Compare commits

..
Author SHA1 Message Date
gitlawr 207cef350f refactor(models): drop redundant effect dep and cast
Address review: scopeOrgId is already folded into clusterOptions (its
useMemo dep), so the effect still fires on scope changes without listing
it; and the next == null guard narrows next to number, so the cast goes.
2026-07-07 16:32:26 +08:00
gitlawr fa3fed8671 fix(models): preselect the scoped default cluster on deploy
The deploy form's cluster select could open blank even when a valid
(default) cluster exists for the current scope. The open handler seeds
the cluster once, but that seed can be empty (options not loaded yet) or
point outside the resolved scope (the scope id and the option list both
settle after mount, and switching scope re-scopes the list), and nothing
re-picked afterwards — so the field stayed empty.

Make the form enforce the invariant directly: whenever the scope or the
available options change, if the current selection is empty or not among
the options, fall back to the scope's default cluster (then a Ready one,
then the first). A still-valid selection is left untouched.
2026-07-07 16:23:38 +08:00
170 changed files with 1130 additions and 5512 deletions
-32
View File
@@ -97,38 +97,6 @@ const handleBChange = (b) => {
};
```
## 4. Controlled input with derived fields
When a controlled field's value comes from **both** user input and a programmatic default (e.g. a percentage picked on a slider, and a default seeded on select / mode-switch), funnel both through **one commit function** — don't duplicate "write field + recompute derived" per call site.
- The `Form.Item`-bound input's `onChange(value)` forwards the value to the commit fn (the field is antd-bound, but pass the value explicitly so the default path can reuse the same fn instead of reading the store).
- Seed defaults by calling the **same** commit fn with the computed value.
- Separate the **commit action** (write field + recompute dependents) from the **render-derive** (read the field → recompute dependents). Keeping the derive standalone lets it re-run on reload/edit where there's no user event.
```ts
// commit action — slider onChange AND default both call this
const commitRatio = (value: number) => {
form.setFieldsValue({ spec: { resources: { ratio: value, cores: 100 } } });
rescaleDerived(); // reads ratio from the form, sets the disabled cpu/ram
};
// render-derive — also called from the edit/reload effect
const rescaleDerived = () => {
const ratio = form.getFieldValue(['spec', 'resources', 'ratio']);
form.setFieldsValue({
spec: {
resources: {
cpu: floorScale(unit.cpu, ratio),
ram: floorScale(unit.ram, ratio)
}
}
});
};
// default seeding reuses the commit fn — one path, not a second copy
const applyDefaults = (item) => commitRatio(Math.min(10, item.maxRatio) || 10);
```
## Related
- Module/file structure for forms lives in the **create-crud-page** skill (section 3).
+1 -12
View File
@@ -124,24 +124,13 @@ Always check `@gpustack/core-ui` first. Frequently reused:
- **Form fields**: `BaseSelect`, `Input` (labeled).
- **Text overflow**: `AutoTooltip`.
- **Icons**: `IconFont`.
- **Tags & status** (4 variants): see the section below.
- **Status display** (success/failed/processing/warning): `StatusTag`.
- **Permission-gated visibility**: `Access` / `useAccess`.
- **Request hooks**: `useRequest` / `useQueryData` / `useQueryDataList`.
- **Table data fetching**: `useTableFetch`.
- **Submit guard** (prevent double-submit): `useSubmitLock`.
- **Tabbed forms**: `ScrollSpyTabs`.
# Tags & status indicators
Four core-ui components cover tag/status display in tables and lists. Pick by **what the value means**, not by how it looks — don't reach for a generic antd `Tag`:
- **`StatusTag`** — semantic status with a **dynamic message/detail** (tooltip, download, extra content). Use when a row's status carries variable text, e.g. a failed job with an error message. Colors come from `StatusColorMap` (error/warning/transitioning/success/inactive).
- **`StatusDot`** — colored dot + short label, **no message**. Use for a plain status/type cell where the value is a fixed enum (e.g. an event-type or log column). Same `StatusColorMap` palette; `inactive` dot is quaternary. If the status needs dynamic text, use `StatusTag` instead.
- **`ThemeTag`** — a **standalone category label** (independent content, e.g. a permission scope or a model name). Default neutral; wraps antd `Tag`.
- **`TextAttribute`** — a small neutral pill that is a **subordinate annotation following a primary text** (e.g. `key-name [custom]`), not a standalone tag. Manages its own leading margin. Two variants: `filled` (default) and `outlined`. Ref the name column in `src/pages/api-keys/hooks/use-keys-columns.tsx`.
Rule of thumb: semantic + dynamic text → `StatusTag`; semantic + fixed enum → `StatusDot`; independent category → `ThemeTag`; annotation of nearby text → `TextAttribute`.
# Dynamic add-item form fields
When building a form, select the add-item component from the **shape of the field's data** (its schema). Match the schema, don't hand-roll a list UI:
+21 -20
View File
@@ -100,15 +100,6 @@ const baseRoutes = [
path: '/models',
redirect: '/models/deployments'
},
{
name: 'userModels',
path: '/models/user-models',
key: 'userModels',
icon: 'icon-models',
selectedIcon: 'icon-models-filled',
defaultIcon: 'icon-models',
component: './llmodels/user-models'
},
{
name: 'modelCatalog',
path: '/models/catalog',
@@ -119,6 +110,16 @@ const baseRoutes = [
access: 'canSeeOrgAdmin',
component: './llmodels/catalog'
},
{
name: 'userModels',
path: '/models/user-models',
key: 'userModels',
icon: 'icon-models',
selectedIcon: 'icon-models-filled',
defaultIcon: 'icon-models',
access: 'canSeeUser',
component: './llmodels/user-models'
},
{
name: 'deployment',
path: '/models/deployments',
@@ -211,16 +212,6 @@ const baseRoutes = [
defaultIcon: 'icon-cloud-outlined',
component: './gpu-service/instances'
},
{
name: 'instanceTypes',
path: '/gpu-service/instance-types',
key: 'gpuServiceInstanceTypes',
icon: 'icon-outline-gpu',
access: 'canSeeOrgAdmin',
selectedIcon: 'icon-filled-gpu',
defaultIcon: 'icon-outline-gpu',
component: './gpu-service/instance-types'
},
{
name: 'templates',
path: '/gpu-service/templates',
@@ -283,7 +274,7 @@ const baseRoutes = [
selectedIcon: 'icon-cluster2-filled',
defaultIcon: 'icon-cluster2-outline',
component: './cluster-management/clusters',
subMenu: ['/resources/clusters/create']
subMenu: ['/resources/clusters/detail', '/resources/clusters/create']
},
{
name: 'workers',
@@ -311,6 +302,16 @@ const baseRoutes = [
selectedIcon: 'icon-credential-filled',
defaultIcon: 'icon-credential-outline',
component: './cluster-management/credentials'
},
{
name: 'clusterDetail',
path: '/resources/clusters/detail',
key: 'clusterDetail',
icon: 'icon-cluster2-outline',
selectedIcon: 'icon-cluster2-filled',
defaultIcon: 'icon-cluster2-outline',
hideInMenu: true,
component: './cluster-management/cluster-detail'
}
]
},
+1 -1
View File
@@ -17,7 +17,7 @@
"@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.42",
"@gpustack/core-ui": "^1.0.35",
"@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6",
+5 -5
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1
version: 7.1.2
'@gpustack/core-ui':
specifier: ^1.0.42
version: 1.0.42(czdvzceysqw7iv6pct2ucnb23e)
specifier: ^1.0.35
version: 1.0.35(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf':
specifier: ^0.1.7
version: 0.1.18
@@ -1481,8 +1481,8 @@ packages:
resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz}
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
'@gpustack/core-ui@1.0.42':
resolution: {integrity: sha512-upMClTHU+xAqd8dlx0w1S9XWHlog5g1hcOCulTk2rmMXqgl66QHfqhEFhJnWIGe1xc8tEj6rW3r5Sirif+qswA==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.42.tgz}
'@gpustack/core-ui@1.0.35':
resolution: {integrity: sha512-MaEmCM3FikeKZdUgDSyUtIfXtNMbNOLeFiTynl6DcKEraUOpk6tgQXPDginBY3uXuisG4hzoIQbpHHkzaYiQew==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.35.tgz}
peerDependencies:
'@ant-design/icons': ^6.1.0
'@ant-design/pro-components': 3.1.0-0
@@ -10802,7 +10802,7 @@ snapshots:
'@formatjs/intl-utils@2.3.0': {}
'@gpustack/core-ui@1.0.42(czdvzceysqw7iv6pct2ucnb23e)':
'@gpustack/core-ui@1.0.35(czdvzceysqw7iv6pct2ucnb23e)':
dependencies:
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<!-- 顶面填充(亮面,高透明度) -->
<linearGradient id="cube-top-grad" x1="12" y1="2" x2="12" y2="12" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#f759ab" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#c41d7f" stop-opacity="0.1"/>
</linearGradient>
<!-- 左侧面填充(暗面,低透明度) -->
<linearGradient id="cube-left-grad" x1="2" y1="7" x2="12" y2="17" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#c41d7f" stop-opacity="0.12"/>
<stop offset="100%" stop-color="#c41d7f" stop-opacity="0.04"/>
</linearGradient>
<linearGradient id="cube-stroke-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#f759ab"/>
<stop offset="100%" stop-color="#c41d7f"/>
</linearGradient>
</defs>
<!-- 顶面填充 -->
<polygon points="12,2 21.5,6.7 12,11.5 2.5,6.7" fill="url(#cube-top-grad)" />
<!-- 左侧面填充 -->
<polygon points="2.5,6.7 12,11.5 12,21.3 2.5,16.5" fill="url(#cube-left-grad)" />
<!-- 立方体全纯线外骨架(细化为圆角衔接) -->
<path d="M12 2L2.5 6.7M12 2l9.5 4.7M21.5 6.7L12 11.5M2.5 6.7L12 11.5M2.5 6.7v9.8l9.5 4.8M21.5 6.7v9.8l-9.5 4.8M12 11.5v9.8" stroke="url(#cube-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-22
View File
@@ -1,22 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<!-- 1. 定义专属微通透渐变填充 -->
<linearGradient id="img-fill-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#d46b08" stop-opacity="0.12"/>
<stop offset="100%" stop-color="#d46b08" stop-opacity="0.04"/>
</linearGradient>
<!-- 2. 定义边框高精度渐变(亮橙到深橙,拉开层次) -->
<linearGradient id="img-stroke-grad" x1="4" y1="4" x2="20" y2="20" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#fa8c16"/>
<stop offset="100%" stop-color="#d46b08"/>
</linearGradient>
</defs>
<!-- 3. 精装底色充填层 -->
<rect x="3" y="3" width="18" height="18" rx="4" fill="url(#img-fill-grad)" />
<!-- 4. 高级柔和微圆角边框层 -->
<rect x="3" y="3" width="18" height="18" rx="4" stroke="url(#img-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 内部几何现代山脉线条 -->
<path d="M3 16l4-4a2 2 0 0 1 2.8 0l5.2 5.2M13 15l2.5-2.5a2 2 0 0 1 2.8 0l2.7 2.7" stroke="url(#img-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 标志性通透小钻石 -->
<rect x="14" y="6" width="4" height="4" rx="1.5" transform="rotate(45 16 8)" fill="#fa8c16" fill-opacity="0.3" stroke="url(#img-stroke-grad)" stroke-width="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-17
View File
@@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id="chat-fill-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#389e0d" stop-opacity="0.1"/>
<stop offset="100%" stop-color="#389e0d" stop-opacity="0.02"/>
</linearGradient>
<linearGradient id="chat-stroke-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#73d13d"/>
<stop offset="100%" stop-color="#389e0d"/>
</linearGradient>
</defs>
<!-- 主现代对话框体(全部改为圆润R角,精装填充) -->
<path d="M18 4H6a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h7.5l3.5 3.5a1 1 0 0 0 1.5-.5V17a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3z" fill="url(#chat-fill-grad)" stroke="url(#chat-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 内部通透对话线条(细化为圆角代码采样块感) -->
<rect x="7" y="8" width="8" height="1.5" rx="0.75" fill="#73d13d" fill-opacity="0.3"/>
<rect x="7" y="11.5" width="10" height="1.5" rx="0.75" fill="#73d13d" fill-opacity="0.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-19
View File
@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id="rank-fill-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#08979c" stop-opacity="0.1"/>
<stop offset="100%" stop-color="#08979c" stop-opacity="0.01"/>
</linearGradient>
<linearGradient id="rank-stroke-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#36cfc9"/>
<stop offset="100%" stop-color="#08979c"/>
</linearGradient>
</defs>
<!-- 数据权重条(全部改为高级圆角) -->
<rect x="11" y="4" width="10" height="2.5" rx="1.25" fill="#36cfc9" fill-opacity="0.1" stroke="url(#rank-stroke-grad)" stroke-width="1.5"/>
<rect x="11" y="9" width="7.5" height="2.5" rx="1.25" fill="#36cfc9" fill-opacity="0.05" stroke="url(#rank-stroke-grad)" stroke-width="1.5"/>
<rect x="11" y="14" width="5" height="2.5" rx="1.25" stroke="url(#rank-stroke-grad)" stroke-width="1.5"/>
<!-- 基准线与立体双向指引箭头(优化为圆角) -->
<path d="M3 17l3 3 3-3M6 4v16" stroke="url(#rank-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.5 5.5L6 4l1.5 1.5" stroke="url(#rank-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-18
View File
@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id="stt-fill-grad" x1="12" y1="3" x2="12" y2="14" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#40a9ff" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#1677ff" stop-opacity="0.05"/>
</linearGradient>
<linearGradient id="stt-stroke-grad" x1="12" y1="3" x2="12" y2="21" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#40a9ff"/>
<stop offset="100%" stop-color="#1677ff"/>
</linearGradient>
</defs>
<!-- 麦克风核心体:精装通透填充 -->
<rect x="8.5" y="3" width="7" height="11" rx="3.5" fill="url(#stt-fill-grad)" stroke="url(#stt-stroke-grad)" stroke-width="1.8" />
<!-- 内部音膜立体结构线(细化为点状) -->
<line x1="10" y1="8" x2="14" y2="8" stroke="url(#stt-stroke-grad)" stroke-width="1" stroke-dasharray="1 2"/>
<!-- 悬挂外托架与底座(全部改为高级圆角) -->
<path d="M5 10a7 7 0 0 0 14 0M12 17v4M8 21h8" stroke="url(#stt-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-23
View File
@@ -1,23 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<!-- 1. 核心高通透深蓝渐变充填 -->
<linearGradient id="tts-v2-fill" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#2f54eb" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#1d39c4" stop-opacity="0.03"/>
</linearGradient>
<!-- 2. 精准调校的专属蓝色渐变边框 -->
<linearGradient id="tts-v2-stroke" x1="4" y1="4" x2="20" y2="20" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#2f54eb"/>
<stop offset="100%" stop-color="#1d39c4"/>
</linearGradient>
</defs>
<!-- 左侧:低频辅助声波(大间距,带现代圆角) -->
<rect x="4" y="8" width="2.2" height="8" rx="1.1" stroke="url(#tts-v2-stroke)" stroke-width="1.8" stroke-linecap="round"/>
<!-- 中央:核心高频声波主体(拉大宽度,注入通透水晶质感) -->
<rect x="10.4" y="2" width="3.2" height="20" rx="1.6" fill="url(#tts-v2-fill)" stroke="url(#tts-v2-stroke)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 右侧:高频衰减声波(保持几何对称与呼吸感) -->
<rect x="17.8" y="5" width="2.2" height="14" rx="1.1" stroke="url(#tts-v2-stroke)" stroke-width="1.8" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-3
View File
@@ -1,3 +0,0 @@
.ant-alert-with-description .ant-alert-title {
line-height: 1;
}
-1
View File
@@ -1,5 +1,4 @@
@import './table.less';
@import './alert.less';
.m-b-20 {
margin-bottom: 20px;
-31
View File
@@ -9,15 +9,6 @@
}
}
@keyframes tableEmptyFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.scroll-table {
.ant-table {
.ant-table-container {
@@ -27,27 +18,5 @@
scrollbar-color: var(--color-scrollbar-thumb) transparent;
}
}
// Reserve a stable block for the empty/loading state so the first-load
// spinner and the empty result occupy the same height as eventual data —
// this removes the layout jump when entering the page. Scoped to
// `.ant-table-content` so it only targets x-scroll tables (whose empty
// row lives here) and leaves fixed-height `scroll.y` tables untouched.
// Height must match the `minHeight` passed to <NoResult> in
// use-no-resource-result.
.ant-table-content {
.ant-table-placeholder {
> .ant-table-cell {
height: calc(100vh - 300px);
}
// NoResult renders nothing while loading and mounts an <Empty> only
// once the request settles, so this fires exactly when the empty
// state appears — a seamless fade-in instead of a hard pop.
.ant-empty {
animation: tableEmptyFadeIn 0.3s ease-in-out;
}
}
}
}
}
+2 -3
View File
@@ -1,10 +1,10 @@
import { GPUStackVersionAtom } from '@/atoms/user';
import { getAtomStorage } from '@/atoms/utils';
import VersionInfo, { modalConfig } from '@/components/version-info';
import externalLinks from '@/constants/external-links';
import { useIntl } from '@umijs/max';
import { Button, Divider, Modal, Typography } from 'antd';
import { createStyles } from 'antd-style';
import { useAtomValue } from 'jotai';
import styled from 'styled-components';
const CompanyWrapper = styled.div`
@@ -35,7 +35,6 @@ const Footer: React.FC = () => {
const intl = useIntl();
const [modal, contextHolder] = Modal.useModal();
const { styles } = useStyles();
const version = useAtomValue(GPUStackVersionAtom);
const showVersion = () => {
modal.info({
@@ -74,7 +73,7 @@ const Footer: React.FC = () => {
</Button>
<Divider orientation="vertical" />
<Button type="link" size="small" onClick={showVersion}>
{version?.version}
{getAtomStorage(GPUStackVersionAtom)?.version}
</Button>
</div>
</div>
+2 -3
View File
@@ -1,7 +1,6 @@
import Logo from '@/assets/images/gpustack-logo.png';
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
import externalLinks from '@/constants/external-links';
import { useLogo } from '@/hooks/use-logo';
import { Button } from 'antd';
import { useAtom } from 'jotai';
import React from 'react';
@@ -19,7 +18,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
isProd,
isDev
} = gpuStackVersionAtom;
const { sidebarLogo } = useLogo();
// user info
const { is_admin } = userDataAtom || {};
@@ -31,7 +30,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
return (
<div className="version-box">
<div className="img">
<img src={sidebarLogo || Logo} alt="logo" />
<img src={Logo} alt="logo" />
</div>
<div className="ver">
-4
View File
@@ -43,10 +43,6 @@ export default {
DatePicker: {
fontSizeLG: 14
},
Alert: {
withDescriptionPadding: '12px 16px',
withDescriptionIconSize: 18
},
Menu: {
iconSize: 16,
iconMarginInlineEnd: 12,
+3 -10
View File
@@ -26,8 +26,8 @@ export default {
cellPaddingInline: 16,
cellPaddingBlock: 6,
cellFontSize: 14,
rowSelectedHoverBg: 'rgb(247 247 247)',
rowHoverBg: 'rgb(247 247 247)',
rowSelectedHoverBg: 'rgb(249 249 249)',
rowHoverBg: 'rgb(249 249 249)',
rowSelectedBg: 'transparent',
headerSortActiveBg: 'transparent',
headerSortHoverBg: 'transparent',
@@ -47,13 +47,6 @@ export default {
DatePicker: {
fontSizeLG: 14
},
Alert: {
withDescriptionPadding: '12px 16px',
withDescriptionIconSize: 18
},
Card: {
headerHeight: 50
},
Menu: {
iconSize: 16,
iconMarginInlineEnd: 12,
@@ -123,7 +116,7 @@ export default {
borderRadiusLG: 6,
borderRadius: 4,
borderRadiusSM: 3,
colorBgContainer: '#fdfdfd',
colorBgContainer: '#fff',
fontSize: 14,
motion: true
}
+15 -2
View File
@@ -21,6 +21,8 @@ html {
--color-fill-sider: #f4f5f4;
--color-bg-1: #f4f5f4;
--color-scroll-bg: #d9d9d9;
--color-fill-2: #fff;
--color-fill-3: #f3f6fa;
--color-logs-bg: #1e1e1e;
--color-logs-text: #d4d4d4;
--layout-content-blockpadding: 24px;
@@ -74,6 +76,7 @@ html {
--ant-rate-star-color: #fadb14;
--color-fill-spin-bg: rgba(255, 255, 255, 15%);
--width-tooltip-max: 420px;
--color-bg-tooltip: '#fff';
--color-modal-content-bg: rgba(255, 255, 255, 90%);
--color-modal-box-shadow: 0 4px 16px rgba(0, 0, 0, 10%);
--color-spotlight-bg: rgba(255, 255, 255, 100%);
@@ -88,7 +91,6 @@ html {
// ======== container ============
--color-border-container: #ededed;
--color-text-table-header: #71717a;
--seal-table-row-min-height: 68px;
}
html[data-theme='realDark'] {
@@ -98,6 +100,7 @@ html[data-theme='realDark'] {
--color-editor-dark: #00101f;
--color-editor-light: #fafafa;
--color-fill-spin-bg: rgba(55, 55, 55, 50%);
--color-bg-tooltip: #424242;
--color-editor-header-bg: #292929;
--color-progress-text: rgba(255, 255, 255, 80%);
--color-modal-content-bg: #1f1f1f;
@@ -112,9 +115,19 @@ html[data-theme='realDark'] {
.ant-result-image {
opacity: 0.9;
}
.ant-pro-page-container-affix .ant-pro-page-container-warp {
background-color: #292929 !important;
transition: none !important;
}
}
html[data-theme='light'] {
.ant-pro-page-container-affix .ant-pro-page-container-warp {
background-color: #fff !important;
transition: none !important;
}
background-color: #f4f5f6;
}
@@ -197,7 +210,7 @@ body {
tr > td {
border-bottom: none;
height: var(--seal-table-row-min-height);
height: 68px;
}
}
+2 -8
View File
@@ -40,11 +40,6 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
}
});
const cancelWatch = useMemoizedFn(() => {
chunkRequestRef.current?.current?.cancel?.();
listRequestTokenRef.current?.cancel?.();
});
const queryAllDataList = async (
params: Global.SearchParams,
options?: any
@@ -83,15 +78,14 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
useEffect(() => {
createWatchChunkRequest();
return () => {
cancelWatch();
chunkRequestRef.current?.cancel?.();
listRequestTokenRef.current?.cancel?.();
};
}, []);
return {
watchDataList,
setWatchDataList,
startWatch: createWatchChunkRequest,
cancelWatch,
deleteItemFromCache: handleDeleteItemFromCache
};
}
-4
View File
@@ -4,7 +4,6 @@ import VersionInfo, { modalConfig } from '@/components/version-info';
import externalLinks from '@/constants/external-links';
import useBodyScroll from '@/hooks/use-body-scroll';
import { logout } from '@/pages/login/apis';
import { getGPUStackPlugin } from '@/plugins';
import { useModel } from '@@/plugin-model';
import {
DiscordOutlined,
@@ -99,7 +98,6 @@ const CustomItem = styled.div`
export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
const { isDarkTheme } = props;
const plugin = getGPUStackPlugin();
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [modal, contextHolder] = Modal.useModal();
const [version] = useAtom(GPUStackVersionAtom);
@@ -288,7 +286,6 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
</NewLabel>
)}
</div>
{!plugin && (
<DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}>
<IconWrapper>
<IconFont
@@ -298,7 +295,6 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
/>
</IconWrapper>
</DropdownActions>
)}
<PluginExtraField name="GlobalSettings" />
<DropdownActions menu={{ ...userMenu }} popupRender={userPopupRender}>
<IconWrapper>
-2
View File
@@ -44,8 +44,6 @@ export default {
'On the Worker that needs to be added, run the following command to join it to the cluster.',
'clusters.create.addCommand.k8s.tips':
'On the Kubernetes cluster that needs to be registered, run the following command to create the Kubernetes resources and register the cluster.',
'clusters.create.addCommand.k8s.version.warning':
'The minimum supported Kubernetes version is 1.23. To use the GPU Service feature, the minimum supported Kubernetes version is 1.27.',
'clusters.create.register.tips':
'On the Kubernetes cluster that needs to be added, run the following command to join its nodes to the cluster.',
'cluster.create.checkEnv.tips':
-23
View File
@@ -2,7 +2,6 @@ export default {
'gpuservice.template': 'GPU Instance Template',
'gpuservice.template.add': 'Add Instance Template',
'gpuservice.template.edit': 'Edit Instance Template',
'gpuservice.template.clone': 'Clone Instance Template',
'gpuservice.template.filter.name': 'Filter by name',
'gpuservice.template.filter.vendor': 'Filter by vendor',
'gpuservice.template.image': 'Image',
@@ -119,34 +118,12 @@ export default {
'No available GPU resources, please choose another instance type.',
'gpuservice.instance.gpuCount.zero':
'CPU-only setup for environment preparation.',
'gpuservice.instance.mode.whole': 'Full GPU',
'gpuservice.instance.mode.sliced': 'By Ratio',
'gpuservice.instance.slice.memoryPercentage': 'VRAM Percentage (%)',
'gpuservice.instance.slice.fullCores': '100% Compute',
'gpuservice.instance.slice.percentage.required':
'Please select or enter a percentage',
'gpuservice.instance.slice.percentage.max':
'The ratio must be between 1% and {count}%',
'gpuservice.instance.stock': 'Stock',
'gpuservice.instance.sliced': 'Sliced',
'gpuservice.instance.sliceable': 'Sliceable',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'OS',
'gpuservice.instance.arch': 'Arch',
'gpuservice.instanceType': 'GPU Instance Type',
'gpuservice.instanceType.add': 'Add Instance Type',
'gpuservice.instanceType.flavor': 'Flavor',
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
'gpuservice.instanceType.product': 'Product',
'gpuservice.instanceType.unitCpu': 'Unit CPU',
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
'gpuservice.instanceType.unitRam': 'Unit RAM',
'gpuservice.instanceType.unitRam.tip': 'RAM allocated per GPU',
'gpuservice.instanceType.localStorage': 'Storage',
'gpuservice.instanceType.localStorage.tip': 'Maximum available disk',
'gpuservice.instanceType.notSliceable': 'Not Sliceable',
'gpuservice.instanceType.filter.name': 'Search by name',
'gpuservice.instance.disk': 'Disk',
'gpuservice.table.count': 'Count',
'gpuservice.instance.disk.system': 'System Disk',
+1 -2
View File
@@ -13,7 +13,7 @@ export default {
'menu.models.modelCatalog': 'Catalog',
'menu.models.catalog': 'Model Catalog',
'menu.models.deployment': 'Deployments',
'menu.models.userModels': 'Models',
'menu.models.userModels': 'My Models',
'menu.models.benchmark': 'Benchmarks',
'menu.models.benchmarkDetail': 'Benchmark Details',
'menu.models.providers': 'Providers',
@@ -47,7 +47,6 @@ export default {
'menu.models.backendsList': 'Inference Backends',
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.instanceTypes': 'Instance Types',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage',
'menu.gpuService.storageTypes': 'Storage Types',
-4
View File
@@ -68,10 +68,6 @@ export default {
'noresult.gpuservice.storage.title': 'No Storage',
'noresult.gpuservice.storage.subTitle': 'No storage has been added yet.',
'noresult.gpuservice.storage.nofound': 'No matching storage found.',
'noresult.gpuservice.instanceType.title': 'No Instance Types',
'noresult.gpuservice.instanceType.subTitle':
'Create an instance type to get started',
'noresult.gpuservice.instanceType.nofound': 'No instance types found',
'noresult.gpuservice.storageType.title': 'No Storage Types',
'noresult.gpuservice.storageType.subTitle':
'No storage types have been added yet.',
-2
View File
@@ -44,8 +44,6 @@ export default {
'On the Worker that needs to be added, run the following command to join it to the cluster.',
'clusters.create.addCommand.k8s.tips':
'登録する Kubernetes クラスターで以下のコマンドを実行し、Kubernetes リソースを作成してクラスターを登録します。',
'clusters.create.addCommand.k8s.version.warning':
'サポートされる Kubernetes の最小バージョンは 1.23 です。GPU Service 機能を使用する場合、サポートされる Kubernetes の最小バージョンは 1.27 です。',
'cluster.create.checkEnv.tips':
'Use the following command to check if the environment is ready.',
'clusters.create.register.tips':
-23
View File
@@ -2,7 +2,6 @@ export default {
'gpuservice.template': 'GPU インスタンステンプレート',
'gpuservice.template.add': 'インスタンステンプレートを追加',
'gpuservice.template.edit': 'インスタンステンプレートを編集',
'gpuservice.template.clone': 'インスタンステンプレートを複製',
'gpuservice.template.filter.name': '名前でフィルター',
'gpuservice.template.filter.vendor': 'ベンダーでフィルター',
'gpuservice.template.image': 'コンテナイメージ',
@@ -118,34 +117,12 @@ export default {
'gpuservice.instance.gpuCount.noAvailable':
'利用可能な GPU リソースがありません。別のインスタンスタイプを選択してください。',
'gpuservice.instance.gpuCount.zero': 'CPU のみを使用し、環境準備用です。',
'gpuservice.instance.mode.whole': 'GPU 全体',
'gpuservice.instance.mode.sliced': '比率で',
'gpuservice.instance.slice.memoryPercentage': 'VRAM の割合(%',
'gpuservice.instance.slice.fullCores': '100% コンピュート',
'gpuservice.instance.slice.percentage.required':
'パーセンテージを選択または入力してください',
'gpuservice.instance.slice.percentage.max':
'比率は 1% から {count}% の間で指定してください',
'gpuservice.instance.stock': '在庫',
'gpuservice.instance.sliced': '分割',
'gpuservice.instance.sliceable': '分割可能',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'OS',
'gpuservice.instance.arch': 'アーキテクチャ',
'gpuservice.instanceType': 'GPU Instance Type',
'gpuservice.instanceType.add': 'Add Instance Type',
'gpuservice.instanceType.flavor': 'Flavor',
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
'gpuservice.instanceType.product': 'Product',
'gpuservice.instanceType.unitCpu': 'Unit CPU',
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
'gpuservice.instanceType.unitRam': 'Unit RAM',
'gpuservice.instanceType.unitRam.tip': 'RAM allocated per GPU',
'gpuservice.instanceType.localStorage': 'Storage',
'gpuservice.instanceType.localStorage.tip': 'Maximum available disk',
'gpuservice.instanceType.notSliceable': 'Not Sliceable',
'gpuservice.instanceType.filter.name': 'Search by name',
'gpuservice.instance.disk': 'ディスク',
'gpuservice.table.count': '数量',
'gpuservice.instance.disk.system': 'システムディスク',
+6 -7
View File
@@ -41,13 +41,12 @@ export default {
'menu.accessControl.organizations': 'Organizations',
'menu.resources.clusters': 'Clusters',
'menu.resources.credentials': 'Cloud Credentials',
'menu.models.userModels': 'Models',
'menu.models.userModels': 'My Models',
'menu.resources.clusterDetail': 'Cluster Detail',
'menu.resources.clusterCreate': 'Create Cluster',
'menu.models.backendsList': 'Inference Backends',
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.instanceTypes': 'Instance Types',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage',
'menu.gpuService.storageTypes': 'ストレージタイプ',
@@ -63,11 +62,11 @@ export default {
// 6. 'menu.accessControl.apikeys': 'API Keys',
// 7. 'menu.accessControl.users': 'Users',
// 8. 'menu.clusterManagement': 'Cluster Management',
// 9. 'menu.resources.clusters': 'Clusters',
// 10. 'menu.resources.credentials': 'Cloud Credentials',
// 11. 'menu.models.userModels': 'Models'
// 12. 'menu.resources.clusterDetail': 'Cluster Detail',
// 13. 'menu.resources.clusterCreate': 'Create Cluster',
// 9. 'menu.clusterManagement.clusters': 'Clusters',
// 10. 'menu.clusterManagement.credentials': 'Cloud Credentials',
// 11. 'menu.models.userModels': 'My Models'
// 12. 'menu.clusterManagement.clusterDetail': 'Cluster Detail',
// 13. 'menu.clusterManagement.clusterCreate': 'Create Cluster',
// 14. 'menu.models.backendsList': 'Inference Backends',
// 15. 'menu.models.benchmark': 'Benchmarks',
// 15. 'menu.models.provider': 'Provider',
-4
View File
@@ -70,10 +70,6 @@ export default {
'noresult.gpuservice.storage.subTitle':
'ストレージはまだ追加されていません。',
'noresult.gpuservice.storage.nofound': '一致するストレージが見つかりません。',
'noresult.gpuservice.instanceType.title': 'No Instance Types',
'noresult.gpuservice.instanceType.subTitle':
'Create an instance type to get started',
'noresult.gpuservice.instanceType.nofound': 'No instance types found',
'noresult.gpuservice.storageType.title': 'ストレージタイプなし',
'noresult.gpuservice.storageType.subTitle':
'ストレージタイプはまだ追加されていません。',
-2
View File
@@ -44,8 +44,6 @@ export default {
'На воркере, который необходимо добавить, выполните следующую команду, чтобы присоединить его к кластеру.',
'clusters.create.addCommand.k8s.tips':
'На Kubernetes-кластере, который необходимо зарегистрировать, выполните следующую команду, чтобы создать ресурсы Kubernetes и зарегистрировать этот кластер.',
'clusters.create.addCommand.k8s.version.warning':
'Минимальная поддерживаемая версия Kubernetes — 1.23. Для использования функции GPU Service минимальная поддерживаемая версия Kubernetes — 1.27.',
'cluster.create.checkEnv.tips':
'Используйте следующую команду для проверки готовности окружения',
'clusters.create.register.tips':
-23
View File
@@ -2,7 +2,6 @@ export default {
'gpuservice.template': 'Шаблон экземпляра GPU',
'gpuservice.template.add': 'Добавить шаблон экземпляра',
'gpuservice.template.edit': 'Редактировать шаблон экземпляра',
'gpuservice.template.clone': 'Клонировать шаблон экземпляра',
'gpuservice.template.filter.name': 'Фильтр по имени',
'gpuservice.template.filter.vendor': 'Фильтр по производителю',
'gpuservice.template.image': 'Образ',
@@ -117,34 +116,12 @@ export default {
'gpuservice.instance.gpuCount.noAvailable':
'Нет доступных ресурсов GPU, выберите другой тип экземпляра.',
'gpuservice.instance.gpuCount.zero': 'Только CPU, для подготовки окружения.',
'gpuservice.instance.mode.whole': 'Весь GPU',
'gpuservice.instance.mode.sliced': 'По доле',
'gpuservice.instance.slice.memoryPercentage': 'Доля VRAM (%)',
'gpuservice.instance.slice.fullCores': '100% вычислений',
'gpuservice.instance.slice.percentage.required':
'Выберите или введите процент',
'gpuservice.instance.slice.percentage.max':
'Доля должна быть от 1% до {count}%',
'gpuservice.instance.stock': 'Остаток',
'gpuservice.instance.sliced': 'Разделено',
'gpuservice.instance.sliceable': 'Делимый',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'ОС',
'gpuservice.instance.arch': 'Архитектура',
'gpuservice.instanceType': 'GPU Instance Type',
'gpuservice.instanceType.add': 'Add Instance Type',
'gpuservice.instanceType.flavor': 'Flavor',
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
'gpuservice.instanceType.product': 'Product',
'gpuservice.instanceType.unitCpu': 'Unit CPU',
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
'gpuservice.instanceType.unitRam': 'Unit RAM',
'gpuservice.instanceType.unitRam.tip': 'RAM allocated per GPU',
'gpuservice.instanceType.localStorage': 'Storage',
'gpuservice.instanceType.localStorage.tip': 'Maximum available disk',
'gpuservice.instanceType.notSliceable': 'Not Sliceable',
'gpuservice.instanceType.filter.name': 'Search by name',
'gpuservice.instance.disk': 'Диск',
'gpuservice.table.count': 'Количество',
'gpuservice.instance.disk.system': 'Системный диск',
+1 -2
View File
@@ -40,14 +40,13 @@ export default {
'menu.accessControl.organizations': 'Организации',
'menu.resources.clusters': 'Кластеры',
'menu.resources.credentials': 'Облачные аккаунты',
'menu.models.userModels': 'Модели',
'menu.models.userModels': 'Мои модели',
'menu.resources.clusterDetail': 'Детали кластера',
'menu.resources.clusterCreate': 'Создать кластер',
'menu.models.backendsList': 'Бэкенды запуска',
'menu.settings': 'Settings',
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.instanceTypes': 'Instance Types',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage',
'menu.gpuService.storageTypes': 'Типы хранилищ',
-4
View File
@@ -69,10 +69,6 @@ export default {
'noresult.gpuservice.storage.title': 'Нет хранилищ',
'noresult.gpuservice.storage.subTitle': 'Хранилища ещё не добавлены.',
'noresult.gpuservice.storage.nofound': 'Подходящие хранилища не найдены.',
'noresult.gpuservice.instanceType.title': 'No Instance Types',
'noresult.gpuservice.instanceType.subTitle':
'Create an instance type to get started',
'noresult.gpuservice.instanceType.nofound': 'No instance types found',
'noresult.gpuservice.storageType.title': 'Нет типов хранилищ',
'noresult.gpuservice.storageType.subTitle': 'Типы хранилищ ещё не добавлены.',
'noresult.gpuservice.storageType.nofound':
-2
View File
@@ -44,8 +44,6 @@ export default {
'Eklenmesi gereken İşçi Düğümde, kümeye katılması için aşağıdaki komutu çalıştırın.',
'clusters.create.addCommand.k8s.tips':
'Kaydedilmesi gereken Kubernetes kümesinde, Kubernetes kaynaklarını oluşturmak ve kümeyi kaydetmek için aşağıdaki komutu çalıştırın.',
'clusters.create.addCommand.k8s.version.warning':
'Desteklenen minimum Kubernetes sürümü 1.23tür. GPU Service özelliğini kullanmak için desteklenen minimum Kubernetes sürümü 1.27dir.',
'clusters.create.register.tips':
'Eklenmesi gereken Kubernetes kümesinde, düğümlerini kümeye katılması için aşağıdaki komutu çalıştırın.',
'cluster.create.checkEnv.tips':
-23
View File
@@ -2,7 +2,6 @@ export default {
'gpuservice.template': 'GPU Örnek Şablonu',
'gpuservice.template.add': 'Örnek Şablonu Ekle',
'gpuservice.template.edit': 'Örnek Şablonunu Düzenle',
'gpuservice.template.clone': 'Örnek Şablonunu Klonla',
'gpuservice.template.filter.name': 'Ada göre filtrele',
'gpuservice.template.filter.vendor': 'Tedarikçiye göre filtrele',
'gpuservice.template.image': 'İmaj',
@@ -113,34 +112,12 @@ export default {
'gpuservice.instance.gpuCount.noAvailable':
'Kullanılabilir GPU kaynağı yok, lütfen başka bir örnek türü seçin.',
'gpuservice.instance.gpuCount.zero': 'Yalnızca CPU, ortam hazırlığı için.',
'gpuservice.instance.mode.whole': 'Tam GPU',
'gpuservice.instance.mode.sliced': 'Orana Göre',
'gpuservice.instance.slice.memoryPercentage': 'VRAM Yüzdesi (%)',
'gpuservice.instance.slice.fullCores': '%100 İşlem Gücü',
'gpuservice.instance.slice.percentage.required':
'Lütfen bir yüzde seçin veya girin',
'gpuservice.instance.slice.percentage.max':
'Oran %1 ile %{count} arasında olmalıdır',
'gpuservice.instance.stock': 'Stok',
'gpuservice.instance.sliced': 'Bölünmüş',
'gpuservice.instance.sliceable': 'Bölünebilir',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'OS',
'gpuservice.instance.arch': 'Mimari',
'gpuservice.instanceType': 'GPU Instance Type',
'gpuservice.instanceType.add': 'Add Instance Type',
'gpuservice.instanceType.flavor': 'Flavor',
'gpuservice.instanceType.flavor.required': 'Please select an instance type flavor',
'gpuservice.instanceType.product': 'Product',
'gpuservice.instanceType.unitCpu': 'Unit CPU',
'gpuservice.instanceType.unitCpu.tip': 'CPU allocated per GPU',
'gpuservice.instanceType.unitRam': 'Unit RAM',
'gpuservice.instanceType.unitRam.tip': 'RAM allocated per GPU',
'gpuservice.instanceType.localStorage': 'Storage',
'gpuservice.instanceType.localStorage.tip': 'Maximum available disk',
'gpuservice.instanceType.notSliceable': 'Not Sliceable',
'gpuservice.instanceType.filter.name': 'Search by name',
'gpuservice.instance.disk': 'Disk',
'gpuservice.table.count': 'Sayı',
'gpuservice.instance.disk.system': 'Sistem Diski',
+2 -3
View File
@@ -13,12 +13,12 @@ export default {
'menu.models.modelCatalog': 'Katalog',
'menu.models.catalog': 'Model Kataloğu',
'menu.models.deployment': 'Dağıtımlar',
'menu.models.userModels': 'Modeller',
'menu.models.userModels': 'Modellerim',
'menu.models.benchmark': 'Kıyaslamalar',
'menu.models.benchmarkDetail': 'Kıyaslama Detayları',
'menu.models.providers': 'Sağlayıcılar',
'menu.models.routes': 'Yönlendirmeler',
'menu.models.usage': 'Kullanım',
'menu.models.usage': 'Usage',
'menu.modelCatalog': 'Katalog',
'menu.resources': 'Kaynaklar',
'menu.apikeys': 'API Anahtarları',
@@ -47,7 +47,6 @@ export default {
'menu.settings': 'Settings',
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.instanceTypes': 'Instance Types',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage',
'menu.gpuService.storageTypes': 'Depolama Türleri',
-4
View File
@@ -67,10 +67,6 @@ export default {
'noresult.gpuservice.storage.title': 'Depolama Yok',
'noresult.gpuservice.storage.subTitle': 'Henüz depolama eklenmedi.',
'noresult.gpuservice.storage.nofound': 'Eşleşen depolama bulunamadı.',
'noresult.gpuservice.instanceType.title': 'No Instance Types',
'noresult.gpuservice.instanceType.subTitle':
'Create an instance type to get started',
'noresult.gpuservice.instanceType.nofound': 'No instance types found',
'noresult.gpuservice.storageType.title': 'Depolama Türü Yok',
'noresult.gpuservice.storageType.subTitle': 'Henüz depolama türü eklenmedi.',
'noresult.gpuservice.storageType.nofound':
-2
View File
@@ -43,8 +43,6 @@ export default {
'在需要添加的节点上运行以下命令,将其加入到集群中。',
'clusters.create.addCommand.k8s.tips':
'在需要注册的 Kubernetes 集群中运行以下命令,创建 Kubernetes 资源,注册该集群。',
'clusters.create.addCommand.k8s.version.warning':
'Kubernetes 版本最低支持 1.23,如果要使用 GPU Service 功能,Kubernetes 版本最低支持 1.27。',
'clusters.create.register.tips':
'在需要添加的 Kubernetes 集群上运行以下命令,将其中的节点加入到集群中。',
'cluster.create.checkEnv.tips': '使用以下命令检查环境是否准备妥当。',
-21
View File
@@ -2,7 +2,6 @@ export default {
'gpuservice.template': 'GPU 实例模板',
'gpuservice.template.add': '添加实例模板',
'gpuservice.template.edit': '编辑实例模板',
'gpuservice.template.clone': '克隆实例模板',
'gpuservice.template.filter.name': '按名称过滤',
'gpuservice.template.filter.vendor': '按厂商过滤',
'gpuservice.template.image': '镜像',
@@ -108,32 +107,12 @@ export default {
'gpuservice.instance.gpuCount.noAvailable':
'没有可用的 GPU 资源,请选择其他实例类型。',
'gpuservice.instance.gpuCount.zero': '仅使用 CPU,用于环境准备。',
'gpuservice.instance.mode.whole': '整卡',
'gpuservice.instance.mode.sliced': '按比例',
'gpuservice.instance.slice.memoryPercentage': '显存占比(%',
'gpuservice.instance.slice.fullCores': '100% 算力',
'gpuservice.instance.slice.percentage.required': '请选择或输入百分比',
'gpuservice.instance.slice.percentage.max': '比例需在 1% 到 {count}% 之间',
'gpuservice.instance.stock': '库存',
'gpuservice.instance.sliced': '切分',
'gpuservice.instance.sliceable': '可切分',
'gpuservice.instance.memory': '显存',
'gpuservice.instance.ram': '内存',
'gpuservice.instance.os': '系统',
'gpuservice.instance.arch': '架构',
'gpuservice.instanceType': 'GPU 实例类型',
'gpuservice.instanceType.add': '添加实例类型',
'gpuservice.instanceType.flavor': '规格',
'gpuservice.instanceType.flavor.required': '请选择实例类型规格',
'gpuservice.instanceType.product': '商品',
'gpuservice.instanceType.unitCpu': '单位 CPU',
'gpuservice.instanceType.unitCpu.tip': '每 GPU 对应多少 CPU',
'gpuservice.instanceType.unitRam': '单位内存',
'gpuservice.instanceType.unitRam.tip': '每 GPU 对应多少内存',
'gpuservice.instanceType.localStorage': '存储',
'gpuservice.instanceType.localStorage.tip': '最大可用磁盘',
'gpuservice.instanceType.notSliceable': '不可切分',
'gpuservice.instanceType.filter.name': '按名称搜索',
'gpuservice.instance.disk': '磁盘',
'gpuservice.table.count': '数量',
'gpuservice.instance.disk.system': '系统盘',
+1 -2
View File
@@ -12,7 +12,7 @@ export default {
'menu.models.modelList': '部署与管理',
'menu.models.modelCatalog': '模型库',
'menu.models.deployment': '部署',
'menu.models.userModels': '模型广场',
'menu.models.userModels': '我的模型',
'menu.models.benchmark': '基准测试',
'menu.models.benchmarkDetail': '基准测试详情',
'menu.models.providers': '提供商',
@@ -47,7 +47,6 @@ export default {
'menu.models.backendsList': '推理后端',
'menu.gpuService': 'GPU 服务',
'menu.gpuService.instances': 'GPU 实例',
'menu.gpuService.instanceTypes': '实例类型',
'menu.gpuService.templates': '实例模板',
'menu.gpuService.storage': '存储',
'menu.gpuService.storageTypes': '存储类型',
-3
View File
@@ -63,9 +63,6 @@ export default {
'noresult.gpuservice.storage.title': '暂无存储',
'noresult.gpuservice.storage.subTitle': '尚未添加任何存储。',
'noresult.gpuservice.storage.nofound': '未找到匹配的存储',
'noresult.gpuservice.instanceType.title': '暂无实例类型',
'noresult.gpuservice.instanceType.subTitle': '创建一个实例类型以开始使用',
'noresult.gpuservice.instanceType.nofound': '未找到实例类型',
'noresult.gpuservice.storageType.title': '暂无存储类型',
'noresult.gpuservice.storageType.subTitle': '尚未添加任何存储类型。',
'noresult.gpuservice.storageType.nofound': '未找到匹配的存储类型',
@@ -1,208 +0,0 @@
import { convertFileSize } from '@/utils';
import { useIntl } from '@umijs/max';
import { Flex, Popover, theme } from 'antd';
import { createStyles } from 'antd-style';
import type { GlobalToken } from 'antd/es/theme';
import React from 'react';
export interface DualArcGaugeItem {
index: number;
label?: string;
memory: {
total: number; // bytes
used: number; // bytes — outer arc (semantic color)
allocated: number; // bytes — inner arc (blue)
};
}
interface DualArcGaugeProps {
data: DualArcGaugeItem[];
}
// guage external radius: 20, internal radius: 16, stroke width: 2
const CX = 25;
const CY = 24;
const R_OUT = 20;
const R_IN = 16;
const SW = 2.5;
const L_OUT = Math.PI * R_OUT;
const L_IN = Math.PI * R_IN;
const arcPath = (r: number) =>
`M ${CX - r} ${CY} A ${r} ${r} 0 0 1 ${CX + r} ${CY}`;
const P_OUT = arcPath(R_OUT);
const P_IN = arcPath(R_IN);
const clamp01 = (n: number) => Math.min(Math.max(n, 0), 1);
// Used color: green < 75%, yellow < 90%, red >= 90%
const usedColor = (ratio: number, token: GlobalToken) => {
if (ratio >= 0.9) return token.colorError;
if (ratio >= 0.75) return token.colorWarning;
return token.colorSuccess;
};
const useStyles = createStyles(({ css, token }) => ({
row: css`
width: fit-content;
padding: 3px 6px;
border-radius: ${token.borderRadius}px;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: ${token.colorFillTertiary};
}
`,
index: css`
font-size: 12px;
font-weight: 500;
color: ${token.colorTextSecondary};
border-bottom: 1px dashed var(--ant-blue-6);
padding-bottom: 3px;
line-height: 1.2;
`,
popTitle: css`
font-size: 12px;
font-weight: 500;
color: ${token.colorText};
margin-bottom: 6px;
`,
popList: css`
font-size: 12px;
font-variant-numeric: tabular-nums;
`,
metric: css`
white-space: nowrap;
`,
dot: css`
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
`,
value: css`
min-width: 64px;
text-align: right;
font-weight: 500;
color: ${token.colorText};
`,
label: css`
color: ${token.colorTextTertiary};
`
}));
const DualArcGauge: React.FC<DualArcGaugeProps> = ({ data }) => {
const intl = useIntl();
const { styles } = useStyles();
const { token } = theme.useToken();
const trackColor = token.colorFillSecondary;
const allocColor = 'var(--ant-blue-6)';
return (
<Flex vertical gap={2} style={{ width: 'fit-content' }}>
{data.map((item) => {
const { total = 0, used = 0, allocated = 0 } = item.memory || {};
const usedR = total > 0 ? clamp01(used / total) : 0;
const allocR = total > 0 ? clamp01(allocated / total) : 0;
const pct = Math.round(usedR * 100);
const uc = usedColor(usedR, token);
// set dim color for allocated if used is 0, otherwise use the same color as used
const allocDotColor =
allocated > 0 ? allocColor : token.colorTextQuaternary;
const content = (
<div>
<div className={styles.popTitle}>
[{item.index}]{item.label ? ` ${item.label}` : ''} ·{' '}
{convertFileSize(total)}
</div>
<Flex vertical gap={4} className={styles.popList}>
<Flex align="center" gap={6} className={styles.metric}>
<span className={styles.dot} style={{ background: uc }} />
<span className={styles.value}>{convertFileSize(used)}</span>
<span className={styles.label}>
{intl.formatMessage({ id: 'resources.table.used' })}
</span>
</Flex>
<Flex align="center" gap={6} className={styles.metric}>
<span
className={styles.dot}
style={{ background: allocDotColor }}
/>
<span className={styles.value}>
{convertFileSize(allocated)}
</span>
<span className={styles.label}>
{intl.formatMessage({ id: 'resources.table.allocated' })}
</span>
</Flex>
</Flex>
</div>
);
return (
<Popover
key={item.index}
content={content}
placement="right"
trigger={['hover', 'click']}
>
<Flex align="center" gap={6} className={styles.row}>
<svg
viewBox="0 0 50 28"
width={54}
height={30}
style={{ overflow: 'visible', flexShrink: 0 }}
>
<path
d={P_OUT}
fill="none"
strokeWidth={SW}
stroke={trackColor}
/>
<path
d={P_IN}
fill="none"
strokeWidth={SW}
stroke={trackColor}
/>
{usedR > 0 && (
<path
d={P_OUT}
fill="none"
strokeWidth={SW}
stroke={uc}
strokeDasharray={`${(usedR * L_OUT).toFixed(1)} ${L_OUT.toFixed(1)}`}
/>
)}
{allocR > 0 && (
<path
d={P_IN}
fill="none"
strokeWidth={SW}
style={{ stroke: allocColor }}
strokeDasharray={`${(allocR * L_IN).toFixed(1)} ${L_IN.toFixed(1)}`}
/>
)}
<text
x={CX}
y={CY - 0.5}
textAnchor="middle"
fontSize={9.5}
fontWeight={500}
fill={uc}
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{pct}%
</text>
</svg>
{<span className={styles.index}>[{item.index}]</span>}
</Flex>
</Popover>
);
})}
</Flex>
);
};
export default DualArcGauge;
+1 -2
View File
@@ -60,8 +60,7 @@ const ModelTag: React.FC<ModelTagProps> = ({ categoryKey, size }) => {
opacity: 1,
paddingInline: 8,
borderRadius: 12,
transform: 'scale(0.9)',
backgroundColor: 'transparent'
transform: 'scale(0.9)'
}}
color={config.color}
>
@@ -23,11 +23,6 @@ interface NumberSelectionProps {
labelExtra?: React.ReactNode;
maxCount?: number;
tips?: string;
// Explicit preset tick values (e.g. [10,20,...,100] for percentage slicing).
// Overrides the default 1..maxCount sequence.
presetValues?: number[];
// Force the free-input box to show regardless of max/maxCount.
alwaysShowInput?: boolean;
onChange?: (value: number) => void;
}
@@ -44,18 +39,17 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
className,
maxCount = 8,
tips,
presetValues,
alwaysShowInput,
style,
onChange
}) => {
const intl = useIntl();
const showCustomInput = alwaysShowInput || max > maxCount;
const presetItems =
presetValues ??
Array.from({ length: Math.max(0, maxCount) }, (_, i) => i + 1);
if (!presetValues && min <= 0) {
const showCustomInput = max > maxCount;
const presetItems = Array.from(
{ length: Math.max(0, maxCount) },
(_, i) => i + 1
);
if (min <= 0) {
presetItems.unshift(0);
}
const items = presetItems;
@@ -39,20 +39,9 @@
display: flex;
align-items: center;
padding: 0 12px;
padding-right: 2px;
color: var(--ant-color-text-tertiary);
white-space: nowrap;
padding-block: 6px;
width: 100%;
// Spread the label and its labelExtra (e.g. the whole/sliced Segmented)
// to opposite ends of the row.
:global(.label-text) {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
}
.contentWrapper {
width: 100%;
+29 -9
View File
@@ -6,12 +6,10 @@ import {
AutoTooltip,
DropdownButtons,
IconFont,
icons,
TextAttribute,
ThemeTag
icons
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { MenuProps, Tooltip } from 'antd';
import { MenuProps, Tag, Tooltip } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
@@ -141,14 +139,23 @@ const useModelsColumns = ({
key: 'name',
sorter: tableSorter(1),
render: (text: string, record: ListItem) => (
<span className="flex items-center gap-8">
<span className="flex items-center">
<AutoTooltip ghost style={{ maxWidth: 400 }} title={text}>
<span className="text-primary">{text}</span>
</AutoTooltip>
{record.is_custom && (
<TextAttribute>
<Tag
style={{
marginLeft: 8,
borderRadius: 12,
color: 'var(--ant-color-text-tertiary)',
borderColor: 'var(--ant-color-split)',
backgroundColor: 'transparent'
}}
variant="outlined"
>
{intl.formatMessage({ id: 'playground.params.custom' })}
</TextAttribute>
</Tag>
)}
</span>
)
@@ -196,13 +203,26 @@ const useModelsColumns = ({
)}
{(record.scope?.includes('inference') ||
record.scope?.includes('*')) && (
<ThemeTag>
<div
style={{
border: '1px solid var(--ant-color-split)',
color: 'var(--ant-color-text-tertiary)',
backgroundColor: 'var(--ant-color-fill-quaternary)',
borderRadius: 4,
fontSize: 13,
paddingInline: 8,
flexGrow: 0,
maxWidth: '100%',
width: 'max-content',
display: 'flex'
}}
>
<AutoTooltip ghost>
{record.allowed_model_names?.length
? record.allowed_model_names.join(', ')
: intl.formatMessage({ id: 'apikeys.models.all' })}
</AutoTooltip>
</ThemeTag>
</div>
)}
</div>
)
+1 -6
View File
@@ -183,15 +183,11 @@ const APIKeys: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-key" />}
filters={{
..._.omit(queryParams, ['sort_by']),
user_id: queryParams.user_id === '*' ? undefined : queryParams.user_id
}}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({
id: 'noresult.keys.nofound'
})}
@@ -232,7 +228,6 @@ const APIKeys: React.FC = () => {
></FilterBar>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
@@ -120,11 +120,6 @@ const CommunityBackends: React.FC<{
const renderItem = (item: ListItem) => {
return (
<div
style={{
backgroundColor: 'var(--ant-color-bg-container)'
}}
>
<BackendCard
active={currentData?.id === item.id}
data={item}
@@ -132,7 +127,6 @@ const CommunityBackends: React.FC<{
onClick={handleOnClickItem}
actionsRenderer={actionsRenderer}
/>
</div>
);
};
@@ -170,7 +164,6 @@ const CommunityBackends: React.FC<{
renderItem={renderItem}
></BackendCardList>
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
+1 -1
View File
@@ -45,7 +45,7 @@ const Title = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--ant-color-bg-elevated);
background-color: var(--ant-color-bg-container);
font-weight: 600;
padding-top: 8px;
padding-bottom: 8px;
-1
View File
@@ -282,7 +282,6 @@ const BackendList = () => {
onSelect={handleOnSelect}
></BackendCardList>
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
-1
View File
@@ -177,7 +177,6 @@ const Benchmark: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={[]}
+13 -14
View File
@@ -5,7 +5,6 @@ import type { PageActionType } from '@/config/types';
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
import useTableFetch from '@/hooks/use-table-fetch';
import useWatchList from '@/hooks/use-watch-list';
import { getGPUStackPlugin } from '@/plugins';
import {
DeleteModal,
FilterBar,
@@ -15,7 +14,7 @@ import {
TableOrder,
TableProvider
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useIntl, useNavigate } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { message } from 'antd';
import { useAtom } from 'jotai';
@@ -74,7 +73,7 @@ const Clusters: React.FC = () => {
deleteAPI: deleteCluster,
watch: true,
API: CLUSTERS_API,
contentForDelete: 'menu.resources.clusters',
contentForDelete: 'menu.clusterManagement.clusters',
defaultQueryParams: {
// Management view: drop cross-Org cluster_access grants. Org
// Owner only sees the clusters they own here. Pickers that
@@ -83,13 +82,10 @@ const Clusters: React.FC = () => {
mine: true
}
});
const navigate = useNavigate();
const { goToGrafana, ActionButton } = useGranfanaLink({
type: 'cluster'
});
// Cluster Access lives in the enterprise plugin: it contributes the
// row action and this self-controlled drawer, owning its own
// open/close state. OSS just mounts it (nothing without a plugin).
const AccessDrawer = getGPUStackPlugin()?.clusterDetail?.AccessDrawer;
const { watchDataList: allWorkerPoolList } = useWatchList(WORKER_POOLS_API);
const [expandAtom] = useAtom(expandKeysAtom);
const [clusterSession, setClusterSession] = useAtom(clusterSessionAtom);
@@ -276,6 +272,14 @@ const Clusters: React.FC = () => {
);
};
const handleOnCell = useMemoizedFn((record: ClusterListItem, dataIndex) => {
if (dataIndex === 'name') {
navigate(
`/resources/clusters/detail?id=${record.id}&name=${record.name}&page=clusters`
);
}
});
useEffect(() => {
const fetchCredentialList = async () => {
const data = await queryCredentialList({ page: -1 });
@@ -358,14 +362,11 @@ const Clusters: React.FC = () => {
dataList={list}
provider={options.parent?.provider}
clusterId={options.parent?.id}
gridTemplate={options.gridTemplate}
prefixWidth={options.prefixWidth}
columns={options.columns}
/>
);
};
const columns = useClusterColumns(handleSelect);
const columns = useClusterColumns(handleSelect, handleOnCell);
return (
<>
@@ -398,7 +399,6 @@ const Clusters: React.FC = () => {
>
<SealTable
rowKey="id"
emptyMinHeight="calc(100vh - 300px)"
loadChildren={getWorkerPoolList}
sortDirections={TABLE_SORT_DIRECTIONS}
expandedRowKeys={expandedRowKeys}
@@ -420,7 +420,7 @@ const Clusters: React.FC = () => {
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-cluster-outline" />}
filters={_.omit(queryParams, ['sort_by', 'mine'])}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({
id: 'noresult.cluster.nofound'
})}
@@ -482,7 +482,6 @@ const Clusters: React.FC = () => {
onClose={handleClusterModalClose}
></ClusterModal>
{AddWorkerModal}
{AccessDrawer && <AccessDrawer />}
</>
);
};
@@ -1,5 +1,3 @@
import { ExclamationCircleFilled } from '@ant-design/icons';
import { AlertBlockInfo } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Typography } from 'antd';
import RegisterClusterInner from '../register-cluster-inner';
@@ -36,14 +34,6 @@ const K8sRunCommand: React.FC<AddWorkerStepProps> = ({ disabled }) => {
id: 'clusters.create.addCommand.k8s.tips'
})}
</Typography.Paragraph>
<AlertBlockInfo
type="warning"
style={{ marginBottom: 8 }}
icon={<ExclamationCircleFilled />}
message={intl.formatMessage({
id: 'clusters.create.addCommand.k8s.version.warning'
})}
></AlertBlockInfo>
<RegisterClusterInner
registrationInfo={registrationInfo}
currentGPU={currentGPU}
@@ -59,12 +59,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]);
const [submitAttempted, setSubmitAttempted] = useState(false);
// Single source of truth for the K8s cluster type, seeded from the cluster
// being edited. Shared via FormContext so the type selector and the
// GPU-only fields stay in sync deterministically (no cross-component watch).
const [clusterType, setClusterType] = useState<'model' | 'gpu'>(() =>
currentData?.k8s_options?.gpuInstanceOptions ? 'gpu' : 'model'
);
const advanceConfigRef = React.useRef<any>(null);
const systemConfig = useAtomValue(systemConfigAtom);
@@ -93,20 +87,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const next: any = { ...opts };
// "model" clusters must not carry GPU-instance config. The field's UI is
// unmounted when model is selected, but strip it here too so the payload
// never keeps a stale gpuInstanceOptions shape from a prior "gpu" choice.
if (clusterType === 'model') {
next.gpuInstanceOptions = null;
} else if (clusterType === 'gpu' && !next.gpuInstanceOptions) {
// gpuInstanceOptions has no always-mounted Form.Item (its only child,
// the optional static address, may be unmounted or empty), so with
// preserve={false} onFinish's `values` can omit it. Read it straight
// from the store so a "gpu" cluster always carries the field.
next.gpuInstanceOptions =
form.getFieldValue(['k8s_options', 'gpuInstanceOptions']) ?? {};
}
const creds = opts.imageCredentials;
if (Array.isArray(creds)) {
next.imageCredentials = creds.map((c: any) => ({
@@ -240,9 +220,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
};
return (
<FormContext.Provider
value={{ submitAttempted, clusterType, setClusterType }}
>
<FormContext.Provider value={{ submitAttempted }}>
<Form
name="clusterForm"
form={form}
@@ -2,12 +2,10 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { Form } from 'antd';
import _ from 'lodash';
import React, { useEffect, useId, useMemo } from 'react';
import styled from 'styled-components';
import { useFormContext } from '../config/form-context';
import { useStepsContext } from '../config/steps-context';
import { ClusterListItem as ListItem } from '../config/types';
import ImageCredential from './image-credential';
@@ -70,6 +68,13 @@ export const OperatorImageForm: React.FC = () => {
);
};
// The presence of `gpuInstanceOptions` on `k8s_options` is the source of truth
// for whether GPU instances are enabled. Both the cluster-type selector
// (rendered up top) and the static-address field (rendered in the advanced
// section) watch this same path so they stay in sync without sharing local
// state.
const GPU_INSTANCE_OPTIONS_PATH = ['k8s_options', 'gpuInstanceOptions'];
// Visual parity with @gpustack/core-ui's SwitchCard so the selector blends
// in with surrounding form fields: same border, radius, padding, and
// typography. The only differences are the two-column grid layout and an
@@ -96,7 +101,6 @@ const ClusterTypeGrid = styled.div`
`;
const ClusterTypeCard = styled.div<{ $active: boolean }>`
position: relative;
display: flex;
align-items: flex-start;
gap: 10px;
@@ -136,17 +140,6 @@ const ClusterTypeCard = styled.div<{ $active: boolean }>`
}
`;
const ExperimentalTag = styled.span`
position: absolute;
right: 2px;
top: 2px;
padding: 2px;
border-radius: 2px;
font-size: 10px;
font-weight: 400;
background-color: var(--ant-blue-1);
`;
const RadioDot = styled.span<{ $active: boolean }>`
position: relative;
flex-shrink: 0;
@@ -176,30 +169,37 @@ const RadioDot = styled.span<{ $active: boolean }>`
// Card-based selector for cluster type. The two options are mutually exclusive
// and the choice maps directly to the presence/absence of `gpuInstanceOptions`
// on the submitted payload. No standalone form field is registered; the click
// only updates the shared `clusterType` state (see FormContext) — the payload's
// `gpuInstanceOptions` shape is derived from it at submit (see cluster-form's
// normalizeOutgoing), and the static-address field mounts/unmounts off it.
// on the form — "model" clears it, "gpu" seeds it to {} (preserving any
// already-entered static address). No standalone form field is registered;
// state is read via useWatch with `preserve: true` so it tracks updates made
// through setFieldValue.
export const ClusterTypeSelector: React.FC = () => {
const intl = useIntl();
const form = Form.useFormInstance();
const { presetClusterType } = useStepsContext();
const labelId = useId();
// Cluster type is shared, explicit state (see FormContext): the click is the
// source of truth. This replaced a Form.useWatch on an unregistered path that
// did not re-render reliably when cleared to undefined.
const { clusterType, setClusterType } = useFormContext();
const value: 'model' | 'gpu' = clusterType ?? 'model';
const handleSelect = useMemoizedFn((next: 'model' | 'gpu') => {
if (next === value) return;
setClusterType?.(next);
const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
form,
preserve: true
});
const value: 'model' | 'gpu' = gpuInstanceOptions ? 'gpu' : 'model';
const handleSelect = (next: 'model' | 'gpu') => {
if (!form || next === value) return;
if (next === 'gpu') {
form.setFieldValue(
GPU_INSTANCE_OPTIONS_PATH,
form.getFieldValue(GPU_INSTANCE_OPTIONS_PATH) ?? {}
);
} else {
form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined);
}
};
const options: {
key: 'model' | 'gpu';
title: string;
description: string;
experimental?: boolean;
}[] = [
{
key: 'model',
@@ -209,8 +209,7 @@ export const ClusterTypeSelector: React.FC = () => {
{
key: 'gpu',
title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }),
description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' }),
experimental: true
description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' })
}
];
@@ -245,11 +244,6 @@ export const ClusterTypeSelector: React.FC = () => {
}}
>
<RadioDot $active={active} />
{opt.experimental && (
<ExperimentalTag>
{intl.formatMessage({ id: 'common.tag.experimental' })}
</ExperimentalTag>
)}
<div className="body">
<div className="title">{opt.title}</div>
<div className="description">{opt.description}</div>
@@ -267,13 +261,14 @@ export const ClusterTypeSelector: React.FC = () => {
// default container registry and the worker config (节点配置).
export const GpuInstancesStaticAddressForm: React.FC = () => {
const intl = useIntl();
// Visibility tracks the shared cluster-type state (see FormContext), so this
// field mounts/unmounts deterministically with the selector. Its Form.Item is
// the only thing keeping gpuInstanceOptions alive, so unmounting it here (with
// the form's preserve={false}) also clears that path from the store.
const { clusterType } = useFormContext();
// See note in ClusterTypeSelector: watch the full store so this field's
// visibility tracks the selector even before it has mounted its own
// Form.Item.
const enabled = !!Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
preserve: true
});
if (clusterType !== 'gpu') {
if (!enabled) {
return null;
}
@@ -2,14 +2,13 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import {
CellContent,
type ChildGridOptions,
DeleteModal,
ExpandedRowGrid,
RowChildren,
TableRowProvider
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { message } from 'antd';
import { Col, message, Row } from 'antd';
import _ from 'lodash';
import React, { useRef, useState } from 'react';
import { deleteWorkerPool, updateWorkerPool } from '../apis';
@@ -17,10 +16,7 @@ import { ProviderType } from '../config';
import { NodePoolFormData, NodePoolListItem } from '../config/types';
import usePoolsColumns from '../hooks/use-pools-columns';
import AddPool from './add-pool';
interface PoolRowsProps extends Pick<
ChildGridOptions,
'gridTemplate' | 'prefixWidth' | 'columns'
> {
interface PoolRowsProps {
dataList: NodePoolListItem[];
provider: ProviderType;
clusterId: number | string;
@@ -29,35 +25,9 @@ interface PoolRowsProps extends Pick<
const PoolRows: React.FC<PoolRowsProps> = ({
dataList,
provider,
clusterId,
gridTemplate,
prefixWidth = 0,
columns: parentColumns
clusterId
}) => {
const intl = useIntl();
// The child row shares the parent's column grid; cells flow left-to-right and
// only declare a span, keyed on the pool column's OWN dataIndex — never on a
// parent cluster column key. Parent layout: name (1) | provider…state middle
// region | created_at (1) | operations (1). The three middle pool columns
// cover that region: `replicas`→state (last, 1 track), `image_name`→
// models+workers (2 tracks), `instance_type` absorbs the rest (plugins +
// provider + gpus).
const columnCount = parentColumns?.length ?? 0;
const middleSpan = Math.max(columnCount - 3, 1);
const spanFor = (dataIndex: string): number => {
switch (dataIndex) {
case 'instance_type':
return Math.max(middleSpan - 3, 1);
case 'image_name':
return 2;
case 'replicas':
return 1;
default:
// name / created_at / operations align 1:1 with their parent column.
return 1;
}
};
const modalRef = useRef<any>(null);
const [addPoolStatus, setAddPoolStatus] = useState<{
open: boolean;
@@ -92,10 +62,8 @@ const PoolRows: React.FC<PoolRowsProps> = ({
}
};
const handleOnCell = async (
row: NodePoolListItem,
_data: { dataIndex: string; newValue: any; oldValue: any }
) => {
const handleOnCell = async (row: NodePoolListItem, dataIndex: string) => {
console.log('handleOncell===', row, dataIndex);
try {
await updateWorkerPool({
data: row,
@@ -150,29 +118,31 @@ const PoolRows: React.FC<PoolRowsProps> = ({
<>
{dataList?.map((data: NodePoolListItem) => {
return (
<TableRowProvider
<div
key={data.id}
value={{ row: data, onCell: handleOnCell }}
style={{ borderRadius: 'var(--ant-table-header-border-radius)' }}
>
<ExpandedRowGrid
gridTemplate={gridTemplate}
prefixWidth={prefixWidth}
>
{columns.map((col: Record<string, any>) => (
<ExpandedRowGrid.Cell
<TableRowProvider value={{ row: data, onCell: handleOnCell }}>
<RowChildren>
<Row style={{ width: '100%' }} align="middle">
{columns.map((col: Record<string, any>) => {
return (
<Col
key={col.dataIndex || col.key}
span={spanFor(col.dataIndex)}
span={col.span}
style={{
color: 'var(--ant-color-text-secondary)'
paddingInline: 0,
...(col.style || {})
}}
>
<CellContent
{..._.omit(col, ['key', 'style', 'span'])}
></CellContent>
</ExpandedRowGrid.Cell>
))}
</ExpandedRowGrid>
<CellContent {..._.omit(col, ['key'])}></CellContent>
</Col>
);
})}
</Row>
</RowChildren>
</TableRowProvider>
</div>
);
})}
<AddPool
@@ -5,12 +5,6 @@ import { ClusterListItem } from './types';
interface FormContextProps {
currentData?: ClusterListItem;
submitAttempted?: boolean;
// K8s cluster type. `gpuInstanceOptions` on the form is derived from this —
// the selector, the static-address field, and submit all read this single
// source of truth instead of independently watching the (unregistered) form
// path, which did not re-render reliably.
clusterType?: 'model' | 'gpu';
setClusterType?: (type: 'model' | 'gpu') => void;
}
export const FormContext = createContext<FormContextProps>({});
+1 -3
View File
@@ -58,7 +58,7 @@ const Credentials: React.FC = () => {
key: PaginationKey.Credentials,
fetchAPI: queryCredentialList,
deleteAPI: deleteCredential,
contentForDelete: 'menu.resources.credentials'
contentForDelete: 'menu.clusterManagement.credentials'
});
const [, setClusterSession] = useAtom(clusterSessionAtom);
const [isFromCluster, setIsFromCluster] = useAtom(fromClusterCreationAtom);
@@ -157,7 +157,6 @@ const Credentials: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={[]}
@@ -200,7 +199,6 @@ const Credentials: React.FC = () => {
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
tableLayout="fixed"
columns={columns}
dataSource={dataSource.dataList}
@@ -13,7 +13,7 @@ import {
type TableColumnProps as SealColumnProps
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Tooltip } from 'antd';
import { Tooltip, Typography } from 'antd';
import dayjs from 'dayjs';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
@@ -85,15 +85,21 @@ const clusterActionList = [
];
const useClusterColumns = (
handleSelect: (val: string, record: ClusterListItem, item?: any) => void
handleSelect: (val: string, record: ClusterListItem, item?: any) => void,
onCellClick?: (record: ClusterListItem, dataIndex: string) => void
): SealColumnProps[] => {
const intl = useIntl();
const systemConfig = useAtomValue(systemConfigAtom);
const pluginCols = usePluginListColumns('clusters');
// The cluster name is plain text: there is no cluster-detail page
// to route into. A plugin may still contribute extra row actions
// (topology, Cluster Access) via `clusterDetail.useGenerateActions`.
const { useGenerateActions } = getGPUStackPlugin()?.clusterDetail || {};
// The cluster-detail page is shipped in OSS source, but OSS keeps
// it unreachable from the cluster list — the link is only
// surfaced when a plugin opts in via
// `clusterDetail.linkableName`. Without a plugin we render the
// name as plain text (matches the pre-restore behaviour); with one
// we use Typography.Link wired to the parent's `onCellClick`.
const { linkableName: nameLinkable, useGenerateActions } =
getGPUStackPlugin()?.clusterDetail || {};
const actionList =
useGenerateActions?.({ actions: clusterActionList }) || clusterActionList;
@@ -154,7 +160,13 @@ const useClusterColumns = (
render: (text: string, record: ClusterListItem) => (
<>
<AutoTooltip ghost title={text}>
{nameLinkable ? (
<Typography.Link onClick={() => onCellClick?.(record, 'name')}>
{record.name}
</Typography.Link>
) : (
<span className="text-primary">{record.name}</span>
)}
</AutoTooltip>
{record.is_default && (
<Tooltip
@@ -176,7 +188,6 @@ const useClusterColumns = (
dataIndex: 'provider',
sorter: tableSorter(2),
span: spans.provider,
minWidth: 110,
render: (value: string) => (
<AutoTooltip ghost minWidth={20}>
{ProviderLabelMap[value]}
@@ -188,21 +199,20 @@ const useClusterColumns = (
dataIndex: 'gpus',
span: 2,
sorter: tableSorter(3),
width: 100,
render: (value: number) => <span>{value}</span>
},
{
title: intl.formatMessage({ id: 'clusters.table.deployments' }),
dataIndex: 'models',
sorter: tableSorter(4),
width: 100,
span: spans.deployments,
render: (value: number) => <span>{value}</span>
},
{
title: intl.formatMessage({ id: 'resources.nodes' }),
dataIndex: 'workers',
sorter: tableSorter(5),
width: 100,
span: spans.workers,
render: (value: number, record: ClusterListItem) => (
<span>
{record.ready_workers} / {record.workers}
@@ -213,7 +223,6 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: 'state',
span: spans.status,
align: 'center',
render: (value: number, record: ClusterListItem) => (
<StatusTag
statusValue={{
@@ -228,7 +237,7 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
sorter: tableSorter(6),
width: 180,
span: spans.createTime,
render: (value: string) => (
<AutoTooltip ghost minWidth={20}>
{dayjs(value).format('YYYY-MM-DD HH:mm:ss')}
@@ -249,7 +258,7 @@ const useClusterColumns = (
)
}
];
}, [handleSelect, intl, pluginCols]);
}, [handleSelect, onCellClick, intl, pluginCols]);
};
export default useClusterColumns;
@@ -43,9 +43,12 @@ const usePoolsColumns = (
showTitle: false
},
span: 3,
style: {
paddingInline: 'var(--ant-table-cell-padding-inline)'
},
render: (text: string) => (
<AutoTooltip title={text} ghost minWidth={20}>
<span className="text-primary">{text}</span>
{text}
</AutoTooltip>
)
},
@@ -57,6 +60,9 @@ const usePoolsColumns = (
showTitle: false
},
span: 4,
style: {
paddingLeft: 62
},
render: (text: string, record: ListItem) => (
<AutoTooltip
title={
@@ -92,6 +98,9 @@ const usePoolsColumns = (
ellipsis: {
showTitle: false
},
style: {
paddingLeft: 56
},
render: (text: string) => (
<AutoTooltip
title={
@@ -116,6 +125,9 @@ const usePoolsColumns = (
dataIndex: 'replicas',
span: 6,
key: 'replicas',
style: {
paddingLeft: 50
},
editable: {
valueType: 'number',
title: intl.formatMessage({ id: 'models.table.replicas.edit' })
@@ -153,6 +165,9 @@ const usePoolsColumns = (
ellipsis: {
showTitle: false
},
style: {
paddingLeft: 42
},
render: (text: string) => (
<AutoTooltip ghost minWidth={20}>
{dayjs(text).format('YYYY-MM-DD HH:mm:ss')}
@@ -164,6 +179,9 @@ const usePoolsColumns = (
key: 'operations',
dataIndex: 'operations',
span: 3,
style: {
paddingLeft: 36
},
render: (text: string, record: ListItem) => (
<DropdownButtons
items={actionItems}
@@ -22,12 +22,7 @@ export const useQueryClusterList = (options?: { useStateData?: boolean }) => {
loading,
cancel
} = useRequest(
async (params: {
page: number;
perPage?: number;
mine?: boolean;
gpu_instance_enabled?: boolean;
}) => {
async (params: { page: number; perPage?: number }) => {
axiosTokenRef.current?.cancel();
axiosTokenRef.current = createAxiosToken();
const res = await queryClusterList(params, {
@@ -18,17 +18,10 @@ const BasicForm = forwardRef((props: BasicFormProps, ref) => {
console.log(values);
};
// Forward through to the live ClusterForm ref on each call instead of
// snapshotting its methods. ClusterForm rebuilds these closures whenever its
// internal state (e.g. clusterType) changes, but BasicForm does not re-render
// with it — a frozen snapshot would keep calling stale closures (reading the
// initial clusterType) and be null on the first render before the ref attaches.
useImperativeHandle(ref, () => ({
validateFields: (...args: any[]) =>
formRef.current?.validateFields(...args),
getFieldsValue: (...args: any[]) =>
formRef.current?.getFieldsValue(...args),
submit: (...args: any[]) => formRef.current?.submit(...args)
validateFields: formRef.current?.validateFields,
getFieldsValue: formRef.current?.getFieldsValue,
submit: formRef.current?.submit
}));
return (
+16 -33
View File
@@ -2,20 +2,6 @@ import {
BreakdownItem,
UsageBreakdownResponse
} from '@/pages/usage/config/types';
import { withDeletedMark } from '@/pages/usage/utils/deleted-label';
import { getIntl } from '@umijs/max';
// group dimension → the id field inside ``identity.current`` (the backend nulls
// it for deleted entities, so the marker degrades to just "[Deleted]").
const GROUP_ID_KEY: Record<
UsageGroupBy,
'model_id' | 'route_id' | 'user_id' | 'api_key_id'
> = {
model: 'model_id',
route: 'route_id',
user: 'user_id',
api_key: 'api_key_id'
};
export const overviewConfigs = [
{
@@ -102,7 +88,6 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
return groupItem;
}
let baseLabel: string;
if (groupBy === 'model') {
const providerName =
identityValue?.provider_name || groupValue?.provider_name;
@@ -112,18 +97,23 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
rawItem.model_name ||
rawItem.model;
baseLabel =
providerName && modelName
? `${providerName}/${modelName}`
: groupItem?.label || modelName || '-';
} else if (groupBy === 'route') {
baseLabel =
if (providerName && modelName) {
return `${providerName}/${modelName}`;
}
return groupItem?.label || modelName || '-';
}
if (groupBy === 'route') {
return (
groupItem?.label ||
identityValue?.route_name ||
groupValue?.route_name ||
'-';
} else {
baseLabel =
'-'
);
}
return (
groupItem?.label ||
identityValue?.user_name ||
identityValue?.api_key_name ||
@@ -135,15 +125,8 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
rawItem.api_key_name ||
rawItem.access_key ||
rawItem[groupBy] ||
'-';
}
// Mark deleted entities in the chart legend / tooltip as text (a legend can't
// render a tag), matching the usage tabs. The id degrades to just "[Deleted]"
// when the backend nulls ``identity.current`` for a deleted entity.
const deletedWord = getIntl().formatMessage({ id: 'usage.table.deleted' });
const id = groupItem?.identity?.current?.[GROUP_ID_KEY[groupBy]];
return withDeletedMark(baseLabel, groupItem?.deleted, deletedWord, id);
'-'
);
};
export const getUsageResponseItems = (
@@ -6,8 +6,8 @@ import useUserDirectory from '../hooks/use-user-directory';
/**
* Owner tag for template cards, disambiguating same-name templates in
* the admin's cross-tenant view. Renders nothing for non-admin callers
* (gated on `canSeeAdmin`) and when a plugin provides its own
* `OwnerScopeTag` slot.
* (the management page is `mine`-scoped for them) and when a plugin
* provides its own `OwnerScopeTag` slot.
*/
const OwnerTag: React.FC<{ ownerId?: number | null }> = ({ ownerId }) => {
const intl = useIntl();
@@ -1,54 +0,0 @@
import { request } from '@umijs/max';
import { FlavorItem, FormData, ListItem } from '../config/types';
export const GPU_INSTANCE_TYPES_API = '/gpu-instance-types';
export const GPU_INSTANCE_TYPE_FLAVORS_API = '/gpu-instance-type-flavors';
// GET /gpu-instance-types?cluster_id — instance types defined on a cluster.
export async function queryGPUInstanceTypes(
params: { cluster_id: number },
options?: any
) {
return request<{ items: ListItem[] }>(GPU_INSTANCE_TYPES_API, {
method: 'GET',
params,
cancelToken: options?.token
});
}
// GET /gpu-instance-type-flavors?cluster_id — the hardware flavors a new
// instance type can be based on.
export async function queryGPUInstanceTypeFlavors(
params: { cluster_id: number },
options?: any
) {
return request<{ items: FlavorItem[] }>(GPU_INSTANCE_TYPE_FLAVORS_API, {
method: 'GET',
params,
cancelToken: options?.token
});
}
// POST /gpu-instance-types?cluster_id (GPUInstanceTypeCreate).
export async function createGPUInstanceType(params: {
cluster_id: number;
data: FormData;
}) {
return request<ListItem>(GPU_INSTANCE_TYPES_API, {
method: 'POST',
params: { cluster_id: params.cluster_id },
data: params.data
});
}
// DELETE /gpu-instance-types/{name}?cluster_id.
export async function deleteGPUInstanceType(params: {
name: string;
cluster_id: number;
}) {
return request(`${GPU_INSTANCE_TYPES_API}/${params.name}`, {
method: 'DELETE',
params: { cluster_id: params.cluster_id }
});
}
@@ -1,165 +0,0 @@
import useSubmitLock from '@/hooks/use-submit-lock';
import Separator from '@/pages/llmodels/components/separator';
import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Typography, message } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { FlavorItem, FormData } from '../config/types';
import GPUServiceInstanceTypeForm from '../forms';
import useQueryFlavors from '../services/use-query-flavors';
import styles from '../styles/instance-types.module.less';
import FlavorList from './flavor-list';
type AddInstanceTypeModalProps = {
title: string;
open: boolean;
clusterId?: number;
onOk: (values: FormData) => void;
onCancel: () => void;
};
const ColTitle: React.FC<{
children: React.ReactNode;
style?: React.CSSProperties;
}> = ({ children, style }) => (
<Typography.Title
level={3}
style={{
fontSize: 14,
paddingTop: 10,
paddingBottom: 16,
margin: 0,
position: 'sticky',
top: 0,
zIndex: 100,
backgroundColor: 'var(--ant-color-bg-elevated)',
...style
}}
>
{children}
</Typography.Title>
);
const AddInstanceTypeModal: React.FC<AddInstanceTypeModalProps> = ({
title,
open,
clusterId,
onOk,
onCancel
}) => {
const intl = useIntl();
const form = useRef<any>(null);
const { loading, guard, run, release } = useSubmitLock();
const [selectedFlavor, setSelectedFlavor] = useState<FlavorItem | null>(null);
const {
dataList: flavorList,
loading: flavorLoading,
fetchFlavors
} = useQueryFlavors();
// Fetch flavors when the drawer opens and auto-select the first one, so the
// form's flavor-derived fields (group / acceleratable) are always set.
useEffect(() => {
if (!open) {
setSelectedFlavor(null);
return;
}
if (!clusterId) return;
const load = async () => {
const list = await fetchFlavors(clusterId);
setSelectedFlavor(list?.[0] ?? null);
};
load();
}, [open, clusterId]);
const handleSubmit = () => {
if (!selectedFlavor) {
message.warning(
intl.formatMessage({ id: 'gpuservice.instanceType.flavor.required' })
);
return;
}
guard(() => form.current?.submit());
};
const handleCancel = () => {
form.current?.resetFields();
onCancel();
};
const onFinish = async (values: FormData) => {
await run(() => onOk({ ...values }));
};
return (
<GSDrawer
title={title}
open={open}
onClose={handleCancel}
destroyOnHidden
closeIcon={false}
mask={{ closable: false }}
keyboard={false}
styles={{
wrapper: { width: 'min(900px, calc(100vw - 220px))' },
body: { overflowY: 'hidden' }
}}
footer={false}
>
<div className={styles.container}>
<div className={styles.colWrapper}>
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
<div className={styles.panelBody}>
<div className={styles.stickyHead}>
<ColTitle
style={{
paddingBottom: 0
}}
>
{intl.formatMessage({ id: 'gpuservice.instanceType.flavor' })}
</ColTitle>
</div>
<FlavorList
value={selectedFlavor?.name}
dataList={flavorList}
loading={flavorLoading}
onChange={setSelectedFlavor}
/>
</div>
</ColumnWrapper>
<Separator />
</div>
<div className={styles.formWrapper}>
<ColumnWrapper
styles={{ container: { paddingBlock: 0 } }}
footer={
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
loading={loading}
style={{
padding: '16px 24px 8px',
display: 'flex',
justifyContent: 'flex-end'
}}
/>
}
>
<ColTitle>
{intl.formatMessage({ id: 'common.title.config' })}
</ColTitle>
<GPUServiceInstanceTypeForm
ref={form}
open={open}
selectedFlavor={selectedFlavor}
onFinish={onFinish}
onFinishFailed={release}
/>
</ColumnWrapper>
</div>
</div>
</GSDrawer>
);
};
export default AddInstanceTypeModal;
@@ -1,129 +0,0 @@
import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton';
import {
AutoTooltip,
IconFont,
TemplateCard,
ThemeTag
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Empty, Flex, Spin, Typography } from 'antd';
import _ from 'lodash';
import { formatMemoryDisplay } from '../../instances/config';
import { manufactureColorMap } from '../../templates/config';
import { FlavorItem } from '../config/types';
import styles from '../styles/instance-types.module.less';
const { Text } = Typography;
interface FlavorListProps {
value?: string;
dataList: FlavorItem[];
loading?: boolean;
onChange?: (item: FlavorItem) => void;
}
const MetaItem: React.FC<{
icon: string;
label: string;
value?: React.ReactNode;
}> = ({ icon, label, value }) => {
return (
<span className={styles.metaLabel}>
<IconFont className="icon" type={icon} />
{label}: <span className={styles.metaValue}>{value ?? '-'}</span>
</span>
);
};
const FlavorList: React.FC<FlavorListProps> = ({
value,
dataList,
loading,
onChange
}) => {
const intl = useIntl();
if (!dataList.length) {
if (loading) {
return (
<Spin spinning size="middle">
<Flex vertical gap={16} style={{ minHeight: 200 }}>
{_.times(6, (index: number) => (
<FileSkeletonRows key={index} counts={2} itemHeight={96} />
))}
</Flex>
</Spin>
);
}
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
return (
<Flex vertical gap={16}>
{dataList.map((item) => {
const spec = item.spec || {};
const manufacturer = spec.manufacturer || '';
const color = manufactureColorMap[manufacturer] ?? 'purple';
// A generic (no product, no/`generic` manufacturer) flavor is shown as
// "CPU-only" instead of falling back to the raw flavor name.
const isCpuOnly =
!spec.acceleratable &&
!spec.product &&
(!manufacturer || manufacturer.toLowerCase() === 'generic');
const title = isCpuOnly ? 'CPU-only' : spec.product || item.name || '-';
return (
<TemplateCard
key={item.name}
className={styles.flavorCard}
clickable
ghost
hoverable
active={value === item.name}
onClick={() => onChange?.(item)}
>
<Flex vertical gap={12} style={{ width: '100%' }}>
<Flex align="center" justify="space-between" gap={8}>
<div style={{ minWidth: 0, fontWeight: 500 }}>
<AutoTooltip ghost minWidth={20}>
<Text>{title}</Text>
</AutoTooltip>
</div>
{manufacturer && (
<ThemeTag color={color} style={{ fontWeight: 400 }}>
{manufacturer.toUpperCase()}
</ThemeTag>
)}
</Flex>
{/* Memory / sliceable only apply to accelerator (GPU) flavors;
a non-acceleratable (generic) flavor has neither. */}
{spec.acceleratable && (
<Flex wrap gap={16}>
<MetaItem
icon="icon-ram-02"
label={intl.formatMessage({
id: 'gpuservice.instance.memory'
})}
value={formatMemoryDisplay(spec.memory ?? undefined) ?? '-'}
/>
<MetaItem
icon="icon-sliced"
label={intl.formatMessage({
id: 'gpuservice.instance.sliceable'
})}
value={
spec.sliceable
? intl.formatMessage({ id: 'common.table.yes' })
: intl.formatMessage({ id: 'common.table.no' })
}
/>
</Flex>
)}
</Flex>
</TemplateCard>
);
})}
</Flex>
);
};
export default FlavorList;
@@ -1,147 +0,0 @@
import {
AutoTooltip,
DropdownActions,
IconFont,
StatusTag,
TemplateCard,
ThemeTag
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button } from 'antd';
import _ from 'lodash';
import { formatMemoryDisplay } from '../../instances/config';
import { manufactureColorMap } from '../../templates/config';
import { ceilMilliToCore, parseQuantityToGi } from '../../utils';
import {
InstanceTypePhaseLabelMap,
status as phaseStatusMap,
rowActionList
} from '../config';
import { ListItem } from '../config/types';
import styles from '../styles/instance-types.module.less';
interface InstanceTypeCardProps {
data: ListItem;
onDelete?: (record: ListItem) => void;
}
const InstanceTypeCard: React.FC<InstanceTypeCardProps> = ({
data,
onDelete
}) => {
const intl = useIntl();
const spec = data.spec || {};
const unit = spec.unitResources || {};
const phase = data.status?.phase || '';
const manufacturer = spec.manufacturer || '';
const manufacturerColor = manufactureColorMap[manufacturer] ?? 'purple';
const memoryText = formatMemoryDisplay(spec.memory ?? undefined);
// Base resources, formatted into a single "·"-separated line. Falsy parts
// (e.g. a CPU-only type without VRAM) drop out rather than showing "-".
const cpuCores = ceilMilliToCore(unit.cpu ?? null)?.cores;
const ramGi = parseQuantityToGi(unit.ram ?? null)?.value;
const storageGi = parseQuantityToGi(spec.localStorage ?? null)?.value;
const osLabel = _.capitalize(spec.os || '');
const archLabel = _.toUpper(spec.arch || '');
const storageWord = intl.formatMessage({
id: 'gpuservice.instanceType.localStorage'
});
const footerParts = [
cpuCores != null ? `${cpuCores} vCPU` : null,
ramGi != null ? `${ramGi} GiB RAM` : null,
storageGi != null ? `${storageGi} GiB ${storageWord}` : null,
osLabel ? `${osLabel}${archLabel ? ` (${archLabel})` : ''}` : null
].filter(Boolean) as string[];
const handleAction = (item: any) => {
if (item.key === 'delete') {
onDelete?.(data);
}
};
return (
<TemplateCard
className={styles.listCard}
clickable={false}
hoverable
ghost
header={
<div className={styles.header}>
<span className={styles.product}>
<AutoTooltip ghost minWidth={20}>
{spec.product || data.name || '-'}
</AutoTooltip>
</span>
<span className={styles.headerRight}>
<span onClick={(e) => e.stopPropagation()}>
<DropdownActions
menu={{ items: rowActionList, onClick: handleAction }}
>
<Button
icon={<IconFont type="icon-more" />}
size="small"
type="text"
/>
</DropdownActions>
</span>
</span>
</div>
}
>
<div className={styles.card}>
<div className={styles.hero}>
<span className={styles.name}>
<AutoTooltip ghost minWidth={20}>
{data.name || '-'}
</AutoTooltip>
</span>
<span className={styles.memory}>{memoryText || '—'}</span>
</div>
<div className={styles.subline}>
{manufacturer && (
<ThemeTag color={manufacturerColor} style={{ fontWeight: 400 }}>
{manufacturer.toUpperCase()}
</ThemeTag>
)}
{phase ? (
<StatusTag
statusValue={{
status: phaseStatusMap[phase],
text: InstanceTypePhaseLabelMap[phase] || phase,
message: data.status?.phaseMessage || ''
}}
/>
) : null}
{spec.clockSpeed ? <span>{spec.clockSpeed}</span> : null}
<span
className={`${styles.tag} ${
spec.sliceable ? styles.tagSliceable : styles.tagPlain
}`}
>
{spec.sliceable
? intl.formatMessage({ id: 'gpuservice.instance.sliceable' })
: intl.formatMessage({
id: 'gpuservice.instanceType.notSliceable'
})}
</span>
</div>
<div className={styles.divider} />
<div className={styles.footer}>
{footerParts.map((part, index) => (
<span key={part}>
{index > 0 && <span className={styles.dotSep}>·</span>}
{part}
</span>
))}
</div>
</div>
</TemplateCard>
);
};
export default InstanceTypeCard;
@@ -1,31 +0,0 @@
import { ResizeContainer } from '@gpustack/core-ui';
import { Spin } from 'antd';
import { ListItem } from '../config/types';
import InstanceTypeCard from './instance-type-card';
interface InstanceTypeListProps {
dataList: ListItem[];
loading: boolean;
onDelete?: (record: ListItem) => void;
}
const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
dataList,
loading,
onDelete
}) => {
return (
<Spin spinning={loading} size="middle">
<ResizeContainer
defaultSpan={8}
resizable
dataList={dataList}
renderItem={(item: ListItem) => (
<InstanceTypeCard data={item} onDelete={onDelete} />
)}
/>
</Spin>
);
};
export default InstanceTypeList;
@@ -1,47 +0,0 @@
import { StatusMaps } from '@/config';
import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui';
// os is fixed to lowercase "linux" on the wire; the form only ever shows Linux.
export const GPU_INSTANCE_TYPE_OS = 'linux';
export const InstanceTypePhaseValueMap = {
Active: 'Active',
Inactive: 'Inactive',
Preparing: 'Preparing'
};
export const InstanceTypePhaseLabelMap: Record<string, string> = {
[InstanceTypePhaseValueMap.Active]: 'Active',
[InstanceTypePhaseValueMap.Inactive]: 'Inactive',
[InstanceTypePhaseValueMap.Preparing]: 'Preparing'
};
export const status: Record<string, StatusType> = {
[InstanceTypePhaseValueMap.Active]: StatusMaps.success,
[InstanceTypePhaseValueMap.Inactive]: StatusMaps.inactive,
[InstanceTypePhaseValueMap.Preparing]: StatusMaps.transitioning
};
export const ArchOptions = [
{ label: 'amd64', value: 'amd64' },
{ label: 'arm64', value: 'arm64' }
];
// ``icon`` is narrowed to ``any`` so the inferred type doesn't reach into
// the antd icon component's internal path.
export const rowActionList: Array<{
label: string;
key: string;
locale: boolean;
icon: any;
danger?: boolean;
}> = [
{
label: 'common.button.delete',
key: 'delete',
locale: true,
icon: icons.DeleteOutlined,
danger: true
}
];
@@ -1,65 +0,0 @@
export interface UnitResources {
cpu?: string | null;
ram?: string | null;
}
export interface InstanceTypeSpec {
manufacturer?: string | null;
product?: string | null;
family?: string | null;
memory?: string | null;
cores?: string | null;
clockSpeed?: string | null;
sliceable?: boolean;
os?: string | null;
arch?: string | null;
acceleratable?: boolean;
acceleratorGroup?: string | null;
generalGroup?: string | null;
unitResources?: UnitResources | null;
localStorage?: string | null;
}
export interface InstanceTypeStatus {
phase?: string | null;
phaseMessage?: string | null;
}
// Row shape for the management list (GET /gpu-instance-types).
export interface ListItem {
name: string;
spec: InstanceTypeSpec;
status?: InstanceTypeStatus;
}
// Selectable flavor shown in the create drawer's first column
// (GET /gpu-instance-type-flavors). Its acceleratorGroup / generalGroup /
// acceleratable are copied into the created instance type.
export interface FlavorItem {
name: string;
spec: {
manufacturer?: string | null;
product?: string | null;
family?: string | null;
memory?: string | null;
cores?: string | null;
sliceable?: boolean;
acceleratable?: boolean;
acceleratorGroup?: string | null;
generalGroup?: string | null;
};
}
// Body for POST /gpu-instance-types (GPUInstanceTypeCreate).
export interface FormData {
name: string;
spec: {
acceleratorGroup?: string | null;
generalGroup?: string | null;
acceleratable?: boolean;
os: string;
arch?: string | null;
unitResources?: UnitResources;
localStorage?: string | null;
};
}
@@ -1,228 +0,0 @@
import { validateLabelNameRegxFor63 } from '@/config';
import {
Input as CInput,
InputNumber,
Select as SealSelect,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { ArchOptions, GPU_INSTANCE_TYPE_OS } from '../config';
import { FlavorItem, FormData } from '../config/types';
// RAM / storage are entered as a plain number in GB but stored/submitted as a
// "Gi" quantity string. These drive the FormItem's submit (`normalize`) and
// display (`getValueProps`) conversions.
const giNormalize = (value?: number | string | null) =>
value ? `${value}Gi` : undefined;
const giValueProps = (value?: string | null) => ({
value: value ? String(value).replace(/Gi$/i, '') : ''
});
interface InstanceTypeFormProps {
ref?: any;
open: boolean;
// The flavor picked in the drawer's first column. Its acceleratorGroup /
// generalGroup / acceleratable are copied into the created instance type.
selectedFlavor?: FlavorItem | null;
onFinish: (values: FormData) => Promise<void>;
onFinishFailed?: (errorInfo: any) => void;
}
const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef(
(props, ref) => {
const { open, selectedFlavor, onFinish, onFinishFailed } = props;
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
const [form] = Form.useForm<FormData>();
// A non-acceleratable (generic) flavor has no per-GPU concept, so unit CPU
// is fixed to 1 and the field is disabled.
const acceleratable = !!selectedFlavor?.spec?.acceleratable;
useEffect(() => {
if (!open) {
form.resetFields();
return;
}
form.setFieldsValue({
spec: {
arch: ArchOptions[0].value
}
} as any);
}, [open, form]);
// Force unit CPU to 1 whenever the picked flavor is not acceleratable.
useEffect(() => {
if (!open || acceleratable) return;
form.setFieldValue(['spec', 'unitResources', 'cpu'], 1);
}, [open, acceleratable, form]);
useImperativeHandle(ref, () => ({
submit: () => {
form.submit();
},
resetFields: () => {
form.resetFields();
}
}));
const handleFinish = async (values: FormData) => {
// The hardware group / acceleratable flags are not user-editable; they
// come from the chosen flavor. os is fixed to lowercase "linux". ram /
// localStorage already carry the "Gi" suffix from the FormItem normalize.
const cpu = values.spec?.unitResources?.cpu;
await onFinish({
name: values.name,
spec: {
acceleratorGroup: selectedFlavor?.spec?.acceleratorGroup ?? null,
generalGroup: selectedFlavor?.spec?.generalGroup ?? null,
acceleratable: selectedFlavor?.spec?.acceleratable ?? false,
os: GPU_INSTANCE_TYPE_OS,
arch: values.spec?.arch ?? null,
unitResources: {
cpu: cpu != null && cpu !== '' ? String(cpu) : null,
ram: values.spec?.unitResources?.ram ?? null
},
localStorage: values.spec?.localStorage ?? null
}
});
};
return (
<Form
name="gpuServiceInstanceTypeForm"
form={form}
onFinish={handleFinish}
onFinishFailed={onFinishFailed}
preserve={false}
>
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: getRuleMessage('input', 'common.table.name')
},
{
pattern: validateLabelNameRegxFor63,
message: intl.formatMessage({ id: 'gpuservice.form.rule.name' })
}
]}
>
<CInput.Input
label={intl.formatMessage({ id: 'common.table.name' })}
required
/>
</Form.Item>
<Form.Item>
<CInput.Input
disabled
value="Linux"
label={intl.formatMessage({ id: 'gpuservice.instance.os' })}
/>
</Form.Item>
<Form.Item<FormData>
name={['spec', 'arch']}
rules={[
{
required: true,
message: getRuleMessage('select', 'gpuservice.instance.arch')
}
]}
>
<SealSelect
label={intl.formatMessage({ id: 'gpuservice.instance.arch' })}
required
options={ArchOptions}
/>
</Form.Item>
<Form.Item<FormData>
name={['spec', 'unitResources', 'cpu']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'gpuservice.instanceType.unitCpu'
)
}
]}
>
<InputNumber
min={0}
disabled={!acceleratable}
style={{ width: '100%' }}
label={intl.formatMessage({
id: 'gpuservice.instanceType.unitCpu'
})}
description={intl.formatMessage({
id: 'gpuservice.instanceType.unitCpu.tip'
})}
required
/>
</Form.Item>
<Form.Item<FormData>
name={['spec', 'unitResources', 'ram']}
normalize={giNormalize}
getValueProps={giValueProps}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'gpuservice.instanceType.unitRam'
)
}
]}
>
<InputNumber
min={0}
style={{ width: '100%' }}
label={`${intl.formatMessage({
id: 'gpuservice.instanceType.unitRam'
})} (GB)`}
description={intl.formatMessage({
id: 'gpuservice.instanceType.unitRam.tip'
})}
required
/>
</Form.Item>
<Form.Item<FormData>
name={['spec', 'localStorage']}
normalize={giNormalize}
getValueProps={giValueProps}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'gpuservice.instanceType.localStorage'
)
}
]}
>
<InputNumber
min={0}
style={{ width: '100%' }}
label={`${intl.formatMessage({
id: 'gpuservice.instanceType.localStorage'
})} (GB)`}
description={intl.formatMessage({
id: 'gpuservice.instanceType.localStorage.tip'
})}
required
/>
</Form.Item>
</Form>
);
}
);
export default GPUServiceInstanceTypeForm;
@@ -1,37 +0,0 @@
import useBodyScroll from '@/hooks/use-body-scroll';
import { useState } from 'react';
const useCreateInstanceTypeModal = () => {
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
title: string;
}>({
open: false,
title: ''
});
const openModal = (title: string) => {
setOpenModalStatus({
open: true,
title
});
saveScrollHeight();
};
const closeModal = () => {
setOpenModalStatus({
open: false,
title: ''
});
restoreScrollHeight();
};
return {
openInstanceTypeModalStatus: openModalStatus,
openInstanceTypeModal: openModal,
closeInstanceTypeModal: closeModal
};
};
export default useCreateInstanceTypeModal;
@@ -1,224 +0,0 @@
import { QuestionCircleOutlined } from '@ant-design/icons';
import {
AutoTooltip,
DropdownButtons,
icons,
StatusTag,
ThemeTag
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Space, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/lib/table';
import _ from 'lodash';
import { useMemo } from 'react';
import { formatMemoryDisplay } from '../../instances/config';
import { manufactureColorMap } from '../../templates/config';
import {
ceilMilliToCore,
formatManufacturer,
parseQuantityToGi
} from '../../utils';
import { InstanceTypePhaseLabelMap, status as phaseStatusMap } from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void;
}
// DropdownButtons reads `locale` / `props` at runtime; its `items` prop is
// typed as antd's MenuProps['items'], so cast the config to satisfy it.
const rowActions = [
{
label: 'common.button.delete',
key: 'delete',
locale: true,
icon: icons.DeleteOutlined,
props: { danger: true }
}
] as any;
// Column header with an info tooltip (used for the per-GPU resource columns).
const TitleWithTip: React.FC<{ title: string; tip: string }> = ({
title,
tip
}) => (
<Space size={4}>
<span>{title}</span>
<Tooltip title={tip}>
<QuestionCircleOutlined
style={{ color: 'var(--ant-color-text-tertiary)' }}
/>
</Tooltip>
</Space>
);
const useInstanceTypeColumns = ({
handleSelect
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
return useMemo(() => {
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: 'name',
key: 'name',
ellipsis: { showTitle: false },
render: (text: string) => (
<AutoTooltip ghost maxWidth={240} title={text}>
<span className="text-primary">{text || '-'}</span>
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'gpuservice.instanceType.product' }),
dataIndex: ['spec', 'product'],
key: 'product',
ellipsis: { showTitle: false },
render: (text: string) => (
<AutoTooltip ghost maxWidth={200}>
{text || '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'resources.table.vendor' }),
dataIndex: ['spec', 'manufacturer'],
key: 'manufacturer',
ellipsis: { showTitle: false },
render: (value: string) =>
value ? (
<ThemeTag
color={manufactureColorMap[value] ?? 'purple'}
style={{ fontWeight: 400, width: 'fit-content' }}
>
{formatManufacturer(value)}
</ThemeTag>
) : (
'-'
)
},
{
title: intl.formatMessage({ id: 'gpuservice.instance.memory' }),
dataIndex: ['spec', 'memory'],
key: 'memory',
ellipsis: { showTitle: false },
// Non-acceleratable (generic) types have no VRAM concept → N/A.
render: (value: string, record: ListItem) =>
record.spec?.acceleratable
? formatMemoryDisplay(value ?? undefined) || '-'
: 'N/A'
},
// {
// title: intl.formatMessage({ id: 'gpuservice.instance.sliceable' }),
// dataIndex: ['spec', 'sliceable'],
// key: 'sliceable',
// ellipsis: { showTitle: false },
// render: (value: boolean) =>
// value
// ? intl.formatMessage({ id: 'common.table.yes' })
// : intl.formatMessage({ id: 'common.table.no' })
// },
{
title: (
<TitleWithTip
title={intl.formatMessage({
id: 'gpuservice.instanceType.unitCpu'
})}
tip={intl.formatMessage({
id: 'gpuservice.instanceType.unitCpu.tip'
})}
/>
),
dataIndex: ['spec', 'unitResources', 'cpu'],
key: 'cpu',
ellipsis: { showTitle: false },
render: (value: string) => {
const cores = ceilMilliToCore(value ?? null)?.cores;
return cores != null ? `${cores} vCPU` : '-';
}
},
{
title: (
<TitleWithTip
title={intl.formatMessage({
id: 'gpuservice.instanceType.unitRam'
})}
tip={intl.formatMessage({
id: 'gpuservice.instanceType.unitRam.tip'
})}
/>
),
dataIndex: ['spec', 'unitResources', 'ram'],
key: 'ram',
ellipsis: { showTitle: false },
render: (value: string) => {
const gi = parseQuantityToGi(value ?? null)?.value;
return gi != null ? `${gi} GB` : '-';
}
},
{
title: (
<TitleWithTip
title={intl.formatMessage({
id: 'gpuservice.instanceType.localStorage'
})}
tip={intl.formatMessage({
id: 'gpuservice.instanceType.localStorage.tip'
})}
/>
),
dataIndex: ['spec', 'localStorage'],
key: 'localStorage',
ellipsis: { showTitle: false },
render: (value: string) => {
const gi = parseQuantityToGi(value ?? null)?.value;
return gi != null ? `${gi} GB` : '-';
}
},
{
title: intl.formatMessage({ id: 'gpuservice.instance.os' }),
key: 'os',
ellipsis: { showTitle: false },
render: (_text, record: ListItem) => {
const os = _.capitalize(record.spec?.os || '');
const arch = _.toUpper(record.spec?.arch || '');
if (!os) return '-';
return arch ? `${os} (${arch})` : os;
}
},
{
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: ['status', 'phase'],
key: 'status',
ellipsis: { showTitle: false },
render: (value: string, record: ListItem) =>
value ? (
<StatusTag
statusValue={{
status: phaseStatusMap[value],
text: InstanceTypePhaseLabelMap[value] || value,
message: record.status?.phaseMessage || ''
}}
/>
) : (
'-'
)
},
{
title: intl.formatMessage({ id: 'common.table.operation' }),
key: 'operation',
dataIndex: 'operation',
ellipsis: { showTitle: false },
render: (_text, record: ListItem) => (
<DropdownButtons
items={rowActions}
onSelect={(val: string) => handleSelect(val, record)}
/>
)
}
];
}, [handleSelect, intl]);
};
export default useInstanceTypeColumns;
@@ -1,231 +0,0 @@
import { ProviderValueMap } from '@/pages/cluster-management/config';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import {
BaseSelect,
DeleteModal,
FilterBar,
IconFont,
NoResult
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, Divider, Flex, Table, message } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import PageBox, { HeaderLeft } from '../../_components/page-box';
import { deleteGPUInstanceType } from './apis';
import AddInstanceTypeModal from './components/add-instance-type-modal';
import { FormData, ListItem } from './config/types';
import useCreateInstanceTypeModal from './hooks/use-create-instance-type-modal';
import useInstanceTypeColumns from './hooks/use-instance-type-columns';
import useCreateInstanceType from './services/use-create-instance-type';
import useQueryInstanceTypes from './services/use-query-instance-types';
const GPUServiceInstanceTypes: React.FC = () => {
const intl = useIntl();
const deleteModalRef = useRef<any>(null);
const [clusterId, setClusterId] = useState<number | undefined>();
const [keyword, setKeyword] = useState('');
const [loaded, setLoaded] = useState(false);
const {
clusterList,
fetchClusterList,
loading: clusterLoading
} = useQueryClusterList();
const {
dataList,
loading: instanceTypesLoading,
fetchInstanceTypes
} = useQueryInstanceTypes();
const { fetchData: createInstanceType } = useCreateInstanceType();
const {
openInstanceTypeModalStatus,
openInstanceTypeModal,
closeInstanceTypeModal
} = useCreateInstanceTypeModal();
// Only Kubernetes clusters own GPU instance types.
const k8sClusters = useMemo(
() => clusterList.filter((c) => c.provider === ProviderValueMap.Kubernetes),
[clusterList]
);
// Fetch the visible clusters, default to the first Kubernetes one, then load
// its instance types. Action-driven: subsequent loads fire from the cluster
// picker / refresh, never from an effect dependency.
useEffect(() => {
const init = async () => {
const items = await fetchClusterList({ page: -1 });
const firstK8s = (items || []).find(
(c: any) => c.provider === ProviderValueMap.Kubernetes
);
if (firstK8s?.id != null) {
setClusterId(firstK8s.id);
await fetchInstanceTypes(firstK8s.id);
}
setLoaded(true);
};
init();
}, []);
const handleClusterChange = useMemoizedFn((value: number) => {
setClusterId(value);
setKeyword('');
fetchInstanceTypes(value);
});
const handleRefresh = useMemoizedFn(() => {
if (clusterId != null) {
fetchInstanceTypes(clusterId);
}
});
const handleNameChange = useMemoizedFn(
(e: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value);
}
);
const handleAdd = useMemoizedFn(() => {
openInstanceTypeModal(
intl.formatMessage({ id: 'gpuservice.instanceType.add' })
);
});
const handleModalOk = useMemoizedFn(async (data: FormData) => {
if (clusterId == null) return;
try {
await createInstanceType({ cluster_id: clusterId, data });
closeInstanceTypeModal();
message.success(intl.formatMessage({ id: 'common.message.success' }));
fetchInstanceTypes(clusterId);
} catch (error) {
// handled by the request interceptor
}
});
const handleDelete = useMemoizedFn((record: ListItem) => {
if (clusterId == null) return;
deleteModalRef.current?.show({
content: intl.formatMessage({ id: 'gpuservice.instanceType' }),
operation: 'common.delete.single.confirm',
name: record.name,
async onOk() {
await deleteGPUInstanceType({
name: record.name,
cluster_id: clusterId
});
fetchInstanceTypes(clusterId);
}
});
});
const handleSelect = useMemoizedFn((val: string, record: ListItem) => {
if (val === 'delete') {
handleDelete(record);
}
});
const columns = useInstanceTypeColumns({ handleSelect });
const filteredList = useMemo(() => {
const trimmed = keyword.trim().toLowerCase();
if (!trimmed) return dataList;
return dataList.filter((item) => item.name.toLowerCase().includes(trimmed));
}, [dataList, keyword]);
const hasK8sCluster = k8sClusters.length > 0;
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={instanceTypesLoading || clusterLoading}
loadend={loaded}
dataSource={filteredList}
image={<IconFont type="icon-gpu1" />}
filters={keyword ? { search: keyword } : undefined}
noFoundText={intl.formatMessage({
id: 'noresult.gpuservice.instanceType.nofound'
})}
title={intl.formatMessage({
id: 'noresult.gpuservice.instanceType.title'
})}
subTitle={
hasK8sCluster
? intl.formatMessage({
id: 'noresult.gpuservice.instanceType.subTitle'
})
: intl.formatMessage({ id: 'noresult.resources.k8sCluster' })
}
{...(hasK8sCluster
? {
onClick: handleAdd,
buttonText: intl.formatMessage({ id: 'noresult.button.add' })
}
: {})}
/>
);
};
return (
<>
<HeaderLeft>
<Flex align="center">
<span className="font-600">
{intl.formatMessage({ id: 'gpuservice.instance.types' })}
</span>
<Divider orientation="vertical" style={{ margin: '0 16px' }} />
<BaseSelect
size="small"
variant="borderless"
style={{ minWidth: 160 }}
popupMatchSelectWidth={false}
options={k8sClusters}
value={clusterId}
onChange={handleClusterChange}
/>
</Flex>
</HeaderLeft>
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
inputHolder={intl.formatMessage({
id: 'gpuservice.instanceType.filter.name'
})}
buttonText={intl.formatMessage({
id: 'gpuservice.instanceType.add'
})}
handleSearch={handleRefresh}
handleClickPrimary={hasK8sCluster ? handleAdd : undefined}
handleInputChange={handleNameChange}
widths={{ input: 300 }}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={filteredList}
loading={{
spinning: instanceTypesLoading || clusterLoading,
size: 'middle'
}}
rowKey={(record) => record.name}
pagination={false}
/>
</ConfigProvider>
</PageBox>
<AddInstanceTypeModal
open={openInstanceTypeModalStatus.open}
title={openInstanceTypeModalStatus.title}
clusterId={clusterId}
onCancel={closeInstanceTypeModal}
onOk={handleModalOk}
/>
<DeleteModal ref={deleteModalRef} />
</>
);
};
export default GPUServiceInstanceTypes;
@@ -1,35 +0,0 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { createGPUInstanceType } from '../apis';
import { FormData, ListItem } from '../config/types';
interface CreateInstanceTypeParams {
cluster_id: number;
data: FormData;
}
export default function useCreateInstanceType() {
const fetchDetail = useCallback(
(params: CreateInstanceTypeParams) =>
createGPUInstanceType({
cluster_id: params.cluster_id,
data: params.data
}),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ListItem,
CreateInstanceTypeParams
>({
fetchDetail,
key: 'createInstanceType'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -1,58 +0,0 @@
import { createAxiosToken } from '@/hooks/use-chunk-request';
import { useRequest } from 'ahooks';
import { CancelTokenSource } from 'axios';
import { useEffect, useRef, useState } from 'react';
import { queryGPUInstanceTypeFlavors } from '../apis';
import { FlavorItem } from '../config/types';
// Cluster-scoped flavors for the create drawer's first column. Fetched when
// the drawer opens (and on cluster change), never via an effect dependency.
export default function useQueryFlavors() {
const tokenRef = useRef<CancelTokenSource | null>(null);
const [dataList, setDataList] = useState<FlavorItem[]>([]);
const {
runAsync: fetchFlavors,
loading,
cancel
} = useRequest(
async (clusterId: number) => {
tokenRef.current?.cancel();
tokenRef.current = createAxiosToken();
const res = await queryGPUInstanceTypeFlavors(
{ cluster_id: clusterId },
{ token: tokenRef.current.token }
);
const list = res?.items || [];
setDataList(list);
return list;
},
{
manual: true,
onError: (error: any) => {
if (error?.message === 'CANCEL_PREVIOUS_REQUEST') return;
setDataList([]);
}
}
);
const cancelRequest = () => {
cancel();
tokenRef.current?.cancel('CANCEL_PREVIOUS_REQUEST');
};
useEffect(() => {
return () => {
cancel();
tokenRef.current?.cancel();
};
}, []);
return {
dataList,
loading,
fetchFlavors,
cancelRequest,
setDataList
};
}
@@ -1,60 +0,0 @@
import { createAxiosToken } from '@/hooks/use-chunk-request';
import { useRequest } from 'ahooks';
import { CancelTokenSource } from 'axios';
import { useEffect, useRef, useState } from 'react';
import { queryGPUInstanceTypes } from '../apis';
import { ListItem } from '../config/types';
// Cluster-scoped instance types for the management list. Action-driven:
// call fetchInstanceTypes(clusterId) from the cluster picker / refresh, not
// via an effect dependency.
export default function useQueryInstanceTypes() {
const tokenRef = useRef<CancelTokenSource | null>(null);
const [dataList, setDataList] = useState<ListItem[]>([]);
const {
runAsync: fetchInstanceTypes,
loading,
cancel
} = useRequest(
async (clusterId: number) => {
tokenRef.current?.cancel();
tokenRef.current = createAxiosToken();
const res = await queryGPUInstanceTypes(
{ cluster_id: clusterId },
{ token: tokenRef.current.token }
);
const list = res?.items || [];
setDataList(list);
return list;
},
{
manual: true,
onError: (error: any) => {
// Ignore the synthetic cancel error from switching clusters quickly.
if (error?.message === 'CANCEL_PREVIOUS_REQUEST') return;
setDataList([]);
}
}
);
const cancelRequest = () => {
cancel();
tokenRef.current?.cancel('CANCEL_PREVIOUS_REQUEST');
};
useEffect(() => {
return () => {
cancel();
tokenRef.current?.cancel();
};
}, []);
return {
dataList,
loading,
fetchInstanceTypes,
cancelRequest,
setDataList
};
}
@@ -1,150 +0,0 @@
// ============ create drawer (two-column: flavors | form) ============
.container {
display: flex;
height: 100%;
min-height: 0;
}
.colWrapper {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
}
.formWrapper {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
}
.panelBody {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
}
.stickyHead {
display: flex;
flex-direction: column;
gap: 16px;
position: sticky;
top: 0;
z-index: 10;
background-color: var(--ant-color-bg-elevated);
}
.flavorCard {
height: auto !important;
min-height: 96px;
}
// ============ list card (Linear-style, minimal) ============
.listCard {
height: auto !important;
}
.card {
display: flex;
flex-direction: column;
height: 100%;
}
// --- level 1: identity + status + actions ---
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
width: 100%;
}
.name {
min-width: 0;
font-size: 13px;
color: var(--ant-color-text-tertiary);
}
.headerRight {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
// --- level 2: hero (model + memory) ---
.hero {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-top: 18px;
}
.product {
min-width: 0;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: var(--ant-color-text);
}
.memory {
flex-shrink: 0;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: var(--ant-color-text);
}
.subline {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
min-height: 22px;
font-size: 13px;
color: var(--ant-color-text-tertiary);
}
.tag {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: var(--ant-border-radius-sm);
font-size: 12px;
line-height: 1;
}
.tagSliceable {
color: var(--ant-color-primary);
background-color: var(--ant-color-primary-bg);
}
.tagPlain {
color: var(--ant-color-text-tertiary);
background-color: var(--ant-color-fill-tertiary);
}
// --- level 3: base resources ---
.divider {
height: 1px;
margin: 16px 0;
background-color: var(--ant-color-border-secondary);
opacity: 0.6;
}
.footer {
font-size: 14px;
color: var(--ant-color-text-tertiary);
}
.dotSep {
margin: 0 8px;
color: var(--ant-color-text-quaternary);
}
@@ -12,7 +12,7 @@ import {
export const GPU_SERVICE_INSTANCES_API = '/gpu-instances';
export const GPU_SERVICE_INSTANCES_TYPE_API = '/gpu-instance-types/aggregated';
export const GPU_SERVICE_INSTANCES_TYPE_API = '/gpu-instance-types';
// View logs / events still go through the K8s proxy until the /v2
// /gpu-instances API exposes equivalents. clusterID and namespace come
@@ -1,14 +1,14 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Tag } from 'antd';
import _ from 'lodash';
import styled from 'styled-components';
import { manufactureColorMap } from '../../templates/config';
import { formatManufacturer } from '../../utils';
import { formatMemoryDisplay } from '../config';
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
const Vendors = ['intel'] as const;
const Title = styled.div`
display: flex;
align-items: center;
@@ -58,9 +58,6 @@ interface InstanceTypeItemProps {
interface MetadataSectionProps {
spec: InstanceTypeItemModel['spec'];
// status.onceMaxRequest.acceleratorSliced (max sliceable percentage). Shown
// next to Max for sliceable types.
slicedMaxPercentage?: number;
}
const MetaItem: React.FC<{
@@ -119,95 +116,109 @@ function getInstanceDerived(item: InstanceTypeItemModel) {
ramUnit: spec.unitResourcesParsed?.ram?.value,
os: _.capitalize(spec.os) || '',
arch: spec.arch,
cpuManufacturer: formatManufacturer(cpuManufacturer),
cpuManufacturer: Vendors.includes(cpuManufacturer as any)
? _.capitalize(cpuManufacturer)
: _.toUpper(cpuManufacturer),
cpuUnitCores: spec.unitResourcesParsed?.cpu?.cores
};
}
type MetaEntry = { icon: string; label?: string; value: React.ReactNode };
// All rows share a single grid so columns — and therefore icons — line up
// vertically. Each item is 3 cells (icon/label/value); every item past the
// first adds a leading dot cell, so a row of k items spans 4k-1 cells. A short
// row is padded with a spanning spacer so the next row restarts at column 1.
const renderMetaRow = (items: MetaEntry[], columns: number, rowKey: string) => {
const cells = items.map((item, index) => (
<MetaItem
key={`${rowKey}-${item.icon}`}
showDot={index > 0}
icon={item.icon}
label={item.label}
value={item.value}
/>
));
const remaining = columns - (4 * items.length - 1);
if (remaining > 0) {
cells.push(
<span
key={`${rowKey}-spacer`}
style={{ gridColumn: `span ${remaining}` }}
/>
);
}
return cells;
};
export const InstanceMetadataSection: React.FC<MetadataSectionProps> = ({
spec,
slicedMaxPercentage
spec
}) => {
const intl = useIntl();
const { ramUnit, cpuUnitCores, isGPU, arch } = getInstanceDerived({
const { ramUnit, cpuUnitCores, isGPU, os, arch } = getInstanceDerived({
spec
} as InstanceTypeItemModel);
// Sliceable types append a "Sliceable {n}%" cell to the second row.
const showSliceable = !!spec.sliceable && (slicedMaxPercentage ?? 0) > 0;
const cpuItem: MetaEntry = {
icon: 'icon-cpu',
label: 'CPU',
value: cpuUnitCores || '-'
};
const ramItem: MetaEntry = {
icon: 'icon-ram-02',
label: intl.formatMessage({ id: 'gpuservice.instance.ram' }),
value: ramUnit ? `${ramUnit} GB` : '-'
};
const memoryItem: MetaEntry = {
icon: 'icon-gpu1',
label: intl.formatMessage({ id: 'gpuservice.instance.memory' }),
value: formatMemoryDisplay(spec?.memory ?? undefined) ?? '-'
};
const archItem: MetaEntry = {
icon: 'icon-cube',
label: intl.formatMessage({ id: 'gpuservice.instance.arch' }),
value: _.toUpper(arch) || '-'
};
const maxItem: MetaEntry = {
icon: 'icon-database',
label: intl.formatMessage({ id: 'common.max' }, { count: '' }),
value: `${spec.maxComputeUnitCount || 0}`
};
const slicedItem: MetaEntry = {
icon: 'icon-sliced',
label: intl.formatMessage({ id: 'gpuservice.instance.sliceable' }),
value: `${slicedMaxPercentage}%`
};
// GPU: 3 items/row → 11 cols. CPU: 2 items/row → 7 cols.
const columns = isGPU ? 11 : 7;
const rows: MetaEntry[][] = isGPU
? [
[ramItem, memoryItem, cpuItem],
showSliceable ? [archItem, maxItem, slicedItem] : [archItem, maxItem]
]
: [[ramItem], [archItem, maxItem]];
return (
<Meta $columns={columns}>
{rows.map((row, index) => renderMetaRow(row, columns, `row-${index}`))}
<Meta $columns={isGPU ? 11 : 7}>
{isGPU && (
<>
{/* row 1: Memory | Max | RAM */}
<MetaItem
show={isGPU}
showDot={false}
icon="icon-gpu1"
label={intl.formatMessage({ id: 'gpuservice.instance.memory' })}
value={formatMemoryDisplay(spec?.memory ?? undefined) ?? '-'}
/>
<MetaItem
showDot={true}
icon="icon-ram-02"
label={intl.formatMessage({ id: 'gpuservice.instance.ram' })}
value={ramUnit ? `${ramUnit} GB` : '-'}
/>
<MetaItem
icon="icon-database"
label={intl.formatMessage(
{
id: 'common.max'
},
{ count: '' }
)}
value={`${spec.maxComputeUnitCount || 0}`}
/>
{/* row 2: OS | Arch | CPU */}
<MetaItem
showDot={false}
icon="icon-server02"
label={intl.formatMessage({ id: 'gpuservice.instance.os' })}
value={os || '-'}
/>
<MetaItem
icon="icon-cube"
label={intl.formatMessage({ id: 'gpuservice.instance.arch' })}
value={_.toUpper(arch) || '-'}
/>
<MetaItem
show={isGPU}
showDot={true}
icon="icon-cpu"
label="CPU"
value={
<Flex gap={4} align="center">
<span>{cpuUnitCores || '-'}</span>
</Flex>
}
/>
</>
)}
{!isGPU && (
<>
{/* row 1: RAM | Max */}
<MetaItem
showDot={false}
icon="icon-ram-02"
label={intl.formatMessage({ id: 'gpuservice.instance.ram' })}
value={ramUnit ? `${ramUnit} GB` : '-'}
/>
<MetaItem
icon="icon-database"
label={intl.formatMessage(
{
id: 'common.max'
},
{ count: '' }
)}
value={`${spec.maxComputeUnitCount || 0}`}
/>
{/* row 2: OS | Arch */}
<MetaItem
showDot={false}
icon="icon-server02"
label={intl.formatMessage({ id: 'gpuservice.instance.os' })}
value={os || '-'}
/>
<MetaItem
icon="icon-cube"
label={intl.formatMessage({ id: 'gpuservice.instance.arch' })}
value={_.toUpper(arch) || '-'}
/>
</>
)}
</Meta>
);
};
@@ -248,7 +259,7 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
disabled={false}
style={{ fontWeight: 400 }}
>
{formatManufacturer(manufacturer)}
{manufacturer?.toUpperCase()}
</ThemeTag>
)}
{showCpuManufacturerTag && (
@@ -260,18 +271,9 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
{cpuManufacturer}
</ThemeTag>
)}
<PluginExtraFields
name="InstanceTypeBillingBadge"
context={{ instanceType: item }}
/>
</Flex>
</Title>
<InstanceMetadataSection
spec={specData}
slicedMaxPercentage={
Number(item.status?.onceMaxRequest?.acceleratorSliced) || 0
}
></InstanceMetadataSection>
<InstanceMetadataSection spec={specData}></InstanceMetadataSection>
</Flex>
);
};
@@ -1,10 +1,8 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton';
import { TemplateCard } from '@gpustack/core-ui';
import { Empty, Flex, Spin } from 'antd';
import _ from 'lodash';
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
import styles from '../styles/instances.module.less';
import InstanceTypeItem from './instance-type-item';
interface InstanceTypeListProps {
@@ -42,19 +40,15 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
return (
<Flex orientation="vertical" gap={16}>
<PluginExtraFields
name="InstanceTypeBillingProvider"
context={{ instanceTypes: dataList }}
/>
{dataList.map((item) => {
const name = item.name;
return (
<TemplateCard
key={name}
className={styles.instanceTypeCard}
clickable
ghost
hoverable
height={106}
active={value === name}
disabled={item.disabled}
onClick={() => handleSelect(item)}
+11 -23
View File
@@ -262,39 +262,32 @@ export const getAcceleratorMax = (
// Picks the candidate (cluster + type name) that should fulfill a requested
// accelerator count: the first candidate of the smallest tier whose
// onceMaxRequest.accelerator is >= the requested count. Accelerated types are
// not gated on CPU remaining (only CPU-only types are); in sliced mode the
// candidate's acceleratorSliced remaining must also be > 0.
// onceMaxRequest.accelerator is >= the requested count and whose cpu/ram/localStorage
// remaining are all > 0.
export const pickCandidateForAccelerator = <
C extends {
cluster: string;
name: string;
cpu?: { remaining?: string | null } | null;
acceleratorSliced?: { remaining?: string | null } | null;
ram?: { remaining?: string | null } | null;
localStorage?: { remaining?: string | null } | null;
}
>(
tiers:
| {
onceMaxRequest: { accelerator?: string; acceleratorSliced?: string };
onceMaxRequest: { accelerator?: string };
candidates?: C[] | null;
}[]
| undefined
| null,
{
count,
acceleratable,
sliced
}: { count: number; acceleratable?: boolean; sliced?: boolean }
{ count, acceleratable }: { count: number; acceleratable?: boolean }
): C | null => {
if (!tiers?.length) return null;
const hasResources = (c: C) => {
// Accelerated types are not gated on CPU remaining; CPU-only types are.
if (!acceleratable && parseQuantity(c.cpu?.remaining) <= 0) return false;
if (sliced && parseQuantity(c.acceleratorSliced?.remaining) <= 0)
return false;
return true;
};
const hasResources = (c: C) =>
parseQuantity(c.cpu?.remaining) > 0 &&
parseQuantity(c.ram?.remaining) > 0 &&
parseQuantity(c.localStorage?.remaining) > 0;
const sorted = [...tiers].sort(
(a, b) =>
@@ -305,12 +298,7 @@ export const pickCandidateForAccelerator = <
// count === 0 ? parseQuantity(tier.onceMaxRequest.accelerator) > count; this is CPU-only case.
for (const tier of sorted) {
const acceleratorCount = parseQuantity(tier.onceMaxRequest?.accelerator);
// Sliced mode requests a fraction of a single card, so the tier's
// whole-card accelerator count (0 for a slice-only type) can't gate it;
// fit on the tier's sliced capacity instead.
const fits = sliced
? parseQuantity(tier.onceMaxRequest?.acceleratorSliced) > 0
: acceleratable
const fits = acceleratable
? acceleratorCount >= count
: acceleratorCount === 0;
if (!fits) continue;
@@ -45,11 +45,6 @@ export interface FormData {
ram: string | null | number;
localStorage: string | null | number;
accelerator: number | string | null;
// Sliced (percentage) mode only. Memory (VRAM) percentage bound to the
// 10-100 selector + free input; cores (compute) percentage bound to the
// "100% compute" checkbox (100 when checked, mirrors memory otherwise).
acceleratorSlicedMemoryPercentage?: number;
acceleratorSlicedCoresPercentage?: number;
};
volume: {
ephemeral?: {
@@ -133,10 +128,8 @@ export interface InstanceTypeCandidate {
name: string;
accelerator: InstanceTypeResource;
cpu: InstanceTypeResource;
// Shared-mode available resource (not shown in the GPU Instance form).
acceleratorShared: InstanceTypeResource;
// Sliced-mode available resource.
acceleratorSliced: InstanceTypeResource;
ram: InstanceTypeResource;
localStorage: InstanceTypeResource;
}
export interface InstanceTypeTierOnceMaxRequestResource {
@@ -156,8 +149,6 @@ export interface InstanceTypeOnceMaxRequestResource {
cpu: QuanityCPU;
ram: QuanityMemory;
localStorage: QuanityLocalStorage;
acceleratorShared: `${number}` | null;
acceleratorSliced: `${number}` | null;
}
export interface CPUCache {
@@ -190,8 +181,6 @@ export interface InstanceTypeSpec {
family?: string | null;
computeCapability?: string | null;
sliced?: string | null;
sliceable?: boolean;
localStorage?: QuanityLocalStorage;
maxComputeUnitCount?: number;
unitResources?: {
cpu: QuanityCPU;
+14 -160
View File
@@ -273,103 +273,33 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
const buildResourcesDataForSubmit = (values: FormData) => {
const unitResourcesParsed = getUnitResources();
const resources = values.spec?.resources ?? ({} as any);
const accelerator = _.toNumber(resources.accelerator) || 0;
const cpuCount = _.toNumber(resources.cpu) || 0;
const accelerator = _.toNumber(values.spec?.resources?.accelerator) || 0;
const cpuCount = _.toNumber(values.spec?.resources?.cpu) || 0;
const cpuNum = unitResourcesParsed?.cpu?.num;
const ramNum = unitResourcesParsed?.ram?.num;
const fallbackCpu = resources.cpu;
const fallbackCpu = values.spec?.resources?.cpu;
const percentage = _.toNumber(
resources.acceleratorSlicedMemoryPercentage
);
const sliced = isGPUType && percentage > 0;
const wholeFactor = isGPUType ? accelerator : cpuCount;
const factor = isGPUType ? accelerator : cpuCount;
// Sliced mode: scale a single card's unit resources by the chosen
// percentage. Scale CPU in millicores and RAM in MiB so fractional
// slices stay precise and k8s-valid (integers) — e.g. 10% of a 4-core /
// 16Gi card → 400m / 1638Mi, not a rounded-up 1 core / 1Gi.
if (sliced && unitResourcesParsed) {
const cpuCores = unitResourcesParsed.cpu?.cores ?? 0;
const ramValue = unitResourcesParsed.ram?.value ?? 0;
return {
cpu: `${Math.max(1, _.floor((cpuCores * 1000 * percentage) / 100))}m`,
ram: `${Math.max(1, _.floor((ramValue * 1024 * percentage) / 100))}Mi`
};
}
// Whole / CPU mode: multiply the unit by the count.
return {
cpu: cpuNum
? `${wholeFactor * cpuNum}${unitResourcesParsed?.cpu?.unit || ''}`
? `${factor * cpuNum}${unitResourcesParsed?.cpu?.unit || ''}`
: // Don't stringify an unset value — `${undefined}` becomes the
// literal "undefined", which fails k8s quantity validation.
fallbackCpu
? `${fallbackCpu}`
: undefined,
ram: ramNum
? `${wholeFactor * ramNum}${unitResourcesParsed?.ram?.unit || ''}`
: resources.ram
? `${factor * ramNum}${unitResourcesParsed?.ram?.unit || ''}`
: values.spec?.resources?.ram
};
};
// Sliced display: set the (disabled) CPU / RAM inputs to a single card's
// unit resources scaled by the chosen percentage, floored. Reads the
// percentage straight from the form so it can be re-run after any slider
// change without threading values through.
const applySlicedResourceScaling = () => {
const unitResourcesParsed = getUnitResources();
const cpuCores = unitResourcesParsed?.cpu?.cores;
const ramValue = unitResourcesParsed?.ram?.value;
const percentage = _.toNumber(
form.getFieldValue([
'spec',
'resources',
'acceleratorSlicedMemoryPercentage'
])
);
form.setFieldsValue({
spec: {
resources: {
// Display the precise (rounded) fractional values — the inputs are
// disabled, so decimals are fine and match the submitted
// millicore / MiB allocation better than a floored integer.
cpu:
cpuCores != null && percentage > 0
? _.round((cpuCores * percentage) / 100, 2)
: null,
ram:
ramValue != null && percentage > 0
? _.round((ramValue * percentage) / 100, 2)
: null
}
}
} as any);
};
// Single entry point for the sliced memory ratio: write the ratio (compute
// stays pinned at 100%) and rescale CPU / RAM off it. Reused by the slider
// onChange and by the sliced-mode defaults so both share one path.
const applySliceMemoryPercentage = (value: number) => {
form.setFieldsValue({
spec: {
resources: {
acceleratorSlicedMemoryPercentage: value,
acceleratorSlicedCoresPercentage: 100
}
}
} as any);
applySlicedResourceScaling();
};
const resolveAndApply = (
instanceType: InstanceTypeItem | undefined,
count: number,
sliced?: boolean
count: number
) => {
if (!instanceType) {
setSelectedInstanceType(undefined);
@@ -398,8 +328,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
instanceType.status?.tiers,
{
count,
acceleratable: instanceType.spec?.acceleratable,
sliced
acceleratable: instanceType.spec?.acceleratable
}
);
@@ -407,12 +336,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
setOnceMaxRequest({
cpu: ceilMilliToCore(candidate?.cpu?.onceMaxRequest)?.cores,
// candidate no longer carries ram/localStorage: memory max comes from
// the type-level onceMaxRequest.ram (already parsed to a Gi number by
// the query hook), disk max from spec.localStorage (UI-only cap).
memory: _.toNumber(instanceType.status?.onceMaxRequest?.ram) || null,
localStorage:
parseQuantityToGi(instanceType.spec?.localStorage)?.value ?? null
memory: parseQuantityToGi(candidate?.ram?.onceMaxRequest)?.value,
localStorage: parseQuantityToGi(candidate?.localStorage?.onceMaxRequest)
?.value
});
form.setFieldsValue({
@@ -428,44 +354,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
});
};
// Whole-card (exclusive) vs sliced (percentage) mode. Only meaningful for
// sliceable accelerator types; derived (no persisted field) — on edit/
// recreate it is inferred from acceleratorSlicedMemoryPercentage > 0.
const [sliceMode, setSliceMode] = useState<'whole' | 'sliced'>('whole');
const handleAcceleratorChange = (count: number) => {
resolveAndApply(selectedInstanceType, count, false);
};
// Seed the sliced-mode default ratio for an instance type: 10% but never
// above the type's max sliceable ratio (status.onceMaxRequest
// .acceleratorSliced). Shares applySliceMemoryPercentage with the slider.
const applySlicedDefaults = (instanceType?: InstanceTypeItem) => {
const slicedMax =
_.toNumber(instanceType?.status?.onceMaxRequest?.acceleratorSliced) ||
0;
applySliceMemoryPercentage(slicedMax ? Math.min(10, slicedMax) : 10);
};
// Toggle between whole-card and sliced mode. Sliced fixes the accelerator
// count to 1 (a single card is partitioned by percentage) and clears the
// slice-percentage fields when leaving sliced mode.
const handleSliceModeChange = (mode: 'whole' | 'sliced') => {
setSliceMode(mode);
if (mode === 'sliced') {
resolveAndApply(selectedInstanceType, 1, true);
applySlicedDefaults(selectedInstanceType);
} else {
form.setFieldsValue({
spec: {
resources: {
acceleratorSlicedMemoryPercentage: undefined,
acceleratorSlicedCoresPercentage: undefined
}
}
} as any);
resolveAndApply(selectedInstanceType, 1, false);
}
resolveAndApply(selectedInstanceType, count);
};
const onTargetChange = (key: string) => {
@@ -517,14 +407,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
? _.toNumber(currentData?.spec?.resources?.accelerator)
: _.toNumber(currentData?.spec?.resources?.cpu) || 0;
// Infer the mode from the persisted slice percentage (recreate keeps
// the section editable; edit/view render a readonly card).
const persistedSliced =
_.toNumber(
currentData?.spec?.resources?.acceleratorSlicedMemoryPercentage
) > 0;
setSliceMode(persistedSliced ? 'sliced' : 'whole');
form.setFieldsValue({
...currentData,
spec: {
@@ -542,12 +424,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
enable_ssh: !!currentData?.spec?.sshPublicKeys?.length,
storageMode: detectMode(currentData?.spec?.volume)
});
// buildResourcesData above filled CPU / RAM for the whole card; rescale
// them off the persisted percentages when recreating a sliced instance.
if (persistedSliced) {
applySlicedResourceScaling();
}
}
}, [action, currentData, form, open, realAction, instanceTypeList]);
@@ -616,30 +492,11 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
getFieldsValue: () => form.getFieldsValue(),
applyInstanceType: (instanceType?: InstanceTypeItem) => {
if (!instanceType) {
setSliceMode('whole');
resolveAndApply(undefined, 0);
return;
}
// A sliceable type with no whole-card capacity (Max < 1) defaults to
// sliced mode — whole mode would have nothing selectable.
const wholeMax = instanceType.spec?.maxComputeUnitCount ?? 0;
const slicedMax =
_.toNumber(instanceType.status?.onceMaxRequest?.acceleratorSliced) ||
0;
const defaultSliced =
!!instanceType.spec?.sliceable && wholeMax < 1 && slicedMax > 0;
if (defaultSliced) {
setSliceMode('sliced');
resolveAndApply(instanceType, 1, true);
applySlicedDefaults(instanceType);
return;
}
// Otherwise default to whole-card mode (a new type may not be
// sliceable); set count to 1 for all instance types: GPU or non-GPU.
setSliceMode('whole');
// set default to 1, for all instance types: GPU or non-GPU
resolveAndApply(instanceType, 1);
}
}));
@@ -750,9 +607,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
currentData={currentData as any}
onceMaxRequest={onceMaxRequest}
noAvailableTypes={noAvailableInstanceTypes}
sliceMode={sliceMode}
onSliceModeChange={handleSliceModeChange}
onSliceMemoryPercentageChange={applySliceMemoryPercentage}
onGPUCountChange={handleAcceleratorChange}
/>
)
@@ -3,7 +3,7 @@ import { PageActionType } from '@/config/types';
import NumberSelection from '@/pages/_components/number-selection';
import { InputNumber } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Form, Segmented } from 'antd';
import { Flex, Form } from 'antd';
import _ from 'lodash';
import { useContext, useMemo } from 'react';
import styled from 'styled-components';
@@ -57,9 +57,6 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
);
};
// Fixed 10-tick percentage scale (10..100) for the sliced (percentage) mode.
const SLICE_PERCENT_TICKS = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
interface InstanceTypeFormItemProps {
action: PageActionType;
disabled?: boolean;
@@ -70,12 +67,6 @@ interface InstanceTypeFormItemProps {
// org owns no clusters. Surface a "no available" message instead of the
// "please select" placeholder + empty CPU / memory inputs.
noAvailableTypes?: boolean;
// Whole-card (exclusive) vs sliced (percentage) mode. Owned by the parent
// form (it drives candidate picking + the fixed accelerator=1 for sliced).
sliceMode?: 'whole' | 'sliced';
onSliceModeChange?: (mode: 'whole' | 'sliced') => void;
// Commit a new sliced memory ratio (writes the field + rescales CPU / RAM).
onSliceMemoryPercentageChange?: (value: number) => void;
onGPUCountChange?: (value: number) => void;
}
@@ -86,13 +77,9 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
selectedInstanceType,
onceMaxRequest,
noAvailableTypes,
sliceMode = 'whole',
onSliceModeChange,
onSliceMemoryPercentageChange,
onGPUCountChange
}) => {
const intl = useIntl();
const form = Form.useFormInstance();
const { isGPUType } = useContext(FormContext);
const maxComputeUnitCount = useMemo(() => {
@@ -117,62 +104,6 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
onGPUCountChange?.(value);
};
// Sliced mode is only offered for sliceable accelerator types, and only when
// the section is editable (create / recreate; edit renders a readonly card).
const showModeSwitch =
action !== PageAction.EDIT &&
isGPUType &&
!!selectedInstanceType?.spec?.sliceable;
const handleModeChange = (value: string) => {
onSliceModeChange?.(value as 'whole' | 'sliced');
};
// Memory (VRAM) percentage changed via the slider/input — forward the new
// value so the parent writes the field and rescales CPU / RAM.
const handleMemoryPercentageChange = (value: number) => {
onSliceMemoryPercentageChange?.(value);
};
// Max selectable ratio in sliced mode: status.onceMaxRequest.acceleratorSliced
// (a percentage). Ticks above it stay visible but disabled.
const slicedMaxPercentage =
_.toNumber(
selectedInstanceType?.status?.onceMaxRequest?.acceleratorSliced
) || 0;
const modeSegmented = showModeSwitch ? (
<Segmented
size="small"
shape="round"
style={{ fontSize: 12 }}
value={sliceMode}
disabled={disabled}
onChange={handleModeChange}
options={[
{
label: intl.formatMessage({ id: 'gpuservice.instance.mode.whole' }),
value: 'whole'
},
{
label: intl.formatMessage({ id: 'gpuservice.instance.mode.sliced' }),
value: 'sliced',
// No sliced capacity → keep the option visible but unselectable.
disabled: slicedMaxPercentage <= 0
}
]}
/>
) : null;
const isSliced = showModeSwitch && sliceMode === 'sliced';
// When the max ratio is below 10%, switch the ticks to a finer 1..10 scale
// so small slices are still selectable; otherwise use the 10..100 scale.
const sliceTicks =
slicedMaxPercentage < 10
? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
: SLICE_PERCENT_TICKS;
const renderMaxLabel = (
label: React.ReactNode,
max?: number | null
@@ -275,7 +206,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
: ['spec', 'resources', 'cpu']
}
preserve
hidden={action === PageAction.EDIT || isSliced}
hidden={action === PageAction.EDIT}
normalize={(value) => (value != null ? _.toString(value) : undefined)}
getValueProps={(value) => ({
value: value != null ? _.toNumber(value) : undefined
@@ -316,7 +247,6 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
max={maxComputeUnitCount}
step={1}
required
labelExtra={sliceMode === 'whole' ? modeSegmented : undefined}
disabled={disabled || action === PageAction.EDIT}
label={`${intl.formatMessage({ id: 'common.max.count' }, { label: numberSelectionLabel.label })} (${intl.formatMessage(
{
@@ -327,89 +257,6 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
/>
</Form.Item>
)}
{!noAvailableTypes && isSliced && (
<FieldBlock>
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']}
getValueProps={(value) => ({
value: value != null ? _.toNumber(value) : undefined
})}
rules={[
{
required: true,
validator: (_, value) => {
const num = Number(value);
if (value == null || value === '' || Number.isNaN(num)) {
return Promise.reject(
new Error(
intl.formatMessage({
id: 'gpuservice.instance.slice.percentage.required'
})
)
);
}
if (num > slicedMaxPercentage || num <= 0) {
return Promise.reject(
new Error(
intl.formatMessage(
{
id: 'gpuservice.instance.slice.percentage.max'
},
{ count: slicedMaxPercentage }
)
)
);
}
return Promise.resolve();
}
}
]}
>
<NumberSelection
min={1}
max={slicedMaxPercentage}
step={1}
maxCount={sliceTicks.length}
presetValues={sliceTicks}
alwaysShowInput
required
disabled={disabled}
onChange={handleMemoryPercentageChange}
labelExtra={modeSegmented}
label={intl.formatMessage({
id: 'gpuservice.instance.slice.memoryPercentage'
})}
/>
</Form.Item>
{/* Compute (cores) percentage is fixed at 100. Kept in the form via a
hidden item so it rides along on submit. */}
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']}
hidden
>
<InputNumber />
</Form.Item>
</FieldBlock>
)}
{/* Edit renders a readonly card (no sliced UI), so register the slice
percentages as hidden fields — otherwise their persisted values are
dropped from the submit payload. */}
{action === PageAction.EDIT && (
<>
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']}
hidden
>
<InputNumber />
</Form.Item>
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']}
hidden
>
<InputNumber />
</Form.Item>
</>
)}
{!noAvailableTypes && (
<Flex gap={12}>
<div style={{ flex: 1 }}>
@@ -434,7 +281,11 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
key="cpu_input"
preserve
>
<InputNumber label={'CPU'} disabled={true} />
<InputNumber
label={'CPU'}
max={onceMaxRequest?.cpu ?? undefined}
disabled={true}
/>
</Form.Item>
</div>
)}
@@ -10,18 +10,12 @@ import useOverlayLayout from '../hooks/use-overlay-layout';
interface StorageOverlayProps {
open: boolean;
// Org the surrounding instance create form targets (platform admin "All"
// view). The storage inherits this scope, so the type list is pinned to
// it — the picker only offers types that org can reference. Undefined
// when there's no create-scope picker (the ambient org context applies).
scopeOrgId?: number | null;
onCancel: () => void;
onSubmit: (values: StorageFormData) => Promise<void> | void;
}
const StorageOverlay: React.FC<StorageOverlayProps> = ({
open,
scopeOrgId,
onCancel,
onSubmit
}) => {
@@ -34,14 +28,9 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
useEffect(() => {
if (open) {
fetchStorageClass(
{ page: -1 },
scopeOrgId != null
? { headers: { 'X-Organization-Id': String(scopeOrgId) } }
: undefined
);
fetchStorageClass({ page: -1 });
}
}, [open, scopeOrgId]);
}, [open]);
const handleSubmit = () => {
formRef.current?.submit();
@@ -86,7 +75,6 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
ref={formRef}
action={PageAction.CREATE}
open={open}
showOrgScope={false}
onFinish={handleFinish}
/>
</FormContext.Provider>
@@ -30,10 +30,6 @@ const StorageVolume = ({
const { getRuleMessage } = useAppUtils();
const form = Form.useFormInstance<FormData>();
const storageMode = Form.useWatch('storageMode', form);
// Owned by the instance create-scope picker (platform admin "All" view).
// A storage added inline belongs to the same org as the instance, so pass
// it to the overlay to scope the storage-type list to that org.
const scopeOrgId = Form.useWatch('organization_id', form);
const { fetchData: createStorage } = useCreateStorage();
const { detailData: storageData, fetchData: fetchStorage } =
useQueryStorage();
@@ -236,7 +232,6 @@ const StorageVolume = ({
<StorageOverlay
open={overlayOpen}
scopeOrgId={scopeOrgId}
onCancel={() => setOverlayOpen(false)}
onSubmit={handleCreateStorage}
/>
@@ -271,7 +271,6 @@ const GPUService: React.FC = () => {
if (!clusterLoading && !hasK8sCluster) {
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading || clusterLoading}
loadend={dataSource.loadend}
dataSource={[]}
@@ -295,7 +294,6 @@ const GPUService: React.FC = () => {
}
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
@@ -362,7 +360,6 @@ const GPUService: React.FC = () => {
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
@@ -36,20 +36,6 @@ export default function useQueryInstanceTypes() {
}
const max = getAcceleratorMax(item.status?.tiers);
// Sliceable types stay selectable as long as either whole-card or sliced
// capacity remains; unavailable only when both status.onceMaxRequest
// .accelerator and .acceleratorSliced are 0.
if (item.spec?.sliceable) {
const wholeMax = Number(item.status?.onceMaxRequest?.accelerator) || 0;
const slicedMax =
Number(item.status?.onceMaxRequest?.acceleratorSliced) || 0;
return {
maxComputeUnitCount: max || 0,
available: wholeMax > 0 || slicedMax > 0
};
}
return {
maxComputeUnitCount: max || 0,
available: (max || 0) > 0
@@ -45,10 +45,3 @@
font-weight: 400;
font-size: 12px;
}
// TemplateCard applies a fixed inline height; override to let sliceable cards
// (with the extra Sliceable row) grow while keeping a 106px floor.
.instanceTypeCard {
height: auto !important;
min-height: 106px;
}
@@ -11,7 +11,7 @@
* with ``buildInstanceTypeRecordFromMiB`` and feed it here.
*/
import _ from 'lodash';
import { parseJsonSafe, parseQuantityToGi } from '../../utils';
import { parseJsonSafe } from '../../utils';
import InstanceTypeCell from '../components/instance-type-cell';
import { formatMemoryDisplay } from '../config';
import { InstanceTypeSpec, ListItem } from '../config/types';
@@ -49,10 +49,6 @@ const buildResourcesData = (
return {};
};
// Memory (VRAM) percentage for a sliced instance; 0 when not sliced.
const getSliceMemoryPercentage = (record: ListItem) =>
_.toNumber(record.spec?.resources?.acceleratorSlicedMemoryPercentage) || 0;
const formatResources = (
instanceTypeSpec: { spec: InstanceTypeSpec },
record: ListItem
@@ -75,34 +71,6 @@ const formatResources = (
};
}
const sliceMemoryPercentage = getSliceMemoryPercentage(record);
// Sliced: CPU / RAM carry the already-scaled values on spec.resources, and
// VRAM is the per-card memory scaled by the memory percentage (floored,
// min 1) — not the whole card's size.
if (sliceMemoryPercentage > 0) {
const vramGi = parseQuantityToGi(
(instanceTypeSpec.spec as any)?.memory
)?.value;
const vram =
vramGi != null
? `${Math.max(1, _.floor((vramGi * sliceMemoryPercentage) / 100))} GB`
: undefined;
return {
cpu: record.spec?.resources?.cpu
? `${record.spec?.resources?.cpu} vCPU`
: '-',
ram: record.spec?.resources?.ram
? toGB(record.spec?.resources?.ram)
: '-',
vram,
localStorage: record.spec?.resources?.localStorage
? toGB(record.spec?.resources?.localStorage)
: undefined
};
}
// VRAM = per-card GPU memory (a single card's size; not aggregated across
// cards — the model's marquee spec).
const vram = formatMemoryDisplay((instanceTypeSpec.spec as any)?.memory);
@@ -140,14 +108,10 @@ export const renderInstanceType = (
parseJsonSafe<any>(record?.description || '{}', {}).spec || {};
const resources = formatResources({ spec: description }, record);
const accelerator = record.spec?.resources?.accelerator;
const sliceMemoryPercentage = getSliceMemoryPercentage(record);
const isSliced = description.acceleratable && sliceMemoryPercentage > 0;
const title =
options.title ??
(description.acceleratable
? isSliced
? `${description.product} (${sliceMemoryPercentage}%)`
: `${description.product} x ${accelerator}`
? `${description.product} x ${accelerator}`
: 'CPU Only');
const volume = (record.spec as any)?.volume;
@@ -167,13 +131,7 @@ export const renderInstanceType = (
icon: 'icon-gpu',
name: 'GPU',
rows: [
// Sliced instances show the ratio instead of a card count (always 1).
isSliced
? [
intl.formatMessage({ id: 'gpuservice.instance.sliced' }),
`${sliceMemoryPercentage}%`
]
: [
[
intl.formatMessage({ id: 'gpuservice.table.count' }),
accelerator ? `${accelerator}` : undefined
],
@@ -110,7 +110,6 @@ const GPUServicePublicKeys: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
@@ -154,7 +153,6 @@ const GPUServicePublicKeys: React.FC = () => {
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
@@ -1,23 +1,6 @@
import { StatusMaps } from '@/config';
import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui';
import { StorageTypeKind } from './types';
export const StorageTypePhaseValueMap = {
Ready: 'Ready',
Deleting: 'Deleting'
};
export const StorageTypePhaseLabelMap: Record<string, string> = {
[StorageTypePhaseValueMap.Ready]: 'Ready',
[StorageTypePhaseValueMap.Deleting]: 'Deleting'
};
export const status: Record<string, StatusType> = {
[StorageTypePhaseValueMap.Ready]: StatusMaps.success,
[StorageTypePhaseValueMap.Deleting]: StatusMaps.warning
};
export const StorageTypeKindValueMap: Record<string, StorageTypeKind> = {
NFS: 'nfs',
S3: 's3'
@@ -44,8 +44,4 @@ export interface ListItem {
nfs?: StorageTypeNFS | null;
s3?: Omit<StorageTypeS3, 'secretKey'> | null;
};
status?: {
phase?: string | null;
phaseMessage?: string | null;
} | null;
}
@@ -5,19 +5,13 @@ import {
AutoTooltip,
DropdownButtons,
IconFont,
StatusTag,
ThemeTag
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import {
rowActionList,
status,
StorageTypeKindLabelMap,
StorageTypePhaseLabelMap
} from '../config';
import { rowActionList, StorageTypeKindLabelMap } from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
@@ -28,13 +22,21 @@ interface ColumnsHookProps {
const getKindLabel = (record: ListItem) => {
if (record.spec?.nfs)
return (
<ThemeTag color="cyan" icon={<FolderOutlined />}>
<ThemeTag
style={{ width: 'fit-content' }}
color="cyan"
icon={<FolderOutlined />}
>
{StorageTypeKindLabelMap.nfs}
</ThemeTag>
);
if (record.spec?.s3)
return (
<ThemeTag color="green" icon={<IconFont type="icon-database" />}>
<ThemeTag
style={{ width: 'fit-content' }}
color="green"
icon={<IconFont type="icon-database" />}
>
{StorageTypeKindLabelMap.s3}
</ThemeTag>
);
@@ -79,24 +81,6 @@ const useStorageTypeColumns = ({
sorter: false,
render: (_text, record) => getKindLabel(record)
},
{
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: ['status', 'phase'],
key: 'status',
sorter: false,
render: (value: string, record: ListItem) =>
value ? (
<StatusTag
statusValue={{
status: status[value],
text: StorageTypePhaseLabelMap[value] || value,
message: record?.status?.phaseMessage || ''
}}
></StatusTag>
) : (
'-'
)
},
...creatorCols,
// {
// title: intl.formatMessage({ id: 'common.table.description' }),
@@ -104,7 +104,6 @@ const GPUServiceStorageTypes: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
@@ -150,7 +149,6 @@ const GPUServiceStorageTypes: React.FC = () => {
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
@@ -51,11 +51,6 @@ export async function queryStorageClass(
return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, {
method: 'GET',
params,
// `headers` lets the create form pin the request to a specific org so
// the picker only offers storage types that org can reference. GETs
// otherwise inherit the ambient org context, which is empty in the
// platform-admin "All" view and would list every org's types.
headers: options?.headers,
cancelToken: options?.token
});
}
@@ -1,11 +1,10 @@
import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock';
import { FormDrawer, ModalFooter } from '@gpustack/core-ui';
import { useEffect, useRef } from 'react';
import { useRef } from 'react';
import { FormContext } from '../config/form-context';
import { FormData, ListItem } from '../config/types';
import GPUServiceStorageForm from '../forms';
import useQueryStorageClass from '../services/use-query-storage-class';
type AddModalProps = {
title: string;
@@ -14,6 +13,7 @@ type AddModalProps = {
onOk: (values: FormData) => void;
data?: ListItem | null;
onCancel: () => void;
storageClassList: Global.BaseOption<string>[];
};
const AddModal: React.FC<AddModalProps> = ({
@@ -22,33 +22,11 @@ const AddModal: React.FC<AddModalProps> = ({
open,
onOk,
data,
onCancel
onCancel,
storageClassList
}) => {
const form = useRef<any>(null);
const { loading, guard, run, release } = useSubmitLock();
// The dropdown gets its own storage-type list, scoped to the org the
// create-scope picker targets — distinct from the page-level list, which
// stays unscoped so the table can label every org's rows.
const { storageClassList, fetchData: fetchStorageClass } =
useQueryStorageClass();
useEffect(() => {
if (open) {
fetchStorageClass({ page: -1 });
}
}, [open]);
// Platform admin picked a target org in the create-scope slot: reload the
// storage-type list pinned to that org so the dropdown only offers types
// the org can reference (its own plus any reachable via cluster access).
const handleScopeChange = (orgId?: number | null) => {
fetchStorageClass(
{ page: -1 },
orgId != null
? { headers: { 'X-Organization-Id': String(orgId) } }
: undefined
);
};
const handleSubmit = () => {
guard(() => form.current?.submit());
@@ -92,7 +70,6 @@ const AddModal: React.FC<AddModalProps> = ({
ref={form}
action={action}
currentData={data}
onScopeChange={handleScopeChange}
onFinish={onFinish}
onFinishFailed={release}
open={open}
@@ -3,18 +3,21 @@ import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui';
export const StoragePhaseValueMap = {
Ready: 'Ready',
Deleting: 'Deleting'
Available: 'Available',
Unavailable: 'Unavailable',
Pending: 'Pending'
};
export const StoragePhaseLabelMap: Record<string, string> = {
[StoragePhaseValueMap.Ready]: 'Ready',
[StoragePhaseValueMap.Deleting]: 'Deleting'
[StoragePhaseValueMap.Available]: 'Available',
[StoragePhaseValueMap.Unavailable]: 'Unavailable',
[StoragePhaseValueMap.Pending]: 'Pending'
};
export const status: Record<string, StatusType> = {
[StoragePhaseValueMap.Ready]: StatusMaps.success,
[StoragePhaseValueMap.Deleting]: StatusMaps.warning
[StoragePhaseValueMap.Available]: StatusMaps.success,
[StoragePhaseValueMap.Unavailable]: StatusMaps.error,
[StoragePhaseValueMap.Pending]: StatusMaps.transitioning
};
export const rowActionList = [

Some files were not shown because too many files have changed in this diff Show More