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
180 changed files with 1457 additions and 6494 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 -16
View File
@@ -98,10 +98,6 @@ Compose layout with Ant components, not hand-written `display: flex`.
Drive spacing with the theme scale (`Flex`/`Space` `gap`, or `var(--ant-*)` spacing tokens), not scattered `px` literals.
## Tables
- **Horizontally scrollable table**: set `scroll={{ x: 'max-content' }}` **and** add `className="scroll-table"` on the `Table`. The class styles the horizontal scroll to match the design; without it the scroll works but looks off.
# Naming conventions
A page module lives under `src/pages/{module}` with this sub-structure: `components/`, `config/`, `forms/`, `hooks/`, `services/`, `index.tsx`. File naming:
@@ -128,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
};
}
+9 -13
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,17 +286,15 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
</NewLabel>
)}
</div>
{!plugin && (
<DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}>
<IconWrapper>
<IconFont
type="icon-help"
className="font-size-20"
style={{ color: 'var(--ant-color-text-tertiary)' }}
/>
</IconWrapper>
</DropdownActions>
)}
<DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}>
<IconWrapper>
<IconFont
type="icon-help"
className="font-size-20"
style={{ color: 'var(--ant-color-text-tertiary)' }}
/>
</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':
-6
View File
@@ -222,12 +222,6 @@ export default {
'common.title.delete.confirm': 'Confirm deletion',
'common.title.stop.confirm': 'Confirm stop',
'common.title.start.confirm': 'Confirm start',
'common.title.activate.confirm': 'Confirm activate',
'common.title.deactivate.confirm': 'Confirm deactivate',
'common.activate.single.confirm':
'Are you sure you want to activate? \n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.deactivate.single.confirm':
'Are you sure you want to deactivate? \n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.title.recreate.confirm': 'Confirm recreate',
'common.button.addLabel': 'Add Label',
'common.button.addSelector': 'Add Selector',
+3 -33
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,44 +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.percentage': 'Percentage (%)',
'gpuservice.instance.slice.coresPercentage': 'Compute Percentage (%)',
'gpuservice.instance.slice.cores.min':
'The compute ratio must be no less than the VRAM ratio ({count}%)',
'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.flavor.gpuGroup': 'GPU Compute',
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
'gpuservice.instanceType.activate': 'Activate',
'gpuservice.instanceType.deactivate': 'Deactivate',
'gpuservice.instanceType.platform': 'Platform',
'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',
@@ -178,6 +145,9 @@ export default {
'Only events from the last hour are shown',
'gpuservice.instance.event.tab.instance': 'Instance Events',
'gpuservice.instance.event.tab.volume': 'Volume Events',
'gpuservice.instance.recreate.confirm.title': 'Confirm recreation',
'gpuservice.instance.recreate.confirm.content':
'The current instance will be deleted first, then recreated with the current configuration.\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'Storage',
'gpuservice.storage.add': 'Add Storage',
'gpuservice.storage.edit': 'Edit Storage',
+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':
-6
View File
@@ -221,12 +221,6 @@ export default {
'common.title.delete.confirm': '削除を確認',
'common.title.stop.confirm': '停止を確認',
'common.title.start.confirm': '開始を確認',
'common.title.activate.confirm': '有効化を確認',
'common.title.deactivate.confirm': '無効化を確認',
'common.activate.single.confirm':
'<span style="font-size: 13px;font-weight: 700">{name}</span> を有効化してもよろしいですか?',
'common.deactivate.single.confirm':
'<span style="font-size: 13px;font-weight: 700">{name}</span> を無効化してもよろしいですか?',
'common.title.recreate.confirm': '再作成を確認',
'common.button.addLabel': 'ラベルを追加',
'common.button.addSelector': 'セレクターを追加',
+3 -33
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,44 +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.percentage': '割合(%',
'gpuservice.instance.slice.coresPercentage': '演算能力の割合(%',
'gpuservice.instance.slice.cores.min':
'演算能力の割合は VRAM の割合({count}%)以上である必要があります',
'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.flavor.gpuGroup': 'GPU Compute',
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
'gpuservice.instanceType.activate': 'Activate',
'gpuservice.instanceType.deactivate': 'Deactivate',
'gpuservice.instanceType.platform': 'Platform',
'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': 'システムディスク',
@@ -177,6 +144,9 @@ export default {
'直近 1 時間のイベントのみ表示されます',
'gpuservice.instance.event.tab.instance': 'インスタンスイベント',
'gpuservice.instance.event.tab.volume': 'ボリュームイベント',
'gpuservice.instance.recreate.confirm.title': '再作成を確認しますか',
'gpuservice.instance.recreate.confirm.content':
'現在のインスタンスを削除した後、現在の構成で再作成します。\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'ストレージ',
'gpuservice.storage.add': 'ストレージを追加',
'gpuservice.storage.edit': 'ストレージを編集',
+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':
-6
View File
@@ -219,12 +219,6 @@ export default {
'common.title.delete.confirm': 'Подтверждение удаления',
'common.title.stop.confirm': 'Подтверждение остановки',
'common.title.start.confirm': 'Подтверждение запуска',
'common.title.activate.confirm': 'Подтверждение активации',
'common.title.deactivate.confirm': 'Подтверждение деактивации',
'common.activate.single.confirm':
'Вы уверены, что хотите активировать <span style="font-size: 13px;font-weight: 700">{name}</span>?',
'common.deactivate.single.confirm':
'Вы уверены, что хотите деактивировать <span style="font-size: 13px;font-weight: 700">{name}</span>?',
'common.title.recreate.confirm': 'Подтверждение пересоздания',
'common.button.addLabel': 'Добавить метку',
'common.button.addSelector': 'Добавить селектор',
+3 -33
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,44 +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.percentage': 'Доля (%)',
'gpuservice.instance.slice.coresPercentage': 'Доля вычислений (%)',
'gpuservice.instance.slice.cores.min':
'Доля вычислений должна быть не меньше доли VRAM ({count}%)',
'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.flavor.gpuGroup': 'GPU Compute',
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
'gpuservice.instanceType.activate': 'Activate',
'gpuservice.instanceType.deactivate': 'Deactivate',
'gpuservice.instanceType.platform': 'Platform',
'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': 'Системный диск',
@@ -176,6 +143,9 @@ export default {
'Отображаются только события за последний час',
'gpuservice.instance.event.tab.instance': 'События экземпляра',
'gpuservice.instance.event.tab.volume': 'События тома',
'gpuservice.instance.recreate.confirm.title': 'Подтвердить пересоздание',
'gpuservice.instance.recreate.confirm.content':
'Текущий экземпляр будет сначала удалён, а затем пересоздан с текущей конфигурацией.\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'Хранилище',
'gpuservice.storage.add': 'Добавить хранилище',
'gpuservice.storage.edit': 'Редактировать хранилище',
+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':
-6
View File
@@ -224,12 +224,6 @@ export default {
'common.title.delete.confirm': 'Silme onayı',
'common.title.stop.confirm': 'Durdurma onayı',
'common.title.start.confirm': 'Başlatma onayı',
'common.title.activate.confirm': 'Etkinleştirme onayı',
'common.title.deactivate.confirm': 'Devre dışı bırakma onayı',
'common.activate.single.confirm':
'Etkinleştirmek istediğinizden emin misiniz? \n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.deactivate.single.confirm':
'Devre dışı bırakmak istediğinizden emin misiniz? \n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.title.recreate.confirm': 'Yeniden oluşturma onayı',
'common.button.addLabel': 'Etiket Ekle',
'common.button.addSelector': 'Seçici Ekle',
+4 -33
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,44 +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.percentage': 'Yüzde (%)',
'gpuservice.instance.slice.coresPercentage': 'İşlem Gücü Yüzdesi (%)',
'gpuservice.instance.slice.cores.min':
'İşlem gücü oranı VRAM oranından ({count}%) küçük olamaz',
'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.flavor.gpuGroup': 'GPU Compute',
'gpuservice.instanceType.flavor.cpuGroup': 'CPU Compute',
'gpuservice.instanceType.activate': 'Activate',
'gpuservice.instanceType.deactivate': 'Deactivate',
'gpuservice.instanceType.platform': 'Platform',
'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',
@@ -172,6 +139,10 @@ export default {
'Yalnızca son bir saatteki olaylar gösterilir',
'gpuservice.instance.event.tab.instance': 'Örnek Olayları',
'gpuservice.instance.event.tab.volume': 'Birim Olayları',
'gpuservice.instance.recreate.confirm.title':
'Yeniden oluşturma onaylansın mı',
'gpuservice.instance.recreate.confirm.content':
'Mevcut örnek önce silinecek, ardından mevcut yapılandırmayla yeniden oluşturulacaktır.\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'Depolama',
'gpuservice.storage.add': 'Depolama Ekle',
'gpuservice.storage.edit': 'Depolamayı Düzenle',
+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': '使用以下命令检查环境是否准备妥当。',
-6
View File
@@ -212,12 +212,6 @@ export default {
'common.title.delete.confirm': '确认删除',
'common.title.stop.confirm': '确认停止',
'common.title.start.confirm': '确认启动',
'common.title.activate.confirm': '确认启用',
'common.title.deactivate.confirm': '确认停用',
'common.activate.single.confirm':
'确定启用 <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.deactivate.single.confirm':
'确定停用 <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.title.recreate.confirm': '确认重新创建',
'common.button.addLabel': '添加标签',
'common.button.addSelector': '添加选择器',
+3 -29
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,40 +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.percentage': '占比(%',
'gpuservice.instance.slice.coresPercentage': '算力占比(%',
'gpuservice.instance.slice.cores.min': '算力占比需不小于显存占比 {count}%',
'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.flavor.gpuGroup': 'GPU 算力',
'gpuservice.instanceType.flavor.cpuGroup': 'CPU 算力',
'gpuservice.instanceType.activate': '启用',
'gpuservice.instanceType.deactivate': '停用',
'gpuservice.instanceType.platform': '平台',
'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': '系统盘',
@@ -162,6 +133,9 @@ export default {
'gpuservice.instance.event.recentHourTip': '仅显示最近一小时的事件。',
'gpuservice.instance.event.tab.instance': '实例事件',
'gpuservice.instance.event.tab.volume': '存储卷事件',
'gpuservice.instance.recreate.confirm.title': '确认重新创建',
'gpuservice.instance.recreate.confirm.content':
'系统将先删除当前实例,然后使用当前配置重新创建。\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': '存储',
'gpuservice.storage.add': '添加存储',
'gpuservice.storage.edit': '编辑存储',
+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,19 +120,13 @@ 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}
layout="community"
onClick={handleOnClickItem}
actionsRenderer={actionsRenderer}
/>
</div>
<BackendCard
active={currentData?.id === item.id}
data={item}
layout="community"
onClick={handleOnClickItem}
actionsRenderer={actionsRenderer}
/>
);
};
@@ -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
key={col.dataIndex || col.key}
span={spanFor(col.dataIndex)}
style={{
color: 'var(--ant-color-text-secondary)'
}}
>
<CellContent
{..._.omit(col, ['key', 'style', 'span'])}
></CellContent>
</ExpandedRowGrid.Cell>
))}
</ExpandedRowGrid>
</TableRowProvider>
<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={col.span}
style={{
paddingInline: 0,
...(col.style || {})
}}
>
<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;
@@ -153,8 +159,14 @@ const useClusterColumns = (
span: 3,
render: (text: string, record: ClusterListItem) => (
<>
<AutoTooltip ghost title={text} minWidth={20}>
<span className="text-primary">{record.name}</span>
<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]}
@@ -186,7 +197,7 @@ const useClusterColumns = (
{
title: intl.formatMessage({ id: 'dashboard.totalgpus' }),
dataIndex: 'gpus',
width: 100,
span: 2,
sorter: tableSorter(3),
render: (value: number) => <span>{value}</span>
},
@@ -195,15 +206,13 @@ const useClusterColumns = (
dataIndex: 'models',
sorter: tableSorter(4),
span: spans.deployments,
maxWidth: 150,
render: (value: number) => <span>{value}</span>
},
{
title: intl.formatMessage({ id: 'resources.nodes' }),
dataIndex: 'workers',
minWidth: 100,
maxWidth: 120,
sorter: tableSorter(5),
span: spans.workers,
render: (value: number, record: ClusterListItem) => (
<span>
{record.ready_workers} / {record.workers}
@@ -214,8 +223,6 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: 'state',
span: spans.status,
minWidth: 80,
align: 'center',
render: (value: number, record: ClusterListItem) => (
<StatusTag
statusValue={{
@@ -230,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')}
@@ -251,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 (
+25 -42
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,38 +97,36 @@ 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 =
groupItem?.label ||
identityValue?.user_name ||
identityValue?.api_key_name ||
identityValue?.access_key ||
groupValue?.user_name ||
groupValue?.api_key_name ||
groupValue?.access_key ||
rawItem.user_name ||
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);
return (
groupItem?.label ||
identityValue?.user_name ||
identityValue?.api_key_name ||
identityValue?.access_key ||
groupValue?.user_name ||
groupValue?.api_key_name ||
groupValue?.access_key ||
rawItem.user_name ||
rawItem.api_key_name ||
rawItem.access_key ||
rawItem[groupBy] ||
'-'
);
};
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,76 +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 }
});
}
// PUT /gpu-instance-types/{name}/activate?cluster_id — activate an instance type.
export async function activateGPUInstanceType(params: {
name: string;
cluster_id: number;
}) {
return request(`${GPU_INSTANCE_TYPES_API}/${params.name}/activate`, {
method: 'PUT',
params: { cluster_id: params.cluster_id }
});
}
// PUT /gpu-instance-types/{name}/deactivate?cluster_id — deactivate an instance type.
export async function deactivateGPUInstanceType(params: {
name: string;
cluster_id: number;
}) {
return request(`${GPU_INSTANCE_TYPES_API}/${params.name}/deactivate`, {
method: 'PUT',
params: { cluster_id: params.cluster_id }
});
}
@@ -1,114 +0,0 @@
import useSubmitLock from '@/hooks/use-submit-lock';
import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { 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';
type AddInstanceTypeModalProps = {
title: string;
open: boolean;
clusterId?: number;
onOk: (values: FormData) => void;
onCancel: () => void;
};
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(600px, calc(100vw - 220px))' },
body: { overflowY: 'hidden' }
}}
footer={false}
>
<ColumnWrapper
styles={{ container: { paddingBlock: 0 } }}
footer={
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
loading={loading}
style={{
padding: '16px 24px 8px',
display: 'flex',
justifyContent: 'flex-end'
}}
/>
}
>
<GPUServiceInstanceTypeForm
ref={form}
open={open}
selectedFlavor={selectedFlavor}
flavorList={flavorList}
flavorLoading={flavorLoading}
onFlavorChange={setSelectedFlavor}
onFinish={onFinish}
onFinishFailed={release}
/>
</ColumnWrapper>
</GSDrawer>
);
};
export default AddInstanceTypeModal;
@@ -1,118 +0,0 @@
import { AutoTooltip, ThemeTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex } from 'antd';
import { formatMemoryDisplay } from '../../instances/config';
import { manufactureColorMap } from '../../templates/config';
import { formatManufacturer } from '../../utils';
// The subset of a flavor / instance-type display shape the flavor renderers
// read. Flavor specs satisfy it directly (minus sliceable, which the API
// removed from flavors); the management list builds it from spec.acceleratable
// + status.detail, deriving sliceable from slicedDetail.
interface FlavorSpecLike {
manufacturer?: string | null;
product?: string | null;
memory?: string | null;
sliceable?: boolean;
acceleratable?: boolean;
}
// A flavor's title mirrors the flavor card: a generic (no product, no/`generic`
// manufacturer, non-acceleratable) flavor reads as "CPU-only".
export const getFlavorTitle = (
spec: FlavorSpecLike = {},
fallbackName?: string | null
) => {
const manufacturer = spec.manufacturer || '';
const isCpuOnly =
!spec.acceleratable &&
!spec.product &&
(!manufacturer || manufacturer.toLowerCase() === 'generic');
return isCpuOnly ? 'CPU-only' : spec.product || fallbackName || '-';
};
// Secondary line, dot-separated: manufacturer · memory · sliceable. memory and
// sliceable apply to accelerator (GPU) flavors only; sliceable stays a tag.
// Returns null when a (generic) flavor has nothing to show.
export const FlavorMeta: React.FC<{ spec?: FlavorSpecLike }> = ({
spec = {}
}) => {
const intl = useIntl();
const manufacturer = spec.manufacturer || '';
const color = manufactureColorMap[manufacturer] ?? 'purple';
const memory = spec.acceleratable
? formatMemoryDisplay(spec.memory ?? undefined)
: '';
const pieces: React.ReactNode[] = [];
if (manufacturer) {
pieces.push(
<ThemeTag
key="vendor"
color={color}
style={{ fontWeight: 400, marginInlineEnd: 0 }}
>
{formatManufacturer(manufacturer)}
</ThemeTag>
);
}
if (memory) {
pieces.push(<span key="memory">{memory}</span>);
}
if (!pieces.length) return null;
return (
<Flex
align="center"
gap={8}
style={{
minWidth: 0,
color: 'var(--ant-color-text-tertiary)',
fontSize: 12
}}
>
{pieces.flatMap((piece, index) =>
index === 0
? [piece]
: [
<span
key={`dot-${index}`}
style={{ color: 'var(--ant-color-text-quaternary)' }}
>
·
</span>,
piece
]
)}
</Flex>
);
};
// Two-line flavor display: title on top, meta row below. Shared by the create
// drawer's dropdown option and the management list's flavor cell.
export const FlavorOption: React.FC<{
spec?: FlavorSpecLike;
fallbackName?: string | null;
maxWidth?: number | string;
}> = ({ spec = {}, fallbackName, maxWidth = '100%' }) => (
<Flex vertical gap={4} style={{ minWidth: 0, padding: '2px 0' }}>
<AutoTooltip ghost minWidth={20} maxWidth={maxWidth}>
{getFlavorTitle(spec, fallbackName)}
</AutoTooltip>
<FlavorMeta spec={spec} />
</Flex>
);
// Single-line flavor display: title then meta inline. Used for the collapsed
// selected value in the create drawer's Select.
export const FlavorSelected: React.FC<{
spec?: FlavorSpecLike;
fallbackName?: string | null;
}> = ({ spec = {}, fallbackName }) => (
<Flex align="center" gap={8} style={{ minWidth: 0 }}>
<AutoTooltip ghost minWidth={20} maxWidth={200}>
{getFlavorTitle(spec, fallbackName)}
</AutoTooltip>
<FlavorMeta spec={spec} />
</Flex>
);
@@ -1,120 +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 only applies to accelerator (GPU) flavors; a
non-acceleratable (generic) flavor has none. (Sliceable is no
longer a flavor field — it is observed per instance type on
status.detail.slicedDetail.) */}
{spec.acceleratable && (
<Flex wrap gap={16}>
<MetaItem
icon="icon-ram-02"
label={intl.formatMessage({
id: 'gpuservice.instance.memory'
})}
value={formatMemoryDisplay(spec.memory ?? undefined) ?? '-'}
/>
</Flex>
)}
</Flex>
</TemplateCard>
);
})}
</Flex>
);
};
export default FlavorList;
@@ -1,151 +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, isSliceableDetail } 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 || {};
// Observed hardware (manufacturer / memory / sliced capability, …) comes
// from status.detail and may be absent until the operator backfills status.
const detail = data.status?.detail || {};
const unit = spec.unitResources || {};
const phase = data.status?.phase || '';
const manufacturer = detail.manufacturer || '';
const manufacturerColor = manufactureColorMap[manufacturer] ?? 'purple';
const sliceable = isSliceableDetail(detail.slicedDetail);
const memoryText = formatMemoryDisplay(detail.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}>
{detail.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}
{detail.clockSpeed ? <span>{detail.clockSpeed}</span> : null}
<span
className={`${styles.tag} ${
sliceable ? styles.tagSliceable : styles.tagPlain
}`}
>
{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',
Draining: 'Draining'
};
export const InstanceTypePhaseLabelMap: Record<string, string> = {
[InstanceTypePhaseValueMap.Active]: 'Active',
[InstanceTypePhaseValueMap.Inactive]: 'Inactive',
[InstanceTypePhaseValueMap.Draining]: 'Draining'
};
export const status: Record<string, StatusType> = {
[InstanceTypePhaseValueMap.Active]: StatusMaps.success,
[InstanceTypePhaseValueMap.Inactive]: StatusMaps.inactive,
[InstanceTypePhaseValueMap.Draining]: 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,73 +0,0 @@
import {
InstanceTypeDetail,
InstanceTypeResource
} from '../../instances/config/types';
export interface UnitResources {
cpu?: string | null;
ram?: string | null;
}
// spec carries user-defined fields only; observed hardware (manufacturer,
// memory, sliced capability, …) lives on status.detail.
export interface InstanceTypeSpec {
displayName?: string | null;
os?: string | null;
arch?: string | null;
acceleratable?: boolean;
acceleratorGroup?: string | null;
generalGroup?: string | null;
unitResources?: UnitResources | null;
localStorage?: string | null;
}
export interface InstanceTypeStatus {
// Observed hardware descriptor; absent until the operator backfills status.
detail?: InstanceTypeDetail | null;
phase?: string | null;
phaseMessage?: string | null;
// Per-mode resource accounting ({onceMaxRequest, remaining, capacity}).
accelerator?: InstanceTypeResource | null;
acceleratorShared?: InstanceTypeResource | null;
acceleratorSliced?: InstanceTypeResource | null;
cpu?: InstanceTypeResource | 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;
acceleratable?: boolean;
acceleratorGroup?: string | null;
generalGroup?: string | null;
};
}
// Body for POST /gpu-instance-types (GPUInstanceTypeCreate).
export interface FormData {
name: string;
spec: {
displayName?: string | null;
acceleratorGroup?: string | null;
generalGroup?: string | null;
acceleratable?: boolean;
os: string;
arch?: string | null;
unitResources?: UnitResources;
localStorage?: string | null;
};
}
@@ -1,327 +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 {
FlavorOption,
FlavorSelected,
getFlavorTitle
} from '../components/flavor-display';
import { ArchOptions, GPU_INSTANCE_TYPE_OS } from '../config';
import { FlavorItem, FormData } from '../config/types';
import styles from '../styles/instance-types.module.less';
// 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 from the flavor Select. Its acceleratorGroup /
// generalGroup / acceleratable are copied into the created instance type.
selectedFlavor?: FlavorItem | null;
flavorList: FlavorItem[];
flavorLoading?: boolean;
onFlavorChange: (flavor: FlavorItem | null) => void;
onFinish: (values: FormData) => Promise<void>;
onFinishFailed?: (errorInfo: any) => void;
}
const GPUServiceInstanceTypeForm: React.FC<InstanceTypeFormProps> = forwardRef(
(props, ref) => {
const {
open,
selectedFlavor,
flavorList,
flavorLoading,
onFlavorChange,
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();
}
}));
// Split flavors into two groups: CPU compute (generic) and GPU compute
// (accelerator). Groups render as labeled sections in the dropdown.
const toFlavorOption = (flavor: FlavorItem) => ({
value: flavor.name,
label: getFlavorTitle(flavor.spec, flavor.name),
flavor
});
const cpuFlavors = flavorList.filter(
(flavor) => !flavor.spec?.acceleratable
);
const gpuFlavors = flavorList.filter(
(flavor) => flavor.spec?.acceleratable
);
const flavorOptions = [
cpuFlavors.length && {
label: intl.formatMessage({
id: 'gpuservice.instanceType.flavor.cpuGroup'
}),
title: 'cpu',
options: cpuFlavors.map(toFlavorOption)
},
gpuFlavors.length && {
label: intl.formatMessage({
id: 'gpuservice.instanceType.flavor.gpuGroup'
}),
title: 'gpu',
options: gpuFlavors.map(toFlavorOption)
}
].filter(Boolean) as any;
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: {
displayName: values.spec?.displayName?.trim() || null,
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<FormData>
name={['spec', 'displayName']}
rules={[
{
max: 63,
message: intl.formatMessage({
id: 'gpuservice.template.displayName.max'
})
}
]}
>
<CInput.Input
trim={false}
label={intl.formatMessage({
id: 'gpuservice.template.displayName'
})}
/>
</Form.Item>
<Form.Item>
<SealSelect
label={intl.formatMessage({ id: 'gpuservice.instanceType.flavor' })}
required
showSearch
optionFilterProp="label"
classNames={{ popup: { root: styles.flavorDropdown } }}
loading={flavorLoading}
value={selectedFlavor?.name}
options={flavorOptions}
onChange={(val: string) =>
onFlavorChange(
flavorList.find((flavor) => flavor.name === val) ?? null
)
}
optionRender={(option: any) => {
const flavor: FlavorItem = option.data.flavor;
return (
<FlavorOption spec={flavor.spec} fallbackName={flavor.name} />
);
}}
labelRender={({ value }) => {
const flavor = flavorList.find((item) => item.name === value);
return flavor ? (
<FlavorSelected spec={flavor.spec} fallbackName={flavor.name} />
) : (
((value ?? '') as React.ReactNode)
);
}}
/>
</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,232 +0,0 @@
import { QuestionCircleOutlined } from '@ant-design/icons';
import {
AutoTooltip,
DropdownButtons,
icons,
StatusTag
} 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 { isSliceableDetail } from '../../instances/config';
import { ceilMilliToCore, parseQuantityToGi } from '../../utils';
import { FlavorOption } from '../components/flavor-display';
import {
InstanceTypePhaseLabelMap,
InstanceTypePhaseValueMap,
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. The
// activate / deactivate action is chosen from the row's current phase: Active
// types can be deactivated, Inactive ones activated (none while Preparing).
const buildRowActions = (record: ListItem) => {
const phase = record.status?.phase;
const actions: any[] = [];
if (phase === InstanceTypePhaseValueMap.Active) {
actions.push({
label: 'gpuservice.instanceType.deactivate',
key: 'deactivate',
locale: true,
icon: icons.Disabled
});
} else if (phase === InstanceTypePhaseValueMap.Inactive) {
actions.push({
label: 'gpuservice.instanceType.activate',
key: 'activate',
locale: true,
icon: icons.Charger
});
}
actions.push({
label: 'common.button.delete',
key: 'delete',
locale: true,
icon: icons.DeleteOutlined,
props: { danger: true }
});
return actions;
};
// 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 },
// Prefer the friendly display name, fall back to the resource name.
render: (text: string, record: ListItem) => {
const label = record.spec?.displayName || text;
return (
<AutoTooltip ghost minWidth={20} maxWidth={200} title={label}>
<span className="text-primary">{label || '-'}</span>
</AutoTooltip>
);
}
},
{
// Flavor cell mirrors the create drawer's dropdown: product name on
// top, manufacturer · memory · sliceable on the meta line below.
// Observed hardware comes from status.detail (absent until the
// operator backfills status); sliceable is derived from slicedDetail.
title: intl.formatMessage({ id: 'gpuservice.instanceType.flavor' }),
dataIndex: ['status', 'detail', 'product'],
key: 'product',
ellipsis: { showTitle: false },
render: (_text: string, record: ListItem) => {
const detail = record.status?.detail;
return (
<FlavorOption
spec={{
acceleratable: record.spec?.acceleratable,
manufacturer: detail?.manufacturer,
product: detail?.product,
memory: detail?.memory,
sliceable: isSliceableDetail(detail?.slicedDetail)
}}
fallbackName={record.name}
maxWidth={200}
/>
);
}
},
{
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.instanceType.platform' }),
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 (
<AutoTooltip
ghost
maxWidth={240}
title={arch ? `${os}/${arch}` : os}
>
{arch ? `${os}/${arch}` : os}
</AutoTooltip>
);
}
},
{
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={buildRowActions(record)}
onSelect={(val: string) => handleSelect(val, record)}
/>
)
}
];
}, [handleSelect, intl]);
};
export default useInstanceTypeColumns;
@@ -1,271 +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 {
activateGPUInstanceType,
deactivateGPUInstanceType,
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,
startWatch
} = 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);
startWatch(firstK8s.id);
}
setLoaded(true);
};
init();
}, []);
const handleClusterChange = useMemoizedFn(async (value: number) => {
setClusterId(value);
setKeyword('');
await fetchInstanceTypes(value);
startWatch(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 handleToggleActive = useMemoizedFn(
(record: ListItem, activate: boolean) => {
if (clusterId == null) return;
const action = activate
? activateGPUInstanceType
: deactivateGPUInstanceType;
deleteModalRef.current?.show({
content: intl.formatMessage({ id: 'gpuservice.instanceType' }),
title: activate
? 'common.title.activate.confirm'
: 'common.title.deactivate.confirm',
okText: activate
? 'gpuservice.instanceType.activate'
: 'gpuservice.instanceType.deactivate',
operation: activate
? 'common.activate.single.confirm'
: 'common.deactivate.single.confirm',
name: record.spec?.displayName || record.name,
async onOk() {
await action({ name: record.name, cluster_id: clusterId });
message.success(intl.formatMessage({ id: 'common.message.success' }));
fetchInstanceTypes(clusterId);
}
});
}
);
const handleSelect = useMemoizedFn((val: string, record: ListItem) => {
if (val === 'delete') {
handleDelete(record);
} else if (val === 'activate') {
handleToggleActive(record, true);
} else if (val === 'deactivate') {
handleToggleActive(record, false);
}
});
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}
scroll={{ x: 'max-content' }}
className={'scroll-table'}
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,113 +0,0 @@
import { WatchEventType } from '@/config';
import useSetChunkRequest, {
createAxiosToken
} from '@/hooks/use-chunk-request';
import { useRequest } from 'ahooks';
import { CancelTokenSource } from 'axios';
import qs from 'query-string';
import { useEffect, useRef, useState } from 'react';
import { GPU_INSTANCE_TYPES_API, queryGPUInstanceTypes } from '../apis';
import { ListItem } from '../config/types';
// Merge a batch of watch events into the current name-keyed list. Instance
// types have no numeric id, so we upsert / remove by `name` rather than reuse
// the shared id-based chunked-list helper.
const mergeWatchEvents = (current: ListItem[], events: any[]) => {
let list = [...current];
events.forEach((event: any) => {
const collection: ListItem[] = event?.collection || [];
if (event?.type === WatchEventType.DELETE) {
const names = collection.map((item) => item.name);
list = list.filter((item) => !names.includes(item.name));
} else if (
event?.type === WatchEventType.CREATE ||
event?.type === WatchEventType.UPDATE
) {
collection.forEach((item) => {
const index = list.findIndex((it) => it.name === item.name);
if (index > -1) {
list[index] = item;
} else {
list = [item, ...list];
}
});
}
});
return list;
};
// Cluster-scoped instance types for the management list. Action-driven:
// call fetchInstanceTypes(clusterId) from the cluster picker / refresh, not
// via an effect dependency. startWatch(clusterId) opens a chunked watch that
// keeps the list in sync with live create/update/delete events.
export default function useQueryInstanceTypes() {
const tokenRef = useRef<CancelTokenSource | null>(null);
const chunkRequestRef = useRef<any>(null);
const { setChunkRequest } = useSetChunkRequest();
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');
};
const stopWatch = () => {
chunkRequestRef.current?.current?.cancel?.();
};
const startWatch = (clusterId: number) => {
stopWatch();
chunkRequestRef.current = setChunkRequest({
url: `${GPU_INSTANCE_TYPES_API}?${qs.stringify({
cluster_id: clusterId
})}`,
handler: (events: any[]) => {
setDataList((pre) => mergeWatchEvents(pre, events));
}
});
};
useEffect(() => {
return () => {
cancel();
tokenRef.current?.cancel();
stopWatch();
};
}, []);
return {
dataList,
loading,
fetchInstanceTypes,
cancelRequest,
startWatch,
stopWatch,
setDataList
};
}
@@ -1,168 +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;
}
// ============ flavor select dropdown ============
.flavorDropdown {
:global {
// tighten the indent of grouped options
.ant-select-item-option-grouped {
padding-inline-start: 12px;
}
// divider between options
.ant-select-item-option {
border-block-end: 1px solid var(--ant-color-border-secondary);
&:last-child {
border-block-end: none;
}
}
}
}
// ============ 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
@@ -5,19 +5,23 @@ import useUserDirectory from '@/pages/gpu-service/hooks/use-user-directory';
import Separator from '@/pages/llmodels/components/separator';
import { getGPUStackPlugin } from '@/plugins';
import { SearchOutlined } from '@ant-design/icons';
import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui';
import {
AlertBlockInfo,
ColumnWrapper,
GSDrawer,
ModalFooter
} from '@gpustack/core-ui';
import { useIntl, useModel } from '@umijs/max';
import { Input, Typography } from 'antd';
import _ from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ListItem as TemplateItem } from '../../templates/config/types';
import useQueryTemplates from '../../templates/services/use-query-templates';
import { InstanceStatusValueMap } from '../config';
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
import GPUServiceInstanceForm from '../forms';
import TemplateSelector, { TemplateGroup } from '../forms/template-selector';
import useQueryInstanceTypes from '../services/use-query-instance-types';
import styles from '../styles/instances.module.less';
import { saveInstanceDataInDescription } from '../utils/instance-description';
import InstanceTypeList from './instance-type-list';
type AddModalProps = {
@@ -25,6 +29,7 @@ type AddModalProps = {
action: PageActionType;
open: boolean;
width?: number | string;
realAction?: string;
clusterList?: Array<{
label: string;
value: number;
@@ -78,7 +83,8 @@ const AddModal: React.FC<AddModalProps> = ({
data,
onCancel,
width,
clusterList = []
clusterList = [],
realAction
}) => {
const intl = useIntl();
const { initialState } = useModel('@@initialState') || {};
@@ -97,12 +103,6 @@ const AddModal: React.FC<AddModalProps> = ({
manufacturer: undefined
});
const [templateId, setTemplateId] = useState<number | undefined>();
// Re-selected instance type on a stopped-instance edit. Kept separate from
// `instanceTypeSelection` (the create card selection) so the two flows don't
// couple; starts empty each open (no default highlight).
const [editSelectedType, setEditSelectedType] = useState<string | undefined>(
undefined
);
const [instanceKeyword, setInstanceKeyword] = useState('');
const [templateKeyword, setTemplateKeyword] = useState('');
const { loading, guard, run, release } = useSubmitLock();
@@ -166,18 +166,9 @@ const AddModal: React.FC<AddModalProps> = ({
);
// const readonly = action === PageAction.VIEW;
const readonly = false;
const showResourceSelectors = action === PageAction.CREATE;
// Only a stopped instance can be re-typed on edit. It shows the instance-type
// column (but not the template column) beside the form; the create card
// columns render for CREATE.
const isStoppedEdit =
action === PageAction.EDIT &&
data?.status?.phase === InstanceStatusValueMap.Stopped;
const showInstanceTypeColumn = showResourceSelectors || isStoppedEdit;
// Editing a non-stopped instance is restricted: only displayName and the
// SSH public keys stay editable; the type / template / storage sections
// render disabled. A stopped instance edits everything.
const isRestrictedEdit = action === PageAction.EDIT && !isStoppedEdit;
const isRecreate = realAction === PageAction.CREATE;
const showResourceSelectors = action === PageAction.CREATE || isRecreate;
const shouldAutoSelectResource = action === PageAction.CREATE && !isRecreate;
const findTemplateByManufacturer = (
manufacturer: string | undefined,
@@ -188,13 +179,24 @@ const AddModal: React.FC<AddModalProps> = ({
: undefined;
};
// GPU types carry their accelerator vendor on status.detail (observed — may
// be absent until the operator backfills status); non-acceleratable (CPU)
// types all map to the single 'cpu' bucket used to match templates.
const saveInstanceDataInDescription = (instanceType: InstanceTypeItem) => {
return JSON.stringify({
name: instanceType.name,
spec: {
..._.omit(instanceType.spec, ['cache', 'cpu']),
cpu: _.pick(instanceType.spec?.cpu, [
'manufacturer',
'product',
'family'
])
}
});
};
// GPU types carry their accelerator vendor; non-acceleratable (CPU) types
// all map to the single 'cpu' bucket used to match templates.
const manufacturerOf = (instanceType: InstanceTypeItem) =>
instanceType.spec.acceleratable
? (instanceType.status?.detail?.manufacturer ?? undefined)
: 'cpu';
instanceType.spec.acceleratable ? instanceType.spec?.manufacturer : 'cpu';
// apply the selection of instance type and template
const applySelection = (
@@ -261,12 +263,43 @@ const AddModal: React.FC<AddModalProps> = ({
);
};
const findAggregateOf = (
candidateName: string | undefined,
clusterId: number | null | undefined,
instanceTypes: InstanceTypeItem[]
): InstanceTypeItem | undefined => {
if (!candidateName) return undefined;
return instanceTypes.find((item) =>
(item.status?.tiers ?? []).some((tier) =>
(tier.candidates ?? []).some(
(c) => c.name === candidateName && Number(c.cluster) === clusterId
)
)
);
};
// initial for first
const applyAutoSelection = (
instanceTypes: InstanceTypeItem[],
templates: TemplateItem[],
orgId?: number | null
) => {
// On edit / view, surface the persisted selection in the card list.
if (!shouldAutoSelectResource) {
const aggregate = findAggregateOf(
data?.spec?.type,
data?.clusterId,
instanceTypes
);
if (aggregate) {
setInstanceTypeSelection({
instanceType: aggregate.name,
manufacturer: manufacturerOf(aggregate)
});
}
return;
}
// Scope to clusters the chosen org owns (admin "All" view).
const owned = filterTypesByOwner(instanceTypes, orgId);
@@ -326,7 +359,6 @@ const AddModal: React.FC<AddModalProps> = ({
manufacturer: undefined
});
setTemplateId(undefined);
setEditSelectedType(undefined);
setInstanceKeyword('');
setTemplateKeyword('');
setScopeOrgId(undefined);
@@ -335,12 +367,8 @@ const AddModal: React.FC<AddModalProps> = ({
if (action === PageAction.CREATE) {
loadCreateResources();
} else if (action === PageAction.EDIT) {
// Edit has no card columns, but the change-type overlay still needs the
// full instance-type list to re-type a stopped instance.
fetchData({ page: -1 });
}
}, [open, action]);
}, [open, shouldAutoSelectResource, action]);
// filter instance types (already scoped to the chosen org's clusters)
const filteredInstanceTypes = ownedInstanceTypes.filter((item) =>
@@ -478,17 +506,6 @@ const AddModal: React.FC<AddModalProps> = ({
applySelection(item, template);
};
// Stopped-edit re-type. Decoupled from applySelection (the create flow): it
// only snapshots the type into `description` and applies it to the form — no
// template selection or filtering.
const handleEditInstanceTypeChange = (item: InstanceTypeItem) => {
setEditSelectedType(item.name);
form.current?.setFieldsValue({
description: saveInstanceDataInDescription(item)
});
form.current?.applyInstanceType?.(item);
};
const handleTemplateChange = (id: number, item: TemplateItem) => {
setTemplateId(id);
const formValues = form.current?.getFieldsValue();
@@ -526,108 +543,104 @@ const AddModal: React.FC<AddModalProps> = ({
footer={false}
>
<div className={styles.container}>
{showInstanceTypeColumn && (
<div
className={styles.colWrapper}
// The 33% cap suits the 3-column create layout; in the 2-column
// stopped-edit layout, split the space evenly with the form column.
style={isStoppedEdit ? { flex: 1, maxWidth: 'none' } : undefined}
>
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
<div className={styles.panelBody}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'var(--ant-color-bg-elevated)'
}}
>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({
id: 'gpuservice.instance.types'
})}
</ColTitle>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder={intl.formatMessage({
id: 'gpuservice.instance.search.type.placeholder'
})}
value={instanceKeyword}
onChange={(e) => setInstanceKeyword(e.target.value)}
/>
</div>
<InstanceTypeList
// Edit (stopped) re-selection is decoupled from create's
// card selection: separate highlight state + apply handler.
value={
isStoppedEdit
? editSelectedType
: instanceTypeSelection.instanceType
}
dataList={filteredInstanceTypes}
loading={instanceTypesLoading}
onChange={
isStoppedEdit
? handleEditInstanceTypeChange
: handleInstanceTypeChange
}
/>
</div>
</ColumnWrapper>
<Separator></Separator>
</div>
)}
{showResourceSelectors && (
<div className={styles.colWrapper}>
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
<div className={styles.panelBody}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'var(--ant-color-bg-elevated)'
}}
>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({
id: 'gpuservice.instance.templates'
})}
</ColTitle>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder={intl.formatMessage({
id: 'gpuservice.instance.search.template.placeholder'
})}
value={templateKeyword}
onChange={(e) => setTemplateKeyword(e.target.value)}
<>
<div className={styles.colWrapper}>
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
<div className={styles.panelBody}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'var(--ant-color-bg-elevated)'
}}
>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({
id: 'gpuservice.instance.types'
})}
</ColTitle>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder={intl.formatMessage({
id: 'gpuservice.instance.search.type.placeholder'
})}
value={instanceKeyword}
onChange={(e) => setInstanceKeyword(e.target.value)}
/>
</div>
<InstanceTypeList
value={instanceTypeSelection.instanceType}
dataList={filteredInstanceTypes}
loading={instanceTypesLoading}
onChange={handleInstanceTypeChange}
/>
</div>
<TemplateSelector
value={templateId}
loading={templateLoading || !initialized}
groups={templateGroups}
onChange={handleTemplateChange}
/>
</div>
</ColumnWrapper>
<Separator></Separator>
</div>
</ColumnWrapper>
<Separator></Separator>
</div>
<div className={styles.colWrapper}>
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
<div className={styles.panelBody}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'var(--ant-color-bg-elevated)'
}}
>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({
id: 'gpuservice.instance.templates'
})}
</ColTitle>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder={intl.formatMessage({
id: 'gpuservice.instance.search.template.placeholder'
})}
value={templateKeyword}
onChange={(e) => setTemplateKeyword(e.target.value)}
/>
</div>
<TemplateSelector
value={templateId}
loading={templateLoading || !initialized}
groups={templateGroups}
onChange={handleTemplateChange}
/>
</div>
</ColumnWrapper>
<Separator></Separator>
</div>
</>
)}
<div className={styles.formWrapper}>
<ColumnWrapper
styles={{ container: { paddingBlock: 0 } }}
footer={
<>
{isRecreate && open && (
<div style={{ marginInline: 24, paddingTop: 8 }}>
<AlertBlockInfo
type="warning"
contentStyle={{ paddingInline: 0 }}
message={intl.formatMessage({
id: 'gpuservice.instance.recreate.confirm.content'
})}
/>
</div>
)}
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
@@ -651,9 +664,9 @@ const AddModal: React.FC<AddModalProps> = ({
<GPUServiceInstanceForm
ref={form}
action={action}
realAction={realAction}
currentData={data}
disabled={readonly}
restrictedEdit={isRestrictedEdit}
onFinish={onFinish}
onFinishFailed={release}
onScopeChange={handleScopeChange}
@@ -1,17 +1,13 @@
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,
InstanceTypeSnapshotSpec
} from '../config/types';
import { buildInstanceTypeSnapshotSpec } from '../utils/instance-description';
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
const Vendors = ['intel'] as const;
const Title = styled.div`
display: flex;
@@ -58,17 +54,10 @@ const Meta = styled.div<{ $columns?: number }>`
interface InstanceTypeItemProps {
item: InstanceTypeItemModel;
action?: React.ReactNode;
}
interface MetadataSectionProps {
// The flat snapshot / display model — built from a live item with
// buildInstanceTypeSnapshotSpec, or parsed back from a persisted
// `description` snapshot (readonly edit card).
spec: InstanceTypeSnapshotSpec;
// status.onceMaxRequest.acceleratorSliced (max sliceable percentage). Shown
// next to Max for sliceable types.
slicedMaxPercentage?: number;
spec: InstanceTypeItemModel['spec'];
}
const MetaItem: React.FC<{
@@ -111,14 +100,8 @@ const CPUManufacturerTag: React.FC<{ manufacturer?: string }> = ({
);
};
// Derives the display fields from the flat snapshot spec (the UI document
// format — built from a live item with buildInstanceTypeSnapshotSpec, or
// parsed back from a persisted `description` snapshot). Observed hardware
// (manufacturer / product / memory / cpu) originates from status.detail.
function getInstanceDerived(
spec: InstanceTypeSnapshotSpec = {},
fallbackName?: string
) {
function getInstanceDerived(item: InstanceTypeItemModel) {
const spec = item.spec || {};
const acceleratable = spec.acceleratable;
const cpuManufacturer = acceleratable
@@ -129,113 +112,122 @@ function getInstanceDerived(
acceleratable,
isGPU: acceleratable,
manufacturer: acceleratable ? spec.manufacturer || '' : 'cpu', // GPU manufacturer or 'cpu' for non-acceleratable types
displayName: acceleratable
? spec.displayName || spec.product || fallbackName
: spec.displayName || 'CPU-only',
displayName: acceleratable ? spec.product || item.name : 'CPU Only',
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(spec);
// 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]];
const { ramUnit, cpuUnitCores, isGPU, os, arch } = getInstanceDerived({
spec
} as InstanceTypeItemModel);
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>
);
};
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
item,
action
}) => {
// Fold the live (API-shaped) item into the flat display model: definition
// fields from spec, observed hardware from status.detail.
const specData = buildInstanceTypeSnapshotSpec(item);
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
const specData = item.spec || {};
const { acceleratable, manufacturer, displayName, cpuManufacturer } =
getInstanceDerived(specData, item.name);
getInstanceDerived(item);
const manufacturerColor = manufactureColorMap[manufacturer] ?? 'purple';
const showManufacturerTag = acceleratable && !!manufacturer;
@@ -267,7 +259,7 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
disabled={false}
style={{ fontWeight: 400 }}
>
{formatManufacturer(manufacturer)}
{manufacturer?.toUpperCase()}
</ThemeTag>
)}
{showCpuManufacturerTag && (
@@ -279,19 +271,9 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
{cpuManufacturer}
</ThemeTag>
)}
<PluginExtraFields
name="InstanceTypeBillingBadge"
context={{ instanceType: item }}
/>
{action && <div style={{ marginLeft: 8 }}>{action}</div>}
</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)}
+15 -42
View File
@@ -3,15 +3,7 @@ import { StatusType } from '@/config/types';
import { IconFont, icons } from '@gpustack/core-ui';
import _ from 'lodash';
import React from 'react';
import { AcceleratorSlicedDetail, ListItem } from '../config/types';
// Whether a type can be sliced, per the API contract (replaces the removed
// `spec.sliceable` boolean): logical (soft) slicing reports per-card capacity
// or physical (e.g. MIG) profiles exist. Every level of slicedDetail may be
// absent (exclude_none responses).
export const isSliceableDetail = (detail?: AcceleratorSlicedDetail | null) =>
(detail?.logical?.count ?? 0) > 0 ||
(detail?.physical?.profiles?.length ?? 0) > 0;
import { ListItem } from '../config/types';
export const InstanceStatusValueMap = {
Scheduling: 'Scheduling',
@@ -259,7 +251,7 @@ const parseQuantity = (value?: string | null): number => {
// Returns the slider max for the accelerator count: the largest
// tier.onceMaxRequest.accelerator across all tiers (not from candidates).
export const getAcceleratorMax = (
tiers?: { onceMaxRequest: { accelerator?: string | null } }[] | null
tiers?: { onceMaxRequest: { accelerator?: string } }[] | null
) => {
if (!tiers?.length) return 0;
return tiers.reduce((acc, tier) => {
@@ -270,46 +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. Only Active candidates
// are eligible. 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;
phase?: string | null;
cpu?: { remaining?: string | null } | null;
acceleratorSliced?: { remaining?: string | null } | null;
ram?: { remaining?: string | null } | null;
localStorage?: { remaining?: string | null } | null;
}
>(
tiers:
| {
onceMaxRequest: {
accelerator?: string | null;
acceleratorSliced?: string | null;
};
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) => {
// Only Active candidates can serve new instances.
if (c.phase !== InstanceTypePhaseValueMap.Active) return false;
// 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) =>
@@ -320,14 +298,9 @@ 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
? acceleratorCount >= count
: acceleratorCount === 0;
const fits = acceleratable
? acceleratorCount >= count
: acceleratorCount === 0;
if (!fits) continue;
const candidate = tier.candidates?.find(hasResources);
if (candidate) return candidate;
+46 -131
View File
@@ -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?: {
@@ -131,129 +126,70 @@ export interface InstanceTypeResource {
export interface InstanceTypeCandidate {
cluster: string;
name: string;
accelerator?: InstanceTypeResource | null;
cpu?: InstanceTypeResource | null;
// Shared-mode available resource (not shown in the GPU Instance form).
acceleratorShared?: InstanceTypeResource | null;
// Sliced-mode available resource.
acceleratorSliced?: InstanceTypeResource | null;
// This candidate's sliced (partitioning) capability.
acceleratorSlicedDetail?: AcceleratorSlicedDetail | null;
phase?: 'Active' | 'Inactive' | 'Draining' | null;
accelerator: InstanceTypeResource;
cpu: InstanceTypeResource;
ram: InstanceTypeResource;
localStorage: InstanceTypeResource;
}
// Per-mode maxima as plain number strings — the shape of the aggregated
// status.onceMaxRequest / status.remaining AND of tier onceMaxRequest /
// remaining (they are identical in the API). accelerator counts whole cards,
// acceleratorShared / acceleratorSliced are percentages, cpu is cores. The
// API carries no ram / localStorage here — RAM caps derive from
// spec.unitResources, disk from spec.localStorage.
export interface InstanceTypeOverviewResource {
accelerator?: `${number}` | null;
acceleratorShared?: `${number}` | null;
acceleratorSliced?: `${number}` | null;
cpu?: QuanityCPU | null;
export interface InstanceTypeTierOnceMaxRequestResource {
accelerator?: string;
cpu: QuanityCPU;
ram: QuanityMemory;
localStorage: QuanityLocalStorage;
}
export interface InstanceTypeTier {
onceMaxRequest: InstanceTypeOverviewResource;
remaining?: InstanceTypeOverviewResource | null;
// The tier's aggregated sliced (partitioning) capability.
acceleratorSlicedDetail?: AcceleratorSlicedDetail | null;
onceMaxRequest: InstanceTypeTierOnceMaxRequestResource;
candidates?: InstanceTypeCandidate[] | null;
}
export interface InstanceTypeOnceMaxRequestResource {
accelerator?: `${number}` | null;
cpu: QuanityCPU;
ram: QuanityMemory;
localStorage: QuanityLocalStorage;
}
export interface CPUCache {
l1i?: string | null;
l1d?: string | null;
l2?: string | null;
l3?: string | null;
l1i: string;
l1d: string;
l2: string;
l3: string;
}
export interface CPUInfo {
physicalCores?: string | null;
threadsPerPhysicalCore?: string | null;
logicalCores?: string | null;
stepping?: string | null;
clockSpeed?: string | null;
maxClockSpeed?: string | null;
cacheLine?: string | null;
cache?: CPUCache | null;
manufacturer?: string | null;
product?: string | null;
family?: string | null;
physicalCores: string;
threadsPerPhysicalCore: string;
logicalCores: string;
stepping: string | null;
clockSpeed: string | null;
maxClockSpeed: string | null;
cacheLine: string;
cache: CPUCache;
manufacturer: string;
product: string;
family: string;
}
// Sliced (partitioning) capability descriptor. Replaces the removed
// `spec.sliceable` boolean: a type is sliceable when logical (soft) slicing
// reports capacity or physical (e.g. MIG) profiles exist — see
// isSliceableDetail in ./index. Appears as status.detail.slicedDetail and as
// tier / candidate `acceleratorSlicedDetail` in the aggregated view.
export interface AcceleratorSlicedLogicalDetail {
coresPercentageOvercommit?: boolean;
// Max soft slices per card; 0 → soft slicing unsupported.
count?: number | null;
}
export interface AcceleratorSlicedPhysicalDetailProfile {
name?: string | null;
count?: number | null;
}
export interface AcceleratorSlicedPhysicalDetail {
profiles?: AcceleratorSlicedPhysicalDetailProfile[] | null;
count?: number | null;
}
export interface AcceleratorSlicedDetail {
logical?: AcceleratorSlicedLogicalDetail | null;
physical?: AcceleratorSlicedPhysicalDetail | null;
}
// status.detail — the observed hardware descriptor. The API moved these off
// spec (spec keeps user-defined fields only). The whole object is absent until
// the operator backfills status, and every response is exclude_none — treat
// every key as possibly missing.
export interface InstanceTypeDetail {
// Device identity.
manufacturer?: string | null;
product?: string | null;
family?: string | null;
// Host node CPU (flat fields, as opposed to the nested `cpu` below).
physicalCores?: string | null;
threadsPerPhysicalCore?: string | null;
logicalCores?: string | null;
stepping?: string | null;
clockSpeed?: string | null;
maxClockSpeed?: string | null;
cacheLine?: string | null;
cache?: CPUCache | null;
// Accelerator hardware.
memory?: string | null;
cores?: string | null;
computeCapability?: string | null;
slicedDetail?: AcceleratorSlicedDetail | null;
// The accelerator's own CPU (distinct from the flat host CPU fields above).
cpu?: CPUInfo | null;
}
// Mirrors the API spec object exactly (user-defined fields only — observed
// hardware lives on status.detail), plus two UI-computed enrichments filled by
// use-query-instance-types whose names exist nowhere in the API.
export interface InstanceTypeSpec {
displayName?: string | null;
acceleratorGroup?: string | null;
generalGroup?: string | null;
acceleratable?: boolean;
os?: string;
arch?: string;
localStorage?: QuanityLocalStorage;
group: string;
acceleratable: boolean;
manufacturer: string;
product?: string | null;
memory?: string | null;
family?: string | null;
computeCapability?: string | null;
sliced?: string | null;
maxComputeUnitCount?: number;
unitResources?: {
cpu: QuanityCPU;
ram: QuanityMemory;
};
// ---- UI-computed (not part of the API contract) ----
// spec.unitResources parsed to numbers.
os?: string;
arch?: string;
cpu?: CPUInfo;
cache?: Record<string, string>;
unitResourcesParsed?: {
cpu: {
cores?: number;
@@ -266,31 +202,10 @@ export interface InstanceTypeSpec {
num: number;
} | null;
};
// Max requestable unit (card / core) count, derived from status.
maxComputeUnitCount?: number;
}
// Flat spec snapshot persisted in a GPU instance's `description` field at
// create time (see utils/instance-description.ts) and reused as the display
// model of the type card / metadata section. It merges the definition spec
// with the observed hardware from status.detail and the derived `sliceable`.
// The flat shape is a UI document format — do NOT confuse it with the API
// InstanceTypeSpec; it stays flat for compatibility with snapshots persisted
// by older instances.
export interface InstanceTypeSnapshotSpec extends InstanceTypeSpec {
manufacturer?: string | null;
product?: string | null;
family?: string | null;
memory?: string | null;
sliceable?: boolean;
// Accelerator CPU identity only (from status.detail.cpu).
cpu?: Pick<CPUInfo, 'manufacturer' | 'product' | 'family'> | null;
}
export interface InstanceTypeStatus {
detail?: InstanceTypeDetail | null;
onceMaxRequest: InstanceTypeOverviewResource;
remaining?: InstanceTypeOverviewResource | null;
onceMaxRequest: InstanceTypeOnceMaxRequestResource;
tiers?: InstanceTypeTier[] | null;
}
+42 -240
View File
@@ -35,11 +35,7 @@ import { DefaultImagePullPolicy } from '../../templates/config';
import TemplateBasicForm, {
BasicResourceMax
} from '../../templates/forms/basic';
import {
isSliceableDetail,
pickCandidateForAccelerator,
StorageModeValueMap
} from '../config';
import { pickCandidateForAccelerator, StorageModeValueMap } from '../config';
import { FormContext } from '../config/form-context';
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
import instanceStyles from '../styles/instances.module.less';
@@ -59,7 +55,7 @@ interface InstanceFormProps {
ref?: any;
open: boolean;
action: PageActionType;
// Present on edit / view.
realAction?: PageActionType | string;
currentData?: ListItem | null;
namespace?: string;
instanceTypeList?: InstanceTypeItem[];
@@ -67,9 +63,6 @@ interface InstanceFormProps {
// surfaces a "no available instance type" message in the scheduling tab.
noAvailableInstanceTypes?: boolean;
disabled?: boolean;
// Editing a non-stopped instance: only displayName and the SSH public keys
// stay editable; the type / template / storage sections render disabled.
restrictedEdit?: boolean;
// Fired when the create-scope picker retargets the form to another
// org (or Global). Only emitted on genuine changes — never on the
// initial mount, and never in builds where the picker isn't mounted
@@ -114,9 +107,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
(props, ref) => {
const {
action,
realAction,
currentData,
disabled,
restrictedEdit,
open,
instanceTypeList = [],
noAvailableInstanceTypes,
@@ -128,9 +121,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
const { getRuleMessage } = useAppUtils();
const [form] = Form.useForm<InstanceFormValues>();
const scrollTabsRef = useRef<any>(null);
// Restricted (non-stopped) edit disables the type / template / storage
// sections; displayName and the SSH public keys keep following `disabled`.
const sectionDisabled = disabled || restrictedEdit;
const formAction =
realAction === PageAction.CREATE ? PageAction.CREATE : action;
const sshEnabled = Form.useWatch('enable_ssh', form);
const description = Form.useWatch(['description'], form);
// `organization_id` is owned by the create-scope picker slot; it only
@@ -281,132 +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, submitted as whole cores / whole Gi (floored, min 1) so
// the payload matches what the disabled CPU / RAM inputs display —
// e.g. 10% of a 4-core / 16Gi card → "1" / "1Gi".
if (sliced && unitResourcesParsed) {
const cpuCores = unitResourcesParsed.cpu?.cores ?? 0;
const ramValue = unitResourcesParsed.ram?.value ?? 0;
return {
cpu: `${Math.max(1, _.floor((cpuCores * percentage) / 100))}`,
ram: `${Math.max(1, _.floor((ramValue * percentage) / 100))}Gi`
};
}
// 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: {
// Floor the scaled unit resources to whole units, never below 1 —
// a small slice (e.g. 8 GB × 10%) still shows a usable 1 vCPU /
// 1 GB instead of 0. Display-only: the submit path recomputes
// both precisely in millicores / Mi.
cpu:
cpuCores != null && percentage > 0
? Math.max(1, _.floor((cpuCores * percentage) / 100))
: null,
ram:
ramValue != null && percentage > 0
? Math.max(1, _.floor((ramValue * percentage) / 100))
: null
}
}
} as any);
};
// Whether the selected type allows the compute (cores) ratio to exceed
// the memory ratio. Without overcommit there is no cores selector and the
// cores ratio is locked to (mirrors) the memory ratio.
const coresOvercommit =
!!selectedInstanceType?.status?.detail?.slicedDetail?.logical
?.coresPercentageOvercommit;
// Single entry point for the sliced memory ratio: write the ratio and
// rescale CPU / RAM off it. With cores overcommit the compute ratio must
// stay >= memory (bump it up when memory overtakes it); without it the
// compute ratio always mirrors memory. Reused by the slider onChange.
const applySliceMemoryPercentage = (value: number) => {
const currentCores = _.toNumber(
form.getFieldValue([
'spec',
'resources',
'acceleratorSlicedCoresPercentage'
])
);
const coresPercentage = coresOvercommit
? Math.max(currentCores, value)
: value;
form.setFieldsValue({
spec: {
resources: {
acceleratorSlicedMemoryPercentage: value,
acceleratorSlicedCoresPercentage: coresPercentage
}
}
} as any);
applySlicedResourceScaling();
};
// Compute (cores) ratio — a GPU-slice-only parameter that rides along on
// submit. It does not scale CPU / RAM (those track the memory ratio), so
// just write the field.
const applySliceCoresPercentage = (value: number) => {
form.setFieldValue(
['spec', 'resources', 'acceleratorSlicedCoresPercentage'],
value
);
};
const resolveAndApply = (
instanceType: InstanceTypeItem | undefined,
count: number,
sliced?: boolean
count: number
) => {
if (!instanceType) {
setSelectedInstanceType(undefined);
@@ -435,23 +328,17 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
instanceType.status?.tiers,
{
count,
acceleratable: instanceType.spec?.acceleratable,
sliced
acceleratable: instanceType.spec?.acceleratable
}
);
console.log('picked candidate', candidate, instanceType, count);
// The API carries no RAM max on onceMaxRequest — derive it from the
// per-unit RAM × the max requestable unit count (RAM always scales with
// the unit count). Disk max comes from spec.localStorage (UI-only cap).
const unitRamGi = instanceType.spec?.unitResourcesParsed?.ram?.value;
const maxUnits = instanceType.spec?.maxComputeUnitCount || 0;
setOnceMaxRequest({
cpu: ceilMilliToCore(candidate?.cpu?.onceMaxRequest)?.cores,
memory: unitRamGi && maxUnits ? unitRamGi * maxUnits : null,
localStorage:
parseQuantityToGi(instanceType.spec?.localStorage)?.value ?? null
memory: parseQuantityToGi(candidate?.ram?.onceMaxRequest)?.value,
localStorage: parseQuantityToGi(candidate?.localStorage?.onceMaxRequest)
?.value
});
form.setFieldsValue({
@@ -467,89 +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 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 defaults for an instance type: memory ratio at 10%
// (never above the type's max sliceable ratio, status.onceMaxRequest
// .acceleratorSliced), and the cores ratio defaulting to the same value
// (cores >= memory). Set both together so a fresh selection doesn't carry a
// stale cores value from a previous type.
const applySlicedDefaults = (instanceType?: InstanceTypeItem) => {
const slicedMax =
_.toNumber(instanceType?.status?.onceMaxRequest?.acceleratorSliced) ||
0;
const memoryPercentage = slicedMax ? Math.min(10, slicedMax) : 10;
form.setFieldsValue({
spec: {
resources: {
acceleratorSlicedMemoryPercentage: memoryPercentage,
acceleratorSlicedCoresPercentage: memoryPercentage
}
}
} as any);
applySlicedResourceScaling();
};
// 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);
}
};
// Apply a chosen instance type to the form: default to sliced mode for a
// sliceable type with no whole-card capacity, otherwise whole-card with a
// count of 1. Shared by the create card selection (imperative handle) and
// the edit change-type overlay.
const 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 =
isSliceableDetail(instanceType.status?.detail?.slicedDetail) &&
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');
resolveAndApply(instanceType, 1);
resolveAndApply(selectedInstanceType, count);
};
const onTargetChange = (key: string) => {
@@ -586,8 +392,11 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
return;
}
// Prefill from the source row on edit / view.
if (currentData) {
if (
action === PageAction.EDIT ||
action === PageAction.VIEW ||
realAction === PageAction.CREATE
) {
console.log('currentData', currentData);
const currentSpec = parseJsonSafe(
currentData?.description || '{}',
@@ -598,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 (edit/view
// render a readonly card).
const persistedSliced =
_.toNumber(
currentData?.spec?.resources?.acceleratorSlicedMemoryPercentage
) > 0;
setSliceMode(persistedSliced ? 'sliced' : 'whole');
form.setFieldsValue({
...currentData,
spec: {
@@ -623,14 +424,8 @@ 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 for a sliced instance.
if (persistedSliced) {
applySlicedResourceScaling();
}
}
}, [action, currentData, form, open, instanceTypeList]);
}, [action, currentData, form, open, realAction, instanceTypeList]);
const getUnitResources = () => {
if (selectedInstanceType?.spec?.unitResourcesParsed) {
@@ -695,7 +490,15 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
form.setFieldsValue(values as any);
},
getFieldsValue: () => form.getFieldsValue(),
applyInstanceType
applyInstanceType: (instanceType?: InstanceTypeItem) => {
if (!instanceType) {
resolveAndApply(undefined, 0);
return;
}
// set default to 1, for all instance types: GPU or non-GPU
resolveAndApply(instanceType, 1);
}
}));
const handleAddSSHKey = () => {
@@ -737,7 +540,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
>
<FormContext.Provider
value={{
action: action,
action: formAction,
currentData: currentData,
isGPUType: isGPUType
}}
@@ -781,7 +584,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
storageMode: StorageModeValueMap.Temporary
}}
>
<Basic action={action} disabled={disabled} />
<Basic action={formAction} disabled={disabled} />
<Form.Item name="clusterId" hidden>
<CInput.Input />
</Form.Item>
@@ -798,16 +601,12 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
forceRender: true,
children: (
<InstanceTypeFormItem
action={action}
disabled={sectionDisabled}
action={formAction}
disabled={disabled}
selectedInstanceType={selectedInstanceType}
currentData={currentData as any}
onceMaxRequest={onceMaxRequest}
noAvailableTypes={noAvailableInstanceTypes}
sliceMode={sliceMode}
onSliceModeChange={handleSliceModeChange}
onSliceMemoryPercentageChange={applySliceMemoryPercentage}
onSliceCoresPercentageChange={applySliceCoresPercentage}
onGPUCountChange={handleAcceleratorChange}
/>
)
@@ -821,7 +620,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
children: (
<TemplateBasicForm
page="instance"
disabled={sectionDisabled}
disabled={disabled || formAction === PageAction.EDIT}
onceMaxRequest={onceMaxRequest}
/>
)
@@ -833,7 +632,10 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}),
forceRender: true,
children: (
<StorageVolume disabled={sectionDisabled} action={action} />
<StorageVolume
disabled={disabled || formAction === PageAction.EDIT}
action={formAction}
/>
)
}
]}
@@ -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';
@@ -12,7 +12,6 @@ import { parseJsonSafe } from '../../utils';
import InstanceTypeItem, {
InstanceMetadataSection
} from '../components/instance-type-item';
import { isSliceableDetail } from '../config';
import { FormContext } from '../config/form-context';
import {
FormData,
@@ -58,24 +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, 50];
// The paired VRAM + Compute selectors (cores overcommit) are grouped in a
// bordered card; a lone "Percentage" selector (no overcommit) renders bare so
// it matches the whole-card GPU Count block's styling.
const SliceFieldWrapper: React.FC<{
withCard: boolean;
children: React.ReactNode;
}> = ({ withCard, children }) =>
withCard ? (
<FieldBlock>
<SelectedCard style={{ padding: 0 }}>{children}</SelectedCard>
</FieldBlock>
) : (
<>{children}</>
);
interface InstanceTypeFormItemProps {
action: PageActionType;
disabled?: boolean;
@@ -86,14 +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;
// Commit a new sliced compute (cores) ratio (writes the field only).
onSliceCoresPercentageChange?: (value: number) => void;
onGPUCountChange?: (value: number) => void;
}
@@ -104,24 +77,13 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
selectedInstanceType,
onceMaxRequest,
noAvailableTypes,
sliceMode = 'whole',
onSliceModeChange,
onSliceMemoryPercentageChange,
onSliceCoresPercentageChange,
onGPUCountChange
}) => {
const intl = useIntl();
const form = Form.useFormInstance();
const { isGPUType } = useContext(FormContext);
// In edit mode the type card is read-only until a type is re-picked from the
// instance-type column (stopped instances only); once selected the section
// behaves like create (editable count / slice controls, live capacity
// labels).
const readonlyType = action === PageAction.EDIT && !selectedInstanceType;
const maxComputeUnitCount = useMemo(() => {
if (readonlyType) {
if (action === PageAction.EDIT) {
const description = parseJsonSafe(
currentData?.description || '{}',
{} as any
@@ -129,91 +91,19 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
return description.spec?.maxComputeUnitCount || 0;
}
return selectedInstanceType?.spec?.maxComputeUnitCount || 0;
}, [readonlyType, currentData, selectedInstanceType]);
}, [action, currentData, selectedInstanceType]);
const isGPU = useMemo(() => {
if (action === PageAction.EDIT) {
return _.toNumber(currentData?.spec?.resources?.accelerator) > 0;
}
return selectedInstanceType?.spec?.acceleratable;
}, [selectedInstanceType, action]);
const handleOnGPUCountChange = (value: number) => {
onGPUCountChange?.(value);
};
// Sliced mode is only offered for sliceable accelerator types, and only when
// the section is editable (create, or edit after re-picking a type; a
// not-yet-re-typed edit renders a readonly card).
const showModeSwitch =
!readonlyType &&
isGPUType &&
isSliceableDetail(selectedInstanceType?.status?.detail?.slicedDetail);
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);
};
// Compute (cores) percentage changed — forward the new value.
const handleCoresPercentageChange = (value: number) => {
onSliceCoresPercentageChange?.(value);
};
// The cores ratio must be >= the memory ratio, so ticks below the current
// memory percentage are disabled (min). Cores range is a fixed 10..100.
const slicedMemoryPercentage =
_.toNumber(
Form.useWatch(
['spec', 'resources', 'acceleratorSlicedMemoryPercentage'],
form
)
) || 1;
// 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;
// Whether the compute (cores) ratio may exceed the memory ratio. When the
// type doesn't support overcommit, cores are locked to the memory ratio —
// no cores selector, and the memory selector reads as a plain "Percentage".
const coresOvercommit =
!!selectedInstanceType?.status?.detail?.slicedDetail?.logical
?.coresPercentageOvercommit;
const modeSegmented = showModeSwitch ? (
<Segmented
size="middle"
type="rounded"
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: number[] =
slicedMaxPercentage < 10
? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
: SLICE_PERCENT_TICKS;
const renderMaxLabel = (
label: React.ReactNode,
max?: number | null
@@ -232,7 +122,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
};
const renderMemoryLabel = (): React.ReactNode => {
if (isGPUType || readonlyType || !onceMaxRequest?.memory) {
if (isGPUType || action === PageAction.EDIT || !onceMaxRequest?.memory) {
return intl.formatMessage({ id: 'gpuservice.template.memory' });
}
@@ -264,8 +154,8 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
}}
>
{description.acceleratable
? `${description.displayName || description.product} x ${currentData?.spec?.resources?.accelerator}`
: description.displayName || 'CPU'}
? `${description.product} x ${currentData?.spec?.resources?.accelerator}`
: 'CPU'}
</span>
<InstanceMetadataSection spec={description}></InstanceMetadataSection>
</Flex>
@@ -298,21 +188,15 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
}
]}
>
{readonlyType ? (
renderInstanceType()
) : (
{action === PageAction.CREATE && (
<InstanceTypePicker
selectedInstanceType={selectedInstanceType}
noAvailable={noAvailableTypes}
/>
)}
{action === PageAction.EDIT && renderInstanceType()}
</Form.Item>
</FieldBlock>
{showModeSwitch && (
<div>
<div style={{ marginBlock: 8 }}>{modeSegmented}</div>
</div>
)}
{!noAvailableTypes && (
<Form.Item<FormData>
key={isGPUType ? 'accelerator' : 'cpu'}
@@ -322,7 +206,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
: ['spec', 'resources', 'cpu']
}
preserve
hidden={readonlyType || isSliced}
hidden={action === PageAction.EDIT}
normalize={(value) => (value != null ? _.toString(value) : undefined)}
getValueProps={(value) => ({
value: value != null ? _.toNumber(value) : undefined
@@ -363,7 +247,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
max={maxComputeUnitCount}
step={1}
required
disabled={disabled || readonlyType}
disabled={disabled || action === PageAction.EDIT}
label={`${intl.formatMessage({ id: 'common.max.count' }, { label: numberSelectionLabel.label })} (${intl.formatMessage(
{
id: 'common.max'
@@ -373,158 +257,6 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
/>
</Form.Item>
)}
{!noAvailableTypes && isSliced && (
<SliceFieldWrapper withCard={coresOvercommit}>
<>
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']}
// Grouped with the compute selector inside one card — tighten
// the default 24px gap between the pair.
style={coresOvercommit ? { marginBottom: 0 } : undefined}
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}
// Inside the card the selector drops its own border; the bare
// (no-overcommit) variant keeps it, like the GPU Count block.
style={coresOvercommit ? { border: 'none' } : undefined}
onChange={handleMemoryPercentageChange}
label={intl.formatMessage({
// Without cores overcommit this single ratio drives both
// VRAM and compute, so drop the "VRAM" qualifier.
id: coresOvercommit
? 'gpuservice.instance.slice.memoryPercentage'
: 'gpuservice.instance.slice.percentage'
})}
/>
</Form.Item>
{/* Compute (cores) percentage. Fixed 10..100 ticks; ticks below the
chosen memory ratio are disabled (cores must be >= memory). Only
types with cores overcommit get the selector — without it the
ratio is locked to the memory percentage (the parent mirrors it),
carried by a hidden field so it still rides the submit. */}
{coresOvercommit ? (
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']}
style={{ marginBottom: 0 }}
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 < slicedMemoryPercentage || num > 100) {
return Promise.reject(
new Error(
intl.formatMessage(
{ id: 'gpuservice.instance.slice.cores.min' },
{ count: slicedMemoryPercentage }
)
)
);
}
return Promise.resolve();
}
}
]}
>
<NumberSelection
min={slicedMemoryPercentage}
max={100}
step={10}
maxCount={SLICE_PERCENT_TICKS.length}
presetValues={SLICE_PERCENT_TICKS}
alwaysShowInput
required
disabled={disabled}
onChange={handleCoresPercentageChange}
style={{ border: 'none' }}
label={intl.formatMessage({
id: 'gpuservice.instance.slice.coresPercentage'
})}
/>
</Form.Item>
) : (
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']}
style={{ marginBottom: 0 }}
hidden
>
<InputNumber />
</Form.Item>
)}
</>
</SliceFieldWrapper>
)}
{/* A not-yet-re-typed 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. */}
{readonlyType && (
<>
<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 }}>
@@ -549,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}
/>
@@ -3,7 +3,6 @@ import type { PageActionType } from '@/config/types';
import useBodyScroll from '@/hooks/use-body-scroll';
import { useIntl } from '@umijs/max';
import { useState } from 'react';
import { InstanceStatusValueMap } from '../config';
import type { ListItem } from '../config/types';
const useCreateInstance = () => {
@@ -15,26 +14,30 @@ const useCreateInstance = () => {
title: string;
currentData?: ListItem | null;
width?: number | string;
realAction?: string;
}>({
action: PageAction.CREATE,
title: '',
open: false,
width: undefined,
currentData: null
currentData: null,
realAction: undefined
});
const openModal = (
action: PageActionType,
title: string,
currentData?: ListItem | null,
width?: number | string
width?: number | string,
realAction?: string
) => {
setOpenModalStatus({
action,
title,
open: true,
currentData,
width
width,
realAction
});
saveScrollHeight();
};
@@ -49,14 +52,11 @@ const useCreateInstance = () => {
};
const openEditInstanceModal = (row: ListItem) => {
// A stopped instance can be re-typed, so it needs the two-column layout
// (instance-type list + form); other statuses edit in a single column.
const isStopped = row.status?.phase === InstanceStatusValueMap.Stopped;
openModal(
PageAction.EDIT,
intl.formatMessage({ id: 'gpuservice.instance.edit' }),
row,
isStopped ? 'min(1040px, calc(100vw - 220px))' : 600
600
);
};
@@ -69,12 +69,23 @@ const useCreateInstance = () => {
);
};
const openRecreateInstanceModal = (row: ListItem) => {
openModal(
PageAction.EDIT,
intl.formatMessage({ id: 'common.button.recreate' }),
row,
'calc(100vw - 220px)',
PageAction.CREATE
);
};
const closeModal = () => {
setOpenModalStatus({
...openModalStatus,
title: '',
open: false,
currentData: null
currentData: null,
realAction: undefined
});
restoreScrollHeight();
};
@@ -86,6 +97,7 @@ const useCreateInstance = () => {
openCreateInstanceModal,
openEditInstanceModal,
openViewInstanceModal,
openRecreateInstanceModal,
closeInstanceModal: closeModal
};
};
+11 -4
View File
@@ -86,6 +86,7 @@ const GPUService: React.FC = () => {
openCreateInstanceModal,
openEditInstanceModal,
openViewInstanceModal,
openRecreateInstanceModal,
closeInstanceModal
} = useCreateInstance();
const { openViewLogsModal, closeViewLogsModal, openViewLogsModalStatus } =
@@ -135,7 +136,13 @@ const GPUService: React.FC = () => {
const handleModalOk = async (data: FormData) => {
try {
if (openInstanceModalStatus.action === PageAction.EDIT) {
if (openInstanceModalStatus.realAction === PageAction.CREATE) {
await deleteGPUServiceInstance(openInstanceModalStatus.currentData!.id);
await new Promise((resolve) => {
setTimeout(resolve, 300);
});
await createInstance({ data });
} else if (openInstanceModalStatus.action === PageAction.EDIT) {
await updateInstance({
id: openInstanceModalStatus.currentData!.id,
data
@@ -236,6 +243,8 @@ const GPUService: React.FC = () => {
openEditInstanceModal(row);
} else if (val === 'delete') {
handleDelete({ ...row });
} else if (val === 'recreate') {
openRecreateInstanceModal(row);
} else if (val === 'viewlog') {
openViewLogsModal(row);
} else if (val === 'viewevent') {
@@ -262,7 +271,6 @@ const GPUService: React.FC = () => {
if (!clusterLoading && !hasK8sCluster) {
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading || clusterLoading}
loadend={dataSource.loadend}
dataSource={[]}
@@ -286,7 +294,6 @@ const GPUService: React.FC = () => {
}
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
@@ -353,7 +360,6 @@ const GPUService: React.FC = () => {
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
@@ -382,6 +388,7 @@ const GPUService: React.FC = () => {
title={openInstanceModalStatus.title}
data={openInstanceModalStatus.currentData}
width={openInstanceModalStatus.width}
realAction={openInstanceModalStatus.realAction}
clusterList={clusterList}
onCancel={closeInstanceModal}
onOk={handleModalOk}
@@ -2,7 +2,7 @@ import { useQueryData } from '@gpustack/core-ui';
import React from 'react';
import { ceilMilliToCore, parseQuantityToGi } from '../../utils';
import { queryGPUServiceInstanceTypes } from '../apis';
import { getAcceleratorMax, isSliceableDetail } from '../config';
import { getAcceleratorMax } from '../config';
import { InstanceTypeItem } from '../config/types';
type InstanceType = InstanceTypeItem & {
@@ -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 (isSliceableDetail(item.status?.detail?.slicedDetail)) {
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
@@ -77,11 +63,17 @@ export default function useQueryInstanceTypes() {
},
status: {
...item.status,
// Normalize cpu (possibly millicores) to a whole-core count string;
// the other onceMaxRequest fields are plain number strings already.
onceMaxRequest: {
...rawMax,
cpu: rawMax?.cpu ? `${ceilMilliToCore(rawMax.cpu)?.cores || 0}` : ''
cpu: rawMax?.cpu
? `${ceilMilliToCore(rawMax.cpu)?.cores || 0}`
: '',
ram: rawMax?.ram
? `${parseQuantityToGi(rawMax.ram)?.value || 0}`
: '',
localStorage: rawMax?.localStorage
? `${parseQuantityToGi(rawMax.localStorage)?.value || 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;
}
@@ -1,35 +0,0 @@
import _ from 'lodash';
import { isSliceableDetail } from '../config';
import { InstanceTypeItem, InstanceTypeSnapshotSpec } from '../config/types';
// Build the flat snapshot spec from a live (API-shaped) instance type:
// definition fields from spec, observed hardware from status.detail, plus the
// derived `sliceable`. This flat shape is the UI document format persisted in
// the instance's `description` (older instances already carry it flat) and
// doubles as the display model of the type card / metadata section.
export const buildInstanceTypeSnapshotSpec = (
instanceType: InstanceTypeItem
): InstanceTypeSnapshotSpec => {
const detail = instanceType.status?.detail;
return {
...instanceType.spec,
..._.pick(detail, ['manufacturer', 'product', 'family', 'memory']),
sliceable: isSliceableDetail(detail?.slicedDetail),
// Accelerator CPU identity only — the full CPU descriptor is too bulky to
// persist and the UI only shows who made it.
cpu: _.pick(detail?.cpu, ['manufacturer', 'product', 'family'])
};
};
// Serialize the chosen instance type into the instance's `description` field —
// a persisted spec snapshot the form reads back to render the type card and
// derive unit resources. Shared by the create flow (card selection) and the
// edit flow (change-type overlay).
export const saveInstanceDataInDescription = (
instanceType: InstanceTypeItem
): string => {
return JSON.stringify({
name: instanceType.name,
spec: buildInstanceTypeSnapshotSpec(instanceType)
});
};

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