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
140 changed files with 1007 additions and 3218 deletions
+1 -12
View File
@@ -124,24 +124,13 @@ Always check `@gpustack/core-ui` first. Frequently reused:
- **Form fields**: `BaseSelect`, `Input` (labeled). - **Form fields**: `BaseSelect`, `Input` (labeled).
- **Text overflow**: `AutoTooltip`. - **Text overflow**: `AutoTooltip`.
- **Icons**: `IconFont`. - **Icons**: `IconFont`.
- **Tags & status** (4 variants): see the section below. - **Status display** (success/failed/processing/warning): `StatusTag`.
- **Permission-gated visibility**: `Access` / `useAccess`. - **Permission-gated visibility**: `Access` / `useAccess`.
- **Request hooks**: `useRequest` / `useQueryData` / `useQueryDataList`. - **Request hooks**: `useRequest` / `useQueryData` / `useQueryDataList`.
- **Table data fetching**: `useTableFetch`. - **Table data fetching**: `useTableFetch`.
- **Submit guard** (prevent double-submit): `useSubmitLock`. - **Submit guard** (prevent double-submit): `useSubmitLock`.
- **Tabbed forms**: `ScrollSpyTabs`. - **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 # 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: 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 -10
View File
@@ -100,15 +100,6 @@ const baseRoutes = [
path: '/models', path: '/models',
redirect: '/models/deployments' 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', name: 'modelCatalog',
path: '/models/catalog', path: '/models/catalog',
@@ -119,6 +110,16 @@ const baseRoutes = [
access: 'canSeeOrgAdmin', access: 'canSeeOrgAdmin',
component: './llmodels/catalog' 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', name: 'deployment',
path: '/models/deployments', path: '/models/deployments',
@@ -273,7 +274,7 @@ const baseRoutes = [
selectedIcon: 'icon-cluster2-filled', selectedIcon: 'icon-cluster2-filled',
defaultIcon: 'icon-cluster2-outline', defaultIcon: 'icon-cluster2-outline',
component: './cluster-management/clusters', component: './cluster-management/clusters',
subMenu: ['/resources/clusters/create'] subMenu: ['/resources/clusters/detail', '/resources/clusters/create']
}, },
{ {
name: 'workers', name: 'workers',
@@ -301,6 +302,16 @@ const baseRoutes = [
selectedIcon: 'icon-credential-filled', selectedIcon: 'icon-credential-filled',
defaultIcon: 'icon-credential-outline', defaultIcon: 'icon-credential-outline',
component: './cluster-management/credentials' 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", "@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51", "@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1", "@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.42", "@gpustack/core-ui": "^1.0.35",
"@huggingface/gguf": "^0.1.7", "@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1", "@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6", "@huggingface/tasks": "^0.11.6",
+5 -5
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1 specifier: ^7.1.1
version: 7.1.2 version: 7.1.2
'@gpustack/core-ui': '@gpustack/core-ui':
specifier: ^1.0.42 specifier: ^1.0.35
version: 1.0.42(czdvzceysqw7iv6pct2ucnb23e) version: 1.0.35(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf': '@huggingface/gguf':
specifier: ^0.1.7 specifier: ^0.1.7
version: 0.1.18 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} 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 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': '@gpustack/core-ui@1.0.35':
resolution: {integrity: sha512-upMClTHU+xAqd8dlx0w1S9XWHlog5g1hcOCulTk2rmMXqgl66QHfqhEFhJnWIGe1xc8tEj6rW3r5Sirif+qswA==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.42.tgz} resolution: {integrity: sha512-MaEmCM3FikeKZdUgDSyUtIfXtNMbNOLeFiTynl6DcKEraUOpk6tgQXPDginBY3uXuisG4hzoIQbpHHkzaYiQew==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.35.tgz}
peerDependencies: peerDependencies:
'@ant-design/icons': ^6.1.0 '@ant-design/icons': ^6.1.0
'@ant-design/pro-components': 3.1.0-0 '@ant-design/pro-components': 3.1.0-0
@@ -10802,7 +10802,7 @@ snapshots:
'@formatjs/intl-utils@2.3.0': {} '@formatjs/intl-utils@2.3.0': {}
'@gpustack/core-ui@1.0.42(czdvzceysqw7iv6pct2ucnb23e)': '@gpustack/core-ui@1.0.35(czdvzceysqw7iv6pct2ucnb23e)':
dependencies: dependencies:
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@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) '@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 './table.less';
@import './alert.less';
.m-b-20 { .m-b-20 {
margin-bottom: 20px; margin-bottom: 20px;
-31
View File
@@ -9,15 +9,6 @@
} }
} }
@keyframes tableEmptyFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.scroll-table { .scroll-table {
.ant-table { .ant-table {
.ant-table-container { .ant-table-container {
@@ -27,27 +18,5 @@
scrollbar-color: var(--color-scrollbar-thumb) transparent; 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 { GPUStackVersionAtom } from '@/atoms/user';
import { getAtomStorage } from '@/atoms/utils';
import VersionInfo, { modalConfig } from '@/components/version-info'; import VersionInfo, { modalConfig } from '@/components/version-info';
import externalLinks from '@/constants/external-links'; import externalLinks from '@/constants/external-links';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button, Divider, Modal, Typography } from 'antd'; import { Button, Divider, Modal, Typography } from 'antd';
import { createStyles } from 'antd-style'; import { createStyles } from 'antd-style';
import { useAtomValue } from 'jotai';
import styled from 'styled-components'; import styled from 'styled-components';
const CompanyWrapper = styled.div` const CompanyWrapper = styled.div`
@@ -35,7 +35,6 @@ const Footer: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const [modal, contextHolder] = Modal.useModal(); const [modal, contextHolder] = Modal.useModal();
const { styles } = useStyles(); const { styles } = useStyles();
const version = useAtomValue(GPUStackVersionAtom);
const showVersion = () => { const showVersion = () => {
modal.info({ modal.info({
@@ -74,7 +73,7 @@ const Footer: React.FC = () => {
</Button> </Button>
<Divider orientation="vertical" /> <Divider orientation="vertical" />
<Button type="link" size="small" onClick={showVersion}> <Button type="link" size="small" onClick={showVersion}>
{version?.version} {getAtomStorage(GPUStackVersionAtom)?.version}
</Button> </Button>
</div> </div>
</div> </div>
+2 -3
View File
@@ -1,7 +1,6 @@
import Logo from '@/assets/images/gpustack-logo.png'; import Logo from '@/assets/images/gpustack-logo.png';
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user'; import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
import externalLinks from '@/constants/external-links'; import externalLinks from '@/constants/external-links';
import { useLogo } from '@/hooks/use-logo';
import { Button } from 'antd'; import { Button } from 'antd';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import React from 'react'; import React from 'react';
@@ -19,7 +18,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
isProd, isProd,
isDev isDev
} = gpuStackVersionAtom; } = gpuStackVersionAtom;
const { sidebarLogo } = useLogo();
// user info // user info
const { is_admin } = userDataAtom || {}; const { is_admin } = userDataAtom || {};
@@ -31,7 +30,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
return ( return (
<div className="version-box"> <div className="version-box">
<div className="img"> <div className="img">
<img src={sidebarLogo || Logo} alt="logo" /> <img src={Logo} alt="logo" />
</div> </div>
<div className="ver"> <div className="ver">
-4
View File
@@ -43,10 +43,6 @@ export default {
DatePicker: { DatePicker: {
fontSizeLG: 14 fontSizeLG: 14
}, },
Alert: {
withDescriptionPadding: '12px 16px',
withDescriptionIconSize: 18
},
Menu: { Menu: {
iconSize: 16, iconSize: 16,
iconMarginInlineEnd: 12, iconMarginInlineEnd: 12,
+3 -10
View File
@@ -26,8 +26,8 @@ export default {
cellPaddingInline: 16, cellPaddingInline: 16,
cellPaddingBlock: 6, cellPaddingBlock: 6,
cellFontSize: 14, cellFontSize: 14,
rowSelectedHoverBg: 'rgb(247 247 247)', rowSelectedHoverBg: 'rgb(249 249 249)',
rowHoverBg: 'rgb(247 247 247)', rowHoverBg: 'rgb(249 249 249)',
rowSelectedBg: 'transparent', rowSelectedBg: 'transparent',
headerSortActiveBg: 'transparent', headerSortActiveBg: 'transparent',
headerSortHoverBg: 'transparent', headerSortHoverBg: 'transparent',
@@ -47,13 +47,6 @@ export default {
DatePicker: { DatePicker: {
fontSizeLG: 14 fontSizeLG: 14
}, },
Alert: {
withDescriptionPadding: '12px 16px',
withDescriptionIconSize: 18
},
Card: {
headerHeight: 50
},
Menu: { Menu: {
iconSize: 16, iconSize: 16,
iconMarginInlineEnd: 12, iconMarginInlineEnd: 12,
@@ -123,7 +116,7 @@ export default {
borderRadiusLG: 6, borderRadiusLG: 6,
borderRadius: 4, borderRadius: 4,
borderRadiusSM: 3, borderRadiusSM: 3,
colorBgContainer: '#fdfdfd', colorBgContainer: '#fff',
fontSize: 14, fontSize: 14,
motion: true motion: true
} }
+15 -2
View File
@@ -21,6 +21,8 @@ html {
--color-fill-sider: #f4f5f4; --color-fill-sider: #f4f5f4;
--color-bg-1: #f4f5f4; --color-bg-1: #f4f5f4;
--color-scroll-bg: #d9d9d9; --color-scroll-bg: #d9d9d9;
--color-fill-2: #fff;
--color-fill-3: #f3f6fa;
--color-logs-bg: #1e1e1e; --color-logs-bg: #1e1e1e;
--color-logs-text: #d4d4d4; --color-logs-text: #d4d4d4;
--layout-content-blockpadding: 24px; --layout-content-blockpadding: 24px;
@@ -74,6 +76,7 @@ html {
--ant-rate-star-color: #fadb14; --ant-rate-star-color: #fadb14;
--color-fill-spin-bg: rgba(255, 255, 255, 15%); --color-fill-spin-bg: rgba(255, 255, 255, 15%);
--width-tooltip-max: 420px; --width-tooltip-max: 420px;
--color-bg-tooltip: '#fff';
--color-modal-content-bg: rgba(255, 255, 255, 90%); --color-modal-content-bg: rgba(255, 255, 255, 90%);
--color-modal-box-shadow: 0 4px 16px rgba(0, 0, 0, 10%); --color-modal-box-shadow: 0 4px 16px rgba(0, 0, 0, 10%);
--color-spotlight-bg: rgba(255, 255, 255, 100%); --color-spotlight-bg: rgba(255, 255, 255, 100%);
@@ -88,7 +91,6 @@ html {
// ======== container ============ // ======== container ============
--color-border-container: #ededed; --color-border-container: #ededed;
--color-text-table-header: #71717a; --color-text-table-header: #71717a;
--seal-table-row-min-height: 68px;
} }
html[data-theme='realDark'] { html[data-theme='realDark'] {
@@ -98,6 +100,7 @@ html[data-theme='realDark'] {
--color-editor-dark: #00101f; --color-editor-dark: #00101f;
--color-editor-light: #fafafa; --color-editor-light: #fafafa;
--color-fill-spin-bg: rgba(55, 55, 55, 50%); --color-fill-spin-bg: rgba(55, 55, 55, 50%);
--color-bg-tooltip: #424242;
--color-editor-header-bg: #292929; --color-editor-header-bg: #292929;
--color-progress-text: rgba(255, 255, 255, 80%); --color-progress-text: rgba(255, 255, 255, 80%);
--color-modal-content-bg: #1f1f1f; --color-modal-content-bg: #1f1f1f;
@@ -112,9 +115,19 @@ html[data-theme='realDark'] {
.ant-result-image { .ant-result-image {
opacity: 0.9; opacity: 0.9;
} }
.ant-pro-page-container-affix .ant-pro-page-container-warp {
background-color: #292929 !important;
transition: none !important;
}
} }
html[data-theme='light'] { html[data-theme='light'] {
.ant-pro-page-container-affix .ant-pro-page-container-warp {
background-color: #fff !important;
transition: none !important;
}
background-color: #f4f5f6; background-color: #f4f5f6;
} }
@@ -197,7 +210,7 @@ body {
tr > td { tr > td {
border-bottom: none; border-bottom: none;
height: var(--seal-table-row-min-height); height: 68px;
} }
} }
+14 -28
View File
@@ -8,38 +8,24 @@ const findValidJSONStrings = (inputStr: string) => {
const openingBraceIndex = inputStr.indexOf('{', startIndex); const openingBraceIndex = inputStr.indexOf('{', startIndex);
if (openingBraceIndex === -1) break; // No more opening braces if (openingBraceIndex === -1) break; // No more opening braces
// find the matching closing brace, ignoring braces inside string let closingBraceIndex = openingBraceIndex;
// literals (e.g. a state_message containing `{`/`}`)
let closingBraceIndex = -1;
let braceCount = 0; let braceCount = 0;
let inString = false;
let escaped = false;
for (let i = openingBraceIndex; i < inputStr.length; i++) { // find couple of braces
const char = inputStr[i]; while (closingBraceIndex < inputStr.length) {
if (inString) { if (inputStr[closingBraceIndex] === '{') {
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = false;
}
} else if (char === '"') {
inString = true;
} else if (char === '{') {
braceCount++; braceCount++;
} else if (char === '}') { } else if (inputStr[closingBraceIndex] === '}') {
braceCount--; braceCount--;
if (braceCount === 0) {
closingBraceIndex = i;
break;
}
} }
if (braceCount === 0) {
break;
}
closingBraceIndex++;
} }
if (closingBraceIndex === -1) { if (braceCount !== 0) {
// no matching closing brace yet, wait for more data // no matching closing brace
break; break;
} }
@@ -51,11 +37,11 @@ const findValidJSONStrings = (inputStr: string) => {
try { try {
const parsedData = JSON.parse(jsonString); const parsedData = JSON.parse(jsonString);
validJSONStrings.push(parsedData); validJSONStrings.push(parsedData);
startIndex = closingBraceIndex + 1;
} catch (error) { } catch (error) {
// skip the malformed segment instead of breaking, otherwise it jams // mabye invalid JSON
// the buffer and every later event on this stream is lost break;
} }
startIndex = closingBraceIndex + 1;
} }
return { return {
+1 -4
View File
@@ -43,11 +43,8 @@ export const createAxiosToken = (): CancelTokenSource => {
}; };
export const sliceData = (data: string, loaded: number, loadedSize: any) => { export const sliceData = (data: string, loaded: number, loadedSize: any) => {
// `loaded` is a byte count while `data` is a UTF-16 string; with any
// non-ASCII payload the two drift apart, so track consumed characters by
// string length only
const result = data.slice(loadedSize.current); const result = data.slice(loadedSize.current);
loadedSize.current = data.length; loadedSize.current = loaded;
return result; return result;
}; };
+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 ( const queryAllDataList = async (
params: Global.SearchParams, params: Global.SearchParams,
options?: any options?: any
@@ -83,15 +78,14 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
useEffect(() => { useEffect(() => {
createWatchChunkRequest(); createWatchChunkRequest();
return () => { return () => {
cancelWatch(); chunkRequestRef.current?.cancel?.();
listRequestTokenRef.current?.cancel?.();
}; };
}, []); }, []);
return { return {
watchDataList, watchDataList,
setWatchDataList, setWatchDataList,
startWatch: createWatchChunkRequest,
cancelWatch,
deleteItemFromCache: handleDeleteItemFromCache deleteItemFromCache: handleDeleteItemFromCache
}; };
} }
+9 -13
View File
@@ -4,7 +4,6 @@ import VersionInfo, { modalConfig } from '@/components/version-info';
import externalLinks from '@/constants/external-links'; import externalLinks from '@/constants/external-links';
import useBodyScroll from '@/hooks/use-body-scroll'; import useBodyScroll from '@/hooks/use-body-scroll';
import { logout } from '@/pages/login/apis'; import { logout } from '@/pages/login/apis';
import { getGPUStackPlugin } from '@/plugins';
import { useModel } from '@@/plugin-model'; import { useModel } from '@@/plugin-model';
import { import {
DiscordOutlined, DiscordOutlined,
@@ -99,7 +98,6 @@ const CustomItem = styled.div`
export const ExtraContent = (props: { isDarkTheme?: boolean }) => { export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
const { isDarkTheme } = props; const { isDarkTheme } = props;
const plugin = getGPUStackPlugin();
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll(); const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [modal, contextHolder] = Modal.useModal(); const [modal, contextHolder] = Modal.useModal();
const [version] = useAtom(GPUStackVersionAtom); const [version] = useAtom(GPUStackVersionAtom);
@@ -288,17 +286,15 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
</NewLabel> </NewLabel>
)} )}
</div> </div>
{!plugin && ( <DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}>
<DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}> <IconWrapper>
<IconWrapper> <IconFont
<IconFont type="icon-help"
type="icon-help" className="font-size-20"
className="font-size-20" style={{ color: 'var(--ant-color-text-tertiary)' }}
style={{ color: 'var(--ant-color-text-tertiary)' }} />
/> </IconWrapper>
</IconWrapper> </DropdownActions>
</DropdownActions>
)}
<PluginExtraField name="GlobalSettings" /> <PluginExtraField name="GlobalSettings" />
<DropdownActions menu={{ ...userMenu }} popupRender={userPopupRender}> <DropdownActions menu={{ ...userMenu }} popupRender={userPopupRender}>
<IconWrapper> <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.', 'On the Worker that needs to be added, run the following command to join it to the cluster.',
'clusters.create.addCommand.k8s.tips': '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.', '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': 'clusters.create.register.tips':
'On the Kubernetes cluster that needs to be added, run the following command to join its nodes to the cluster.', 'On the Kubernetes cluster that needs to be added, run the following command to join its nodes to the cluster.',
'cluster.create.checkEnv.tips': 'cluster.create.checkEnv.tips':
-1
View File
@@ -2,7 +2,6 @@ export default {
'gpuservice.template': 'GPU Instance Template', 'gpuservice.template': 'GPU Instance Template',
'gpuservice.template.add': 'Add Instance Template', 'gpuservice.template.add': 'Add Instance Template',
'gpuservice.template.edit': 'Edit Instance Template', 'gpuservice.template.edit': 'Edit Instance Template',
'gpuservice.template.clone': 'Clone Instance Template',
'gpuservice.template.filter.name': 'Filter by name', 'gpuservice.template.filter.name': 'Filter by name',
'gpuservice.template.filter.vendor': 'Filter by vendor', 'gpuservice.template.filter.vendor': 'Filter by vendor',
'gpuservice.template.image': 'Image', 'gpuservice.template.image': 'Image',
+1 -1
View File
@@ -13,7 +13,7 @@ export default {
'menu.models.modelCatalog': 'Catalog', 'menu.models.modelCatalog': 'Catalog',
'menu.models.catalog': 'Model Catalog', 'menu.models.catalog': 'Model Catalog',
'menu.models.deployment': 'Deployments', 'menu.models.deployment': 'Deployments',
'menu.models.userModels': 'Models', 'menu.models.userModels': 'My Models',
'menu.models.benchmark': 'Benchmarks', 'menu.models.benchmark': 'Benchmarks',
'menu.models.benchmarkDetail': 'Benchmark Details', 'menu.models.benchmarkDetail': 'Benchmark Details',
'menu.models.providers': 'Providers', 'menu.models.providers': 'Providers',
-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.', 'On the Worker that needs to be added, run the following command to join it to the cluster.',
'clusters.create.addCommand.k8s.tips': 'clusters.create.addCommand.k8s.tips':
'登録する Kubernetes クラスターで以下のコマンドを実行し、Kubernetes リソースを作成してクラスターを登録します。', '登録する Kubernetes クラスターで以下のコマンドを実行し、Kubernetes リソースを作成してクラスターを登録します。',
'clusters.create.addCommand.k8s.version.warning':
'サポートされる Kubernetes の最小バージョンは 1.23 です。GPU Service 機能を使用する場合、サポートされる Kubernetes の最小バージョンは 1.27 です。',
'cluster.create.checkEnv.tips': 'cluster.create.checkEnv.tips':
'Use the following command to check if the environment is ready.', 'Use the following command to check if the environment is ready.',
'clusters.create.register.tips': 'clusters.create.register.tips':
-1
View File
@@ -2,7 +2,6 @@ export default {
'gpuservice.template': 'GPU インスタンステンプレート', 'gpuservice.template': 'GPU インスタンステンプレート',
'gpuservice.template.add': 'インスタンステンプレートを追加', 'gpuservice.template.add': 'インスタンステンプレートを追加',
'gpuservice.template.edit': 'インスタンステンプレートを編集', 'gpuservice.template.edit': 'インスタンステンプレートを編集',
'gpuservice.template.clone': 'インスタンステンプレートを複製',
'gpuservice.template.filter.name': '名前でフィルター', 'gpuservice.template.filter.name': '名前でフィルター',
'gpuservice.template.filter.vendor': 'ベンダーでフィルター', 'gpuservice.template.filter.vendor': 'ベンダーでフィルター',
'gpuservice.template.image': 'コンテナイメージ', 'gpuservice.template.image': 'コンテナイメージ',
+6 -6
View File
@@ -41,7 +41,7 @@ export default {
'menu.accessControl.organizations': 'Organizations', 'menu.accessControl.organizations': 'Organizations',
'menu.resources.clusters': 'Clusters', 'menu.resources.clusters': 'Clusters',
'menu.resources.credentials': 'Cloud Credentials', 'menu.resources.credentials': 'Cloud Credentials',
'menu.models.userModels': 'Models', 'menu.models.userModels': 'My Models',
'menu.resources.clusterDetail': 'Cluster Detail', 'menu.resources.clusterDetail': 'Cluster Detail',
'menu.resources.clusterCreate': 'Create Cluster', 'menu.resources.clusterCreate': 'Create Cluster',
'menu.models.backendsList': 'Inference Backends', 'menu.models.backendsList': 'Inference Backends',
@@ -62,11 +62,11 @@ export default {
// 6. 'menu.accessControl.apikeys': 'API Keys', // 6. 'menu.accessControl.apikeys': 'API Keys',
// 7. 'menu.accessControl.users': 'Users', // 7. 'menu.accessControl.users': 'Users',
// 8. 'menu.clusterManagement': 'Cluster Management', // 8. 'menu.clusterManagement': 'Cluster Management',
// 9. 'menu.resources.clusters': 'Clusters', // 9. 'menu.clusterManagement.clusters': 'Clusters',
// 10. 'menu.resources.credentials': 'Cloud Credentials', // 10. 'menu.clusterManagement.credentials': 'Cloud Credentials',
// 11. 'menu.models.userModels': 'Models' // 11. 'menu.models.userModels': 'My Models'
// 12. 'menu.resources.clusterDetail': 'Cluster Detail', // 12. 'menu.clusterManagement.clusterDetail': 'Cluster Detail',
// 13. 'menu.resources.clusterCreate': 'Create Cluster', // 13. 'menu.clusterManagement.clusterCreate': 'Create Cluster',
// 14. 'menu.models.backendsList': 'Inference Backends', // 14. 'menu.models.backendsList': 'Inference Backends',
// 15. 'menu.models.benchmark': 'Benchmarks', // 15. 'menu.models.benchmark': 'Benchmarks',
// 15. 'menu.models.provider': 'Provider', // 15. 'menu.models.provider': 'Provider',
-2
View File
@@ -44,8 +44,6 @@ export default {
'На воркере, который необходимо добавить, выполните следующую команду, чтобы присоединить его к кластеру.', 'На воркере, который необходимо добавить, выполните следующую команду, чтобы присоединить его к кластеру.',
'clusters.create.addCommand.k8s.tips': 'clusters.create.addCommand.k8s.tips':
'На Kubernetes-кластере, который необходимо зарегистрировать, выполните следующую команду, чтобы создать ресурсы Kubernetes и зарегистрировать этот кластер.', 'На Kubernetes-кластере, который необходимо зарегистрировать, выполните следующую команду, чтобы создать ресурсы Kubernetes и зарегистрировать этот кластер.',
'clusters.create.addCommand.k8s.version.warning':
'Минимальная поддерживаемая версия Kubernetes — 1.23. Для использования функции GPU Service минимальная поддерживаемая версия Kubernetes — 1.27.',
'cluster.create.checkEnv.tips': 'cluster.create.checkEnv.tips':
'Используйте следующую команду для проверки готовности окружения', 'Используйте следующую команду для проверки готовности окружения',
'clusters.create.register.tips': 'clusters.create.register.tips':
-1
View File
@@ -2,7 +2,6 @@ export default {
'gpuservice.template': 'Шаблон экземпляра GPU', 'gpuservice.template': 'Шаблон экземпляра GPU',
'gpuservice.template.add': 'Добавить шаблон экземпляра', 'gpuservice.template.add': 'Добавить шаблон экземпляра',
'gpuservice.template.edit': 'Редактировать шаблон экземпляра', 'gpuservice.template.edit': 'Редактировать шаблон экземпляра',
'gpuservice.template.clone': 'Клонировать шаблон экземпляра',
'gpuservice.template.filter.name': 'Фильтр по имени', 'gpuservice.template.filter.name': 'Фильтр по имени',
'gpuservice.template.filter.vendor': 'Фильтр по производителю', 'gpuservice.template.filter.vendor': 'Фильтр по производителю',
'gpuservice.template.image': 'Образ', 'gpuservice.template.image': 'Образ',
+1 -1
View File
@@ -40,7 +40,7 @@ export default {
'menu.accessControl.organizations': 'Организации', 'menu.accessControl.organizations': 'Организации',
'menu.resources.clusters': 'Кластеры', 'menu.resources.clusters': 'Кластеры',
'menu.resources.credentials': 'Облачные аккаунты', 'menu.resources.credentials': 'Облачные аккаунты',
'menu.models.userModels': 'Модели', 'menu.models.userModels': 'Мои модели',
'menu.resources.clusterDetail': 'Детали кластера', 'menu.resources.clusterDetail': 'Детали кластера',
'menu.resources.clusterCreate': 'Создать кластер', 'menu.resources.clusterCreate': 'Создать кластер',
'menu.models.backendsList': 'Бэкенды запуска', 'menu.models.backendsList': 'Бэкенды запуска',
-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.', 'Eklenmesi gereken İşçi Düğümde, kümeye katılması için aşağıdaki komutu çalıştırın.',
'clusters.create.addCommand.k8s.tips': '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.', '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': '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.', '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': 'cluster.create.checkEnv.tips':
-1
View File
@@ -2,7 +2,6 @@ export default {
'gpuservice.template': 'GPU Örnek Şablonu', 'gpuservice.template': 'GPU Örnek Şablonu',
'gpuservice.template.add': 'Örnek Şablonu Ekle', 'gpuservice.template.add': 'Örnek Şablonu Ekle',
'gpuservice.template.edit': 'Örnek Şablonunu Düzenle', 'gpuservice.template.edit': 'Örnek Şablonunu Düzenle',
'gpuservice.template.clone': 'Örnek Şablonunu Klonla',
'gpuservice.template.filter.name': 'Ada göre filtrele', 'gpuservice.template.filter.name': 'Ada göre filtrele',
'gpuservice.template.filter.vendor': 'Tedarikçiye göre filtrele', 'gpuservice.template.filter.vendor': 'Tedarikçiye göre filtrele',
'gpuservice.template.image': 'İmaj', 'gpuservice.template.image': 'İmaj',
+2 -2
View File
@@ -13,12 +13,12 @@ export default {
'menu.models.modelCatalog': 'Katalog', 'menu.models.modelCatalog': 'Katalog',
'menu.models.catalog': 'Model Kataloğu', 'menu.models.catalog': 'Model Kataloğu',
'menu.models.deployment': 'Dağıtımlar', 'menu.models.deployment': 'Dağıtımlar',
'menu.models.userModels': 'Modeller', 'menu.models.userModels': 'Modellerim',
'menu.models.benchmark': 'Kıyaslamalar', 'menu.models.benchmark': 'Kıyaslamalar',
'menu.models.benchmarkDetail': 'Kıyaslama Detayları', 'menu.models.benchmarkDetail': 'Kıyaslama Detayları',
'menu.models.providers': 'Sağlayıcılar', 'menu.models.providers': 'Sağlayıcılar',
'menu.models.routes': 'Yönlendirmeler', 'menu.models.routes': 'Yönlendirmeler',
'menu.models.usage': 'Kullanım', 'menu.models.usage': 'Usage',
'menu.modelCatalog': 'Katalog', 'menu.modelCatalog': 'Katalog',
'menu.resources': 'Kaynaklar', 'menu.resources': 'Kaynaklar',
'menu.apikeys': 'API Anahtarları', 'menu.apikeys': 'API Anahtarları',
-2
View File
@@ -43,8 +43,6 @@ export default {
'在需要添加的节点上运行以下命令,将其加入到集群中。', '在需要添加的节点上运行以下命令,将其加入到集群中。',
'clusters.create.addCommand.k8s.tips': 'clusters.create.addCommand.k8s.tips':
'在需要注册的 Kubernetes 集群中运行以下命令,创建 Kubernetes 资源,注册该集群。', '在需要注册的 Kubernetes 集群中运行以下命令,创建 Kubernetes 资源,注册该集群。',
'clusters.create.addCommand.k8s.version.warning':
'Kubernetes 版本最低支持 1.23,如果要使用 GPU Service 功能,Kubernetes 版本最低支持 1.27。',
'clusters.create.register.tips': 'clusters.create.register.tips':
'在需要添加的 Kubernetes 集群上运行以下命令,将其中的节点加入到集群中。', '在需要添加的 Kubernetes 集群上运行以下命令,将其中的节点加入到集群中。',
'cluster.create.checkEnv.tips': '使用以下命令检查环境是否准备妥当。', 'cluster.create.checkEnv.tips': '使用以下命令检查环境是否准备妥当。',
-1
View File
@@ -2,7 +2,6 @@ export default {
'gpuservice.template': 'GPU 实例模板', 'gpuservice.template': 'GPU 实例模板',
'gpuservice.template.add': '添加实例模板', 'gpuservice.template.add': '添加实例模板',
'gpuservice.template.edit': '编辑实例模板', 'gpuservice.template.edit': '编辑实例模板',
'gpuservice.template.clone': '克隆实例模板',
'gpuservice.template.filter.name': '按名称过滤', 'gpuservice.template.filter.name': '按名称过滤',
'gpuservice.template.filter.vendor': '按厂商过滤', 'gpuservice.template.filter.vendor': '按厂商过滤',
'gpuservice.template.image': '镜像', 'gpuservice.template.image': '镜像',
+1 -1
View File
@@ -12,7 +12,7 @@ export default {
'menu.models.modelList': '部署与管理', 'menu.models.modelList': '部署与管理',
'menu.models.modelCatalog': '模型库', 'menu.models.modelCatalog': '模型库',
'menu.models.deployment': '部署', 'menu.models.deployment': '部署',
'menu.models.userModels': '模型广场', 'menu.models.userModels': '我的模型',
'menu.models.benchmark': '基准测试', 'menu.models.benchmark': '基准测试',
'menu.models.benchmarkDetail': '基准测试详情', 'menu.models.benchmarkDetail': '基准测试详情',
'menu.models.providers': '提供商', 'menu.models.providers': '提供商',
@@ -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, opacity: 1,
paddingInline: 8, paddingInline: 8,
borderRadius: 12, borderRadius: 12,
transform: 'scale(0.9)', transform: 'scale(0.9)'
backgroundColor: 'transparent'
}} }}
color={config.color} color={config.color}
> >
+29 -9
View File
@@ -6,12 +6,10 @@ import {
AutoTooltip, AutoTooltip,
DropdownButtons, DropdownButtons,
IconFont, IconFont,
icons, icons
TextAttribute,
ThemeTag
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { MenuProps, Tooltip } from 'antd'; import { MenuProps, Tag, Tooltip } from 'antd';
import { ColumnsType } from 'antd/lib/table'; import { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -141,14 +139,23 @@ const useModelsColumns = ({
key: 'name', key: 'name',
sorter: tableSorter(1), sorter: tableSorter(1),
render: (text: string, record: ListItem) => ( render: (text: string, record: ListItem) => (
<span className="flex items-center gap-8"> <span className="flex items-center">
<AutoTooltip ghost style={{ maxWidth: 400 }} title={text}> <AutoTooltip ghost style={{ maxWidth: 400 }} title={text}>
<span className="text-primary">{text}</span> <span className="text-primary">{text}</span>
</AutoTooltip> </AutoTooltip>
{record.is_custom && ( {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' })} {intl.formatMessage({ id: 'playground.params.custom' })}
</TextAttribute> </Tag>
)} )}
</span> </span>
) )
@@ -196,13 +203,26 @@ const useModelsColumns = ({
)} )}
{(record.scope?.includes('inference') || {(record.scope?.includes('inference') ||
record.scope?.includes('*')) && ( 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> <AutoTooltip ghost>
{record.allowed_model_names?.length {record.allowed_model_names?.length
? record.allowed_model_names.join(', ') ? record.allowed_model_names.join(', ')
: intl.formatMessage({ id: 'apikeys.models.all' })} : intl.formatMessage({ id: 'apikeys.models.all' })}
</AutoTooltip> </AutoTooltip>
</ThemeTag> </div>
)} )}
</div> </div>
) )
+1 -6
View File
@@ -183,15 +183,11 @@ const APIKeys: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
image={<IconFont type="icon-key" />} image={<IconFont type="icon-key" />}
filters={{ filters={_.omit(queryParams, ['sort_by'])}
..._.omit(queryParams, ['sort_by']),
user_id: queryParams.user_id === '*' ? undefined : queryParams.user_id
}}
noFoundText={intl.formatMessage({ noFoundText={intl.formatMessage({
id: 'noresult.keys.nofound' id: 'noresult.keys.nofound'
})} })}
@@ -232,7 +228,6 @@ const APIKeys: React.FC = () => {
></FilterBar> ></FilterBar>
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
rowSelection={rowSelection} rowSelection={rowSelection}
@@ -120,19 +120,13 @@ const CommunityBackends: React.FC<{
const renderItem = (item: ListItem) => { const renderItem = (item: ListItem) => {
return ( return (
<div <BackendCard
style={{ active={currentData?.id === item.id}
backgroundColor: 'var(--ant-color-bg-container)' data={item}
}} layout="community"
> onClick={handleOnClickItem}
<BackendCard actionsRenderer={actionsRenderer}
active={currentData?.id === item.id} />
data={item}
layout="community"
onClick={handleOnClickItem}
actionsRenderer={actionsRenderer}
/>
</div>
); );
}; };
@@ -170,7 +164,6 @@ const CommunityBackends: React.FC<{
renderItem={renderItem} renderItem={renderItem}
></BackendCardList> ></BackendCardList>
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
+1 -1
View File
@@ -45,7 +45,7 @@ const Title = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: var(--ant-color-bg-elevated); background-color: var(--ant-color-bg-container);
font-weight: 600; font-weight: 600;
padding-top: 8px; padding-top: 8px;
padding-bottom: 8px; padding-bottom: 8px;
-1
View File
@@ -282,7 +282,6 @@ const BackendList = () => {
onSelect={handleOnSelect} onSelect={handleOnSelect}
></BackendCardList> ></BackendCardList>
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
-1
View File
@@ -177,7 +177,6 @@ const Benchmark: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={[]} dataSource={[]}
+13 -14
View File
@@ -5,7 +5,6 @@ import type { PageActionType } from '@/config/types';
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys'; import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
import useTableFetch from '@/hooks/use-table-fetch'; import useTableFetch from '@/hooks/use-table-fetch';
import useWatchList from '@/hooks/use-watch-list'; import useWatchList from '@/hooks/use-watch-list';
import { getGPUStackPlugin } from '@/plugins';
import { import {
DeleteModal, DeleteModal,
FilterBar, FilterBar,
@@ -15,7 +14,7 @@ import {
TableOrder, TableOrder,
TableProvider TableProvider
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl, useNavigate } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { message } from 'antd'; import { message } from 'antd';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
@@ -74,7 +73,7 @@ const Clusters: React.FC = () => {
deleteAPI: deleteCluster, deleteAPI: deleteCluster,
watch: true, watch: true,
API: CLUSTERS_API, API: CLUSTERS_API,
contentForDelete: 'menu.resources.clusters', contentForDelete: 'menu.clusterManagement.clusters',
defaultQueryParams: { defaultQueryParams: {
// Management view: drop cross-Org cluster_access grants. Org // Management view: drop cross-Org cluster_access grants. Org
// Owner only sees the clusters they own here. Pickers that // Owner only sees the clusters they own here. Pickers that
@@ -83,13 +82,10 @@ const Clusters: React.FC = () => {
mine: true mine: true
} }
}); });
const navigate = useNavigate();
const { goToGrafana, ActionButton } = useGranfanaLink({ const { goToGrafana, ActionButton } = useGranfanaLink({
type: 'cluster' 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 { watchDataList: allWorkerPoolList } = useWatchList(WORKER_POOLS_API);
const [expandAtom] = useAtom(expandKeysAtom); const [expandAtom] = useAtom(expandKeysAtom);
const [clusterSession, setClusterSession] = useAtom(clusterSessionAtom); 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(() => { useEffect(() => {
const fetchCredentialList = async () => { const fetchCredentialList = async () => {
const data = await queryCredentialList({ page: -1 }); const data = await queryCredentialList({ page: -1 });
@@ -358,14 +362,11 @@ const Clusters: React.FC = () => {
dataList={list} dataList={list}
provider={options.parent?.provider} provider={options.parent?.provider}
clusterId={options.parent?.id} clusterId={options.parent?.id}
gridTemplate={options.gridTemplate}
prefixWidth={options.prefixWidth}
columns={options.columns}
/> />
); );
}; };
const columns = useClusterColumns(handleSelect); const columns = useClusterColumns(handleSelect, handleOnCell);
return ( return (
<> <>
@@ -398,7 +399,6 @@ const Clusters: React.FC = () => {
> >
<SealTable <SealTable
rowKey="id" rowKey="id"
emptyMinHeight="calc(100vh - 300px)"
loadChildren={getWorkerPoolList} loadChildren={getWorkerPoolList}
sortDirections={TABLE_SORT_DIRECTIONS} sortDirections={TABLE_SORT_DIRECTIONS}
expandedRowKeys={expandedRowKeys} expandedRowKeys={expandedRowKeys}
@@ -420,7 +420,7 @@ const Clusters: React.FC = () => {
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
image={<IconFont type="icon-cluster-outline" />} image={<IconFont type="icon-cluster-outline" />}
filters={_.omit(queryParams, ['sort_by', 'mine'])} filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({ noFoundText={intl.formatMessage({
id: 'noresult.cluster.nofound' id: 'noresult.cluster.nofound'
})} })}
@@ -482,7 +482,6 @@ const Clusters: React.FC = () => {
onClose={handleClusterModalClose} onClose={handleClusterModalClose}
></ClusterModal> ></ClusterModal>
{AddWorkerModal} {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 { useIntl } from '@umijs/max';
import { Typography } from 'antd'; import { Typography } from 'antd';
import RegisterClusterInner from '../register-cluster-inner'; import RegisterClusterInner from '../register-cluster-inner';
@@ -36,14 +34,6 @@ const K8sRunCommand: React.FC<AddWorkerStepProps> = ({ disabled }) => {
id: 'clusters.create.addCommand.k8s.tips' id: 'clusters.create.addCommand.k8s.tips'
})} })}
</Typography.Paragraph> </Typography.Paragraph>
<AlertBlockInfo
type="warning"
style={{ marginBottom: 8 }}
icon={<ExclamationCircleFilled />}
message={intl.formatMessage({
id: 'clusters.create.addCommand.k8s.version.warning'
})}
></AlertBlockInfo>
<RegisterClusterInner <RegisterClusterInner
registrationInfo={registrationInfo} registrationInfo={registrationInfo}
currentGPU={currentGPU} currentGPU={currentGPU}
@@ -59,12 +59,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const intl = useIntl(); const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]); const [activeKey, setActiveKey] = React.useState<string[]>([]);
const [submitAttempted, setSubmitAttempted] = useState(false); 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 advanceConfigRef = React.useRef<any>(null);
const systemConfig = useAtomValue(systemConfigAtom); const systemConfig = useAtomValue(systemConfigAtom);
@@ -93,20 +87,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const next: any = { ...opts }; 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; const creds = opts.imageCredentials;
if (Array.isArray(creds)) { if (Array.isArray(creds)) {
next.imageCredentials = creds.map((c: any) => ({ next.imageCredentials = creds.map((c: any) => ({
@@ -240,9 +220,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
}; };
return ( return (
<FormContext.Provider <FormContext.Provider value={{ submitAttempted }}>
value={{ submitAttempted, clusterType, setClusterType }}
>
<Form <Form
name="clusterForm" name="clusterForm"
form={form} form={form}
@@ -2,12 +2,10 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { Input as CInput, LabelSelector } from '@gpustack/core-ui'; import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { Form } from 'antd'; import { Form } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useEffect, useId, useMemo } from 'react'; import React, { useEffect, useId, useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useFormContext } from '../config/form-context';
import { useStepsContext } from '../config/steps-context'; import { useStepsContext } from '../config/steps-context';
import { ClusterListItem as ListItem } from '../config/types'; import { ClusterListItem as ListItem } from '../config/types';
import ImageCredential from './image-credential'; 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 // Visual parity with @gpustack/core-ui's SwitchCard so the selector blends
// in with surrounding form fields: same border, radius, padding, and // in with surrounding form fields: same border, radius, padding, and
// typography. The only differences are the two-column grid layout and an // 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 }>` const ClusterTypeCard = styled.div<{ $active: boolean }>`
position: relative;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 10px; 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 }>` const RadioDot = styled.span<{ $active: boolean }>`
position: relative; position: relative;
flex-shrink: 0; 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 // Card-based selector for cluster type. The two options are mutually exclusive
// and the choice maps directly to the presence/absence of `gpuInstanceOptions` // and the choice maps directly to the presence/absence of `gpuInstanceOptions`
// on the submitted payload. No standalone form field is registered; the click // on the form — "model" clears it, "gpu" seeds it to {} (preserving any
// only updates the shared `clusterType` state (see FormContext) — the payload's // already-entered static address). No standalone form field is registered;
// `gpuInstanceOptions` shape is derived from it at submit (see cluster-form's // state is read via useWatch with `preserve: true` so it tracks updates made
// normalizeOutgoing), and the static-address field mounts/unmounts off it. // through setFieldValue.
export const ClusterTypeSelector: React.FC = () => { export const ClusterTypeSelector: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance();
const { presetClusterType } = useStepsContext(); const { presetClusterType } = useStepsContext();
const labelId = useId(); const labelId = useId();
// Cluster type is shared, explicit state (see FormContext): the click is the const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
// source of truth. This replaced a Form.useWatch on an unregistered path that form,
// did not re-render reliably when cleared to undefined. preserve: true
const { clusterType, setClusterType } = useFormContext();
const value: 'model' | 'gpu' = clusterType ?? 'model';
const handleSelect = useMemoizedFn((next: 'model' | 'gpu') => {
if (next === value) return;
setClusterType?.(next);
}); });
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: { const options: {
key: 'model' | 'gpu'; key: 'model' | 'gpu';
title: string; title: string;
description: string; description: string;
experimental?: boolean;
}[] = [ }[] = [
{ {
key: 'model', key: 'model',
@@ -209,8 +209,7 @@ export const ClusterTypeSelector: React.FC = () => {
{ {
key: 'gpu', key: 'gpu',
title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }), title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }),
description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' }), description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' })
experimental: true
} }
]; ];
@@ -245,11 +244,6 @@ export const ClusterTypeSelector: React.FC = () => {
}} }}
> >
<RadioDot $active={active} /> <RadioDot $active={active} />
{opt.experimental && (
<ExperimentalTag>
{intl.formatMessage({ id: 'common.tag.experimental' })}
</ExperimentalTag>
)}
<div className="body"> <div className="body">
<div className="title">{opt.title}</div> <div className="title">{opt.title}</div>
<div className="description">{opt.description}</div> <div className="description">{opt.description}</div>
@@ -267,13 +261,14 @@ export const ClusterTypeSelector: React.FC = () => {
// default container registry and the worker config (节点配置). // default container registry and the worker config (节点配置).
export const GpuInstancesStaticAddressForm: React.FC = () => { export const GpuInstancesStaticAddressForm: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
// Visibility tracks the shared cluster-type state (see FormContext), so this // See note in ClusterTypeSelector: watch the full store so this field's
// field mounts/unmounts deterministically with the selector. Its Form.Item is // visibility tracks the selector even before it has mounted its own
// the only thing keeping gpuInstanceOptions alive, so unmounting it here (with // Form.Item.
// the form's preserve={false}) also clears that path from the store. const enabled = !!Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
const { clusterType } = useFormContext(); preserve: true
});
if (clusterType !== 'gpu') { if (!enabled) {
return null; return null;
} }
@@ -2,14 +2,13 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { import {
CellContent, CellContent,
type ChildGridOptions,
DeleteModal, DeleteModal,
ExpandedRowGrid, RowChildren,
TableRowProvider TableRowProvider
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { message } from 'antd'; import { Col, message, Row } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { deleteWorkerPool, updateWorkerPool } from '../apis'; import { deleteWorkerPool, updateWorkerPool } from '../apis';
@@ -17,10 +16,7 @@ import { ProviderType } from '../config';
import { NodePoolFormData, NodePoolListItem } from '../config/types'; import { NodePoolFormData, NodePoolListItem } from '../config/types';
import usePoolsColumns from '../hooks/use-pools-columns'; import usePoolsColumns from '../hooks/use-pools-columns';
import AddPool from './add-pool'; import AddPool from './add-pool';
interface PoolRowsProps extends Pick< interface PoolRowsProps {
ChildGridOptions,
'gridTemplate' | 'prefixWidth' | 'columns'
> {
dataList: NodePoolListItem[]; dataList: NodePoolListItem[];
provider: ProviderType; provider: ProviderType;
clusterId: number | string; clusterId: number | string;
@@ -29,35 +25,9 @@ interface PoolRowsProps extends Pick<
const PoolRows: React.FC<PoolRowsProps> = ({ const PoolRows: React.FC<PoolRowsProps> = ({
dataList, dataList,
provider, provider,
clusterId, clusterId
gridTemplate,
prefixWidth = 0,
columns: parentColumns
}) => { }) => {
const intl = useIntl(); 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 modalRef = useRef<any>(null);
const [addPoolStatus, setAddPoolStatus] = useState<{ const [addPoolStatus, setAddPoolStatus] = useState<{
open: boolean; open: boolean;
@@ -92,10 +62,8 @@ const PoolRows: React.FC<PoolRowsProps> = ({
} }
}; };
const handleOnCell = async ( const handleOnCell = async (row: NodePoolListItem, dataIndex: string) => {
row: NodePoolListItem, console.log('handleOncell===', row, dataIndex);
_data: { dataIndex: string; newValue: any; oldValue: any }
) => {
try { try {
await updateWorkerPool({ await updateWorkerPool({
data: row, data: row,
@@ -150,33 +118,31 @@ const PoolRows: React.FC<PoolRowsProps> = ({
<> <>
{dataList?.map((data: NodePoolListItem) => { {dataList?.map((data: NodePoolListItem) => {
return ( return (
<TableRowProvider <div
key={data.id} key={data.id}
value={{ row: data, onCell: handleOnCell }} style={{ borderRadius: 'var(--ant-table-header-border-radius)' }}
> >
<ExpandedRowGrid <TableRowProvider value={{ row: data, onCell: handleOnCell }}>
gridTemplate={gridTemplate} <RowChildren>
prefixWidth={prefixWidth} <Row style={{ width: '100%' }} align="middle">
> {columns.map((col: Record<string, any>) => {
{columns.map((col: Record<string, any>) => ( return (
<ExpandedRowGrid.Cell <Col
key={col.dataIndex || col.key} key={col.dataIndex || col.key}
span={spanFor(col.dataIndex)} span={col.span}
style={{ style={{
color: 'var(--ant-color-text-secondary)', paddingInline: 0,
// CellContent shrinks to its content inside the flex ...(col.style || {})
// cell, so its own align class can't center it — }}
// center at the cell level instead. >
justifyContent: col.align <CellContent {..._.omit(col, ['key'])}></CellContent>
}} </Col>
> );
<CellContent })}
{..._.omit(col, ['key', 'style', 'span'])} </Row>
></CellContent> </RowChildren>
</ExpandedRowGrid.Cell> </TableRowProvider>
))} </div>
</ExpandedRowGrid>
</TableRowProvider>
); );
})} })}
<AddPool <AddPool
@@ -5,12 +5,6 @@ import { ClusterListItem } from './types';
interface FormContextProps { interface FormContextProps {
currentData?: ClusterListItem; currentData?: ClusterListItem;
submitAttempted?: boolean; 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>({}); export const FormContext = createContext<FormContextProps>({});
+1 -3
View File
@@ -58,7 +58,7 @@ const Credentials: React.FC = () => {
key: PaginationKey.Credentials, key: PaginationKey.Credentials,
fetchAPI: queryCredentialList, fetchAPI: queryCredentialList,
deleteAPI: deleteCredential, deleteAPI: deleteCredential,
contentForDelete: 'menu.resources.credentials' contentForDelete: 'menu.clusterManagement.credentials'
}); });
const [, setClusterSession] = useAtom(clusterSessionAtom); const [, setClusterSession] = useAtom(clusterSessionAtom);
const [isFromCluster, setIsFromCluster] = useAtom(fromClusterCreationAtom); const [isFromCluster, setIsFromCluster] = useAtom(fromClusterCreationAtom);
@@ -157,7 +157,6 @@ const Credentials: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={[]} dataSource={[]}
@@ -200,7 +199,6 @@ const Credentials: React.FC = () => {
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
tableLayout="fixed" tableLayout="fixed"
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -13,7 +13,7 @@ import {
type TableColumnProps as SealColumnProps type TableColumnProps as SealColumnProps
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Tooltip } from 'antd'; import { Tooltip, Typography } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -85,15 +85,21 @@ const clusterActionList = [
]; ];
const useClusterColumns = ( 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[] => { ): SealColumnProps[] => {
const intl = useIntl(); const intl = useIntl();
const systemConfig = useAtomValue(systemConfigAtom); const systemConfig = useAtomValue(systemConfigAtom);
const pluginCols = usePluginListColumns('clusters'); const pluginCols = usePluginListColumns('clusters');
// The cluster name is plain text: there is no cluster-detail page // The cluster-detail page is shipped in OSS source, but OSS keeps
// to route into. A plugin may still contribute extra row actions // it unreachable from the cluster list — the link is only
// (topology, Cluster Access) via `clusterDetail.useGenerateActions`. // surfaced when a plugin opts in via
const { useGenerateActions } = getGPUStackPlugin()?.clusterDetail || {}; // `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 = const actionList =
useGenerateActions?.({ actions: clusterActionList }) || clusterActionList; useGenerateActions?.({ actions: clusterActionList }) || clusterActionList;
@@ -153,8 +159,14 @@ const useClusterColumns = (
span: 3, span: 3,
render: (text: string, record: ClusterListItem) => ( render: (text: string, record: ClusterListItem) => (
<> <>
<AutoTooltip ghost title={text} minWidth={20}> <AutoTooltip ghost title={text}>
<span className="text-primary">{record.name}</span> {nameLinkable ? (
<Typography.Link onClick={() => onCellClick?.(record, 'name')}>
{record.name}
</Typography.Link>
) : (
<span className="text-primary">{record.name}</span>
)}
</AutoTooltip> </AutoTooltip>
{record.is_default && ( {record.is_default && (
<Tooltip <Tooltip
@@ -176,7 +188,6 @@ const useClusterColumns = (
dataIndex: 'provider', dataIndex: 'provider',
sorter: tableSorter(2), sorter: tableSorter(2),
span: spans.provider, span: spans.provider,
minWidth: 110,
render: (value: string) => ( render: (value: string) => (
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{ProviderLabelMap[value]} {ProviderLabelMap[value]}
@@ -186,7 +197,7 @@ const useClusterColumns = (
{ {
title: intl.formatMessage({ id: 'dashboard.totalgpus' }), title: intl.formatMessage({ id: 'dashboard.totalgpus' }),
dataIndex: 'gpus', dataIndex: 'gpus',
width: 100, span: 2,
sorter: tableSorter(3), sorter: tableSorter(3),
render: (value: number) => <span>{value}</span> render: (value: number) => <span>{value}</span>
}, },
@@ -195,15 +206,13 @@ const useClusterColumns = (
dataIndex: 'models', dataIndex: 'models',
sorter: tableSorter(4), sorter: tableSorter(4),
span: spans.deployments, span: spans.deployments,
maxWidth: 150,
render: (value: number) => <span>{value}</span> render: (value: number) => <span>{value}</span>
}, },
{ {
title: intl.formatMessage({ id: 'resources.nodes' }), title: intl.formatMessage({ id: 'resources.nodes' }),
dataIndex: 'workers', dataIndex: 'workers',
minWidth: 100,
maxWidth: 120,
sorter: tableSorter(5), sorter: tableSorter(5),
span: spans.workers,
render: (value: number, record: ClusterListItem) => ( render: (value: number, record: ClusterListItem) => (
<span> <span>
{record.ready_workers} / {record.workers} {record.ready_workers} / {record.workers}
@@ -214,8 +223,6 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'common.table.status' }), title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: 'state', dataIndex: 'state',
span: spans.status, span: spans.status,
minWidth: 80,
align: 'center',
render: (value: number, record: ClusterListItem) => ( render: (value: number, record: ClusterListItem) => (
<StatusTag <StatusTag
statusValue={{ statusValue={{
@@ -230,7 +237,7 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'common.table.createTime' }), title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at', dataIndex: 'created_at',
sorter: tableSorter(6), sorter: tableSorter(6),
width: 180, span: spans.createTime,
render: (value: string) => ( render: (value: string) => (
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{dayjs(value).format('YYYY-MM-DD HH:mm:ss')} {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; export default useClusterColumns;
@@ -43,9 +43,12 @@ const usePoolsColumns = (
showTitle: false showTitle: false
}, },
span: 3, span: 3,
style: {
paddingInline: 'var(--ant-table-cell-padding-inline)'
},
render: (text: string) => ( render: (text: string) => (
<AutoTooltip title={text} ghost minWidth={20}> <AutoTooltip title={text} ghost minWidth={20}>
<span className="text-primary">{text}</span> {text}
</AutoTooltip> </AutoTooltip>
) )
}, },
@@ -57,6 +60,9 @@ const usePoolsColumns = (
showTitle: false showTitle: false
}, },
span: 4, span: 4,
style: {
paddingLeft: 62
},
render: (text: string, record: ListItem) => ( render: (text: string, record: ListItem) => (
<AutoTooltip <AutoTooltip
title={ title={
@@ -92,6 +98,9 @@ const usePoolsColumns = (
ellipsis: { ellipsis: {
showTitle: false showTitle: false
}, },
style: {
paddingLeft: 56
},
render: (text: string) => ( render: (text: string) => (
<AutoTooltip <AutoTooltip
title={ title={
@@ -116,7 +125,9 @@ const usePoolsColumns = (
dataIndex: 'replicas', dataIndex: 'replicas',
span: 6, span: 6,
key: 'replicas', key: 'replicas',
align: 'center', style: {
paddingLeft: 50
},
editable: { editable: {
valueType: 'number', valueType: 'number',
title: intl.formatMessage({ id: 'models.table.replicas.edit' }) title: intl.formatMessage({ id: 'models.table.replicas.edit' })
@@ -154,6 +165,9 @@ const usePoolsColumns = (
ellipsis: { ellipsis: {
showTitle: false showTitle: false
}, },
style: {
paddingLeft: 42
},
render: (text: string) => ( render: (text: string) => (
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{dayjs(text).format('YYYY-MM-DD HH:mm:ss')} {dayjs(text).format('YYYY-MM-DD HH:mm:ss')}
@@ -165,6 +179,9 @@ const usePoolsColumns = (
key: 'operations', key: 'operations',
dataIndex: 'operations', dataIndex: 'operations',
span: 3, span: 3,
style: {
paddingLeft: 36
},
render: (text: string, record: ListItem) => ( render: (text: string, record: ListItem) => (
<DropdownButtons <DropdownButtons
items={actionItems} items={actionItems}
@@ -22,12 +22,7 @@ export const useQueryClusterList = (options?: { useStateData?: boolean }) => {
loading, loading,
cancel cancel
} = useRequest( } = useRequest(
async (params: { async (params: { page: number; perPage?: number }) => {
page: number;
perPage?: number;
mine?: boolean;
gpu_instance_enabled?: boolean;
}) => {
axiosTokenRef.current?.cancel(); axiosTokenRef.current?.cancel();
axiosTokenRef.current = createAxiosToken(); axiosTokenRef.current = createAxiosToken();
const res = await queryClusterList(params, { const res = await queryClusterList(params, {
@@ -18,17 +18,10 @@ const BasicForm = forwardRef((props: BasicFormProps, ref) => {
console.log(values); 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, () => ({ useImperativeHandle(ref, () => ({
validateFields: (...args: any[]) => validateFields: formRef.current?.validateFields,
formRef.current?.validateFields(...args), getFieldsValue: formRef.current?.getFieldsValue,
getFieldsValue: (...args: any[]) => submit: formRef.current?.submit
formRef.current?.getFieldsValue(...args),
submit: (...args: any[]) => formRef.current?.submit(...args)
})); }));
return ( return (
+25 -42
View File
@@ -2,20 +2,6 @@ import {
BreakdownItem, BreakdownItem,
UsageBreakdownResponse UsageBreakdownResponse
} from '@/pages/usage/config/types'; } 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 = [ export const overviewConfigs = [
{ {
@@ -102,7 +88,6 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
return groupItem; return groupItem;
} }
let baseLabel: string;
if (groupBy === 'model') { if (groupBy === 'model') {
const providerName = const providerName =
identityValue?.provider_name || groupValue?.provider_name; identityValue?.provider_name || groupValue?.provider_name;
@@ -112,38 +97,36 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
rawItem.model_name || rawItem.model_name ||
rawItem.model; rawItem.model;
baseLabel = if (providerName && modelName) {
providerName && modelName return `${providerName}/${modelName}`;
? `${providerName}/${modelName}` }
: groupItem?.label || modelName || '-';
} else if (groupBy === 'route') { return groupItem?.label || modelName || '-';
baseLabel = }
if (groupBy === 'route') {
return (
groupItem?.label || groupItem?.label ||
identityValue?.route_name || identityValue?.route_name ||
groupValue?.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 return (
// render a tag), matching the usage tabs. The id degrades to just "[Deleted]" groupItem?.label ||
// when the backend nulls ``identity.current`` for a deleted entity. identityValue?.user_name ||
const deletedWord = getIntl().formatMessage({ id: 'usage.table.deleted' }); identityValue?.api_key_name ||
const id = groupItem?.identity?.current?.[GROUP_ID_KEY[groupBy]]; identityValue?.access_key ||
return withDeletedMark(baseLabel, groupItem?.deleted, deletedWord, id); 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 = ( export const getUsageResponseItems = (
@@ -6,8 +6,8 @@ import useUserDirectory from '../hooks/use-user-directory';
/** /**
* Owner tag for template cards, disambiguating same-name templates in * Owner tag for template cards, disambiguating same-name templates in
* the admin's cross-tenant view. Renders nothing for non-admin callers * the admin's cross-tenant view. Renders nothing for non-admin callers
* (gated on `canSeeAdmin`) and when a plugin provides its own * (the management page is `mine`-scoped for them) and when a plugin
* `OwnerScopeTag` slot. * provides its own `OwnerScopeTag` slot.
*/ */
const OwnerTag: React.FC<{ ownerId?: number | null }> = ({ ownerId }) => { const OwnerTag: React.FC<{ ownerId?: number | null }> = ({ ownerId }) => {
const intl = useIntl(); const intl = useIntl();
@@ -1,4 +1,3 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui'; import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Flex, Tag } from 'antd'; import { Flex, Tag } from 'antd';
@@ -272,10 +271,6 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
{cpuManufacturer} {cpuManufacturer}
</ThemeTag> </ThemeTag>
)} )}
<PluginExtraFields
name="InstanceTypeBillingBadge"
context={{ instanceType: item }}
/>
</Flex> </Flex>
</Title> </Title>
<InstanceMetadataSection spec={specData}></InstanceMetadataSection> <InstanceMetadataSection spec={specData}></InstanceMetadataSection>
@@ -1,4 +1,3 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton'; import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton';
import { TemplateCard } from '@gpustack/core-ui'; import { TemplateCard } from '@gpustack/core-ui';
import { Empty, Flex, Spin } from 'antd'; import { Empty, Flex, Spin } from 'antd';
@@ -41,10 +40,6 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
return ( return (
<Flex orientation="vertical" gap={16}> <Flex orientation="vertical" gap={16}>
<PluginExtraFields
name="InstanceTypeBillingProvider"
context={{ instanceTypes: dataList }}
/>
{dataList.map((item) => { {dataList.map((item) => {
const name = item.name; const name = item.name;
return ( return (
@@ -10,18 +10,12 @@ import useOverlayLayout from '../hooks/use-overlay-layout';
interface StorageOverlayProps { interface StorageOverlayProps {
open: boolean; 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; onCancel: () => void;
onSubmit: (values: StorageFormData) => Promise<void> | void; onSubmit: (values: StorageFormData) => Promise<void> | void;
} }
const StorageOverlay: React.FC<StorageOverlayProps> = ({ const StorageOverlay: React.FC<StorageOverlayProps> = ({
open, open,
scopeOrgId,
onCancel, onCancel,
onSubmit onSubmit
}) => { }) => {
@@ -34,14 +28,9 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
fetchStorageClass( fetchStorageClass({ page: -1 });
{ page: -1 },
scopeOrgId != null
? { headers: { 'X-Organization-Id': String(scopeOrgId) } }
: undefined
);
} }
}, [open, scopeOrgId]); }, [open]);
const handleSubmit = () => { const handleSubmit = () => {
formRef.current?.submit(); formRef.current?.submit();
@@ -86,7 +75,6 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
ref={formRef} ref={formRef}
action={PageAction.CREATE} action={PageAction.CREATE}
open={open} open={open}
showOrgScope={false}
onFinish={handleFinish} onFinish={handleFinish}
/> />
</FormContext.Provider> </FormContext.Provider>
@@ -30,10 +30,6 @@ const StorageVolume = ({
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const form = Form.useFormInstance<FormData>(); const form = Form.useFormInstance<FormData>();
const storageMode = Form.useWatch('storageMode', form); 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 { fetchData: createStorage } = useCreateStorage();
const { detailData: storageData, fetchData: fetchStorage } = const { detailData: storageData, fetchData: fetchStorage } =
useQueryStorage(); useQueryStorage();
@@ -236,7 +232,6 @@ const StorageVolume = ({
<StorageOverlay <StorageOverlay
open={overlayOpen} open={overlayOpen}
scopeOrgId={scopeOrgId}
onCancel={() => setOverlayOpen(false)} onCancel={() => setOverlayOpen(false)}
onSubmit={handleCreateStorage} onSubmit={handleCreateStorage}
/> />
@@ -271,7 +271,6 @@ const GPUService: React.FC = () => {
if (!clusterLoading && !hasK8sCluster) { if (!clusterLoading && !hasK8sCluster) {
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading || clusterLoading} loading={dataSource.loading || clusterLoading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={[]} dataSource={[]}
@@ -295,7 +294,6 @@ const GPUService: React.FC = () => {
} }
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -362,7 +360,6 @@ const GPUService: React.FC = () => {
/> />
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
rowSelection={rowSelection} rowSelection={rowSelection}
@@ -110,7 +110,6 @@ const GPUServicePublicKeys: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -154,7 +153,6 @@ const GPUServicePublicKeys: React.FC = () => {
/> />
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
rowSelection={rowSelection} rowSelection={rowSelection}
@@ -1,23 +1,6 @@
import { StatusMaps } from '@/config';
import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui'; import { icons } from '@gpustack/core-ui';
import { StorageTypeKind } from './types'; import { StorageTypeKind } from './types';
export const StorageTypePhaseValueMap = {
Ready: 'Ready',
Deleting: 'Deleting'
};
export const StorageTypePhaseLabelMap: Record<string, string> = {
[StorageTypePhaseValueMap.Ready]: 'Ready',
[StorageTypePhaseValueMap.Deleting]: 'Deleting'
};
export const status: Record<string, StatusType> = {
[StorageTypePhaseValueMap.Ready]: StatusMaps.success,
[StorageTypePhaseValueMap.Deleting]: StatusMaps.warning
};
export const StorageTypeKindValueMap: Record<string, StorageTypeKind> = { export const StorageTypeKindValueMap: Record<string, StorageTypeKind> = {
NFS: 'nfs', NFS: 'nfs',
S3: 's3' S3: 's3'
@@ -44,8 +44,4 @@ export interface ListItem {
nfs?: StorageTypeNFS | null; nfs?: StorageTypeNFS | null;
s3?: Omit<StorageTypeS3, 'secretKey'> | null; s3?: Omit<StorageTypeS3, 'secretKey'> | null;
}; };
status?: {
phase?: string | null;
phaseMessage?: string | null;
} | null;
} }
@@ -5,19 +5,13 @@ import {
AutoTooltip, AutoTooltip,
DropdownButtons, DropdownButtons,
IconFont, IconFont,
StatusTag,
ThemeTag ThemeTag
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table'; import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import { rowActionList, StorageTypeKindLabelMap } from '../config';
rowActionList,
status,
StorageTypeKindLabelMap,
StorageTypePhaseLabelMap
} from '../config';
import { ListItem } from '../config/types'; import { ListItem } from '../config/types';
interface ColumnsHookProps { interface ColumnsHookProps {
@@ -28,13 +22,21 @@ interface ColumnsHookProps {
const getKindLabel = (record: ListItem) => { const getKindLabel = (record: ListItem) => {
if (record.spec?.nfs) if (record.spec?.nfs)
return ( return (
<ThemeTag color="cyan" icon={<FolderOutlined />}> <ThemeTag
style={{ width: 'fit-content' }}
color="cyan"
icon={<FolderOutlined />}
>
{StorageTypeKindLabelMap.nfs} {StorageTypeKindLabelMap.nfs}
</ThemeTag> </ThemeTag>
); );
if (record.spec?.s3) if (record.spec?.s3)
return ( return (
<ThemeTag color="green" icon={<IconFont type="icon-database" />}> <ThemeTag
style={{ width: 'fit-content' }}
color="green"
icon={<IconFont type="icon-database" />}
>
{StorageTypeKindLabelMap.s3} {StorageTypeKindLabelMap.s3}
</ThemeTag> </ThemeTag>
); );
@@ -79,24 +81,6 @@ const useStorageTypeColumns = ({
sorter: false, sorter: false,
render: (_text, record) => getKindLabel(record) render: (_text, record) => getKindLabel(record)
}, },
{
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: ['status', 'phase'],
key: 'status',
sorter: false,
render: (value: string, record: ListItem) =>
value ? (
<StatusTag
statusValue={{
status: status[value],
text: StorageTypePhaseLabelMap[value] || value,
message: record?.status?.phaseMessage || ''
}}
></StatusTag>
) : (
'-'
)
},
...creatorCols, ...creatorCols,
// { // {
// title: intl.formatMessage({ id: 'common.table.description' }), // title: intl.formatMessage({ id: 'common.table.description' }),
@@ -104,7 +104,6 @@ const GPUServiceStorageTypes: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -150,7 +149,6 @@ const GPUServiceStorageTypes: React.FC = () => {
/> />
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
rowSelection={rowSelection} rowSelection={rowSelection}
@@ -51,11 +51,6 @@ export async function queryStorageClass(
return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, { return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, {
method: 'GET', method: 'GET',
params, params,
// `headers` lets the create form pin the request to a specific org so
// the picker only offers storage types that org can reference. GETs
// otherwise inherit the ambient org context, which is empty in the
// platform-admin "All" view and would list every org's types.
headers: options?.headers,
cancelToken: options?.token cancelToken: options?.token
}); });
} }
@@ -1,11 +1,10 @@
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock'; import useSubmitLock from '@/hooks/use-submit-lock';
import { FormDrawer, ModalFooter } from '@gpustack/core-ui'; import { FormDrawer, ModalFooter } from '@gpustack/core-ui';
import { useEffect, useRef } from 'react'; import { useRef } from 'react';
import { FormContext } from '../config/form-context'; import { FormContext } from '../config/form-context';
import { FormData, ListItem } from '../config/types'; import { FormData, ListItem } from '../config/types';
import GPUServiceStorageForm from '../forms'; import GPUServiceStorageForm from '../forms';
import useQueryStorageClass from '../services/use-query-storage-class';
type AddModalProps = { type AddModalProps = {
title: string; title: string;
@@ -14,6 +13,7 @@ type AddModalProps = {
onOk: (values: FormData) => void; onOk: (values: FormData) => void;
data?: ListItem | null; data?: ListItem | null;
onCancel: () => void; onCancel: () => void;
storageClassList: Global.BaseOption<string>[];
}; };
const AddModal: React.FC<AddModalProps> = ({ const AddModal: React.FC<AddModalProps> = ({
@@ -22,33 +22,11 @@ const AddModal: React.FC<AddModalProps> = ({
open, open,
onOk, onOk,
data, data,
onCancel onCancel,
storageClassList
}) => { }) => {
const form = useRef<any>(null); const form = useRef<any>(null);
const { loading, guard, run, release } = useSubmitLock(); const { loading, guard, run, release } = useSubmitLock();
// The dropdown gets its own storage-type list, scoped to the org the
// create-scope picker targets — distinct from the page-level list, which
// stays unscoped so the table can label every org's rows.
const { storageClassList, fetchData: fetchStorageClass } =
useQueryStorageClass();
useEffect(() => {
if (open) {
fetchStorageClass({ page: -1 });
}
}, [open]);
// Platform admin picked a target org in the create-scope slot: reload the
// storage-type list pinned to that org so the dropdown only offers types
// the org can reference (its own plus any reachable via cluster access).
const handleScopeChange = (orgId?: number | null) => {
fetchStorageClass(
{ page: -1 },
orgId != null
? { headers: { 'X-Organization-Id': String(orgId) } }
: undefined
);
};
const handleSubmit = () => { const handleSubmit = () => {
guard(() => form.current?.submit()); guard(() => form.current?.submit());
@@ -92,7 +70,6 @@ const AddModal: React.FC<AddModalProps> = ({
ref={form} ref={form}
action={action} action={action}
currentData={data} currentData={data}
onScopeChange={handleScopeChange}
onFinish={onFinish} onFinish={onFinish}
onFinishFailed={release} onFinishFailed={release}
open={open} open={open}
@@ -3,18 +3,21 @@ import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui'; import { icons } from '@gpustack/core-ui';
export const StoragePhaseValueMap = { export const StoragePhaseValueMap = {
Ready: 'Ready', Available: 'Available',
Deleting: 'Deleting' Unavailable: 'Unavailable',
Pending: 'Pending'
}; };
export const StoragePhaseLabelMap: Record<string, string> = { export const StoragePhaseLabelMap: Record<string, string> = {
[StoragePhaseValueMap.Ready]: 'Ready', [StoragePhaseValueMap.Available]: 'Available',
[StoragePhaseValueMap.Deleting]: 'Deleting' [StoragePhaseValueMap.Unavailable]: 'Unavailable',
[StoragePhaseValueMap.Pending]: 'Pending'
}; };
export const status: Record<string, StatusType> = { export const status: Record<string, StatusType> = {
[StoragePhaseValueMap.Ready]: StatusMaps.success, [StoragePhaseValueMap.Available]: StatusMaps.success,
[StoragePhaseValueMap.Deleting]: StatusMaps.warning [StoragePhaseValueMap.Unavailable]: StatusMaps.error,
[StoragePhaseValueMap.Pending]: StatusMaps.transitioning
}; };
export const rowActionList = [ export const rowActionList = [
@@ -33,7 +33,6 @@ export interface ListItem {
}; };
status?: { status?: {
phase?: string | null; phase?: string | null;
phaseMessage?: string | null;
} | null; } | null;
} }
+2 -23
View File
@@ -12,23 +12,7 @@ import { useContext } from 'react';
import { FormContext } from '../config/form-context'; import { FormContext } from '../config/form-context';
import { FormData } from '../config/types'; import { FormData } from '../config/types';
const Basic = ({ const Basic = ({ action, open }: { action: string; open: boolean }) => {
action,
open,
showOrgScope = true,
onOrgScopeChange
}: {
action: string;
open: boolean;
// Hosts that already fix the tenant scope elsewhere (e.g. the instance
// create form, which owns the org picker) hide this slot so the storage
// is created in the surrounding scope rather than a second, conflicting
// one.
showOrgScope?: boolean;
// Fired on a user selection in the create-scope picker so the host can
// reload the org-scoped storage-type list.
onOrgScopeChange?: (orgId: number | null | undefined) => void;
}) => {
const intl = useIntl(); const intl = useIntl();
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const { storageClassList } = useContext(FormContext); const { storageClassList } = useContext(FormContext);
@@ -60,12 +44,7 @@ const Basic = ({
label={intl.formatMessage({ id: 'common.table.displayName' })} label={intl.formatMessage({ id: 'common.table.displayName' })}
/> />
</Form.Item> </Form.Item>
{showOrgScope && ( <PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
<PluginExtraFields
name="CreateOrgScopeField"
context={{ action, onChange: onOrgScopeChange }}
/>
)}
<Flex gap={16}> <Flex gap={16}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Form.Item<FormData> <Form.Item<FormData>
+3 -62
View File
@@ -1,8 +1,7 @@
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { useMemoizedFn } from 'ahooks';
import { Form } from 'antd'; import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { FormData, ListItem } from '../config/types'; import { FormData, ListItem } from '../config/types';
import Basic from './basic'; import Basic from './basic';
@@ -11,67 +10,14 @@ interface StorageFormProps {
open: boolean; open: boolean;
action: PageActionType; action: PageActionType;
currentData?: ListItem | null; currentData?: ListItem | null;
// Hide the create-scope org picker when the host already fixes the
// tenant scope (e.g. the instance create form). Defaults to shown.
showOrgScope?: boolean;
// Fired when the create-scope picker (platform admin "All" view)
// retargets the form to another org. Only emitted on genuine changes,
// never on the initial mount, and never in builds where the picker
// isn't mounted (the watched field stays undefined). Lets the parent
// reload the org-scoped storage-type options.
onScopeChange?: (orgId: number | null | undefined) => void;
onFinish: (values: FormData) => Promise<void>; onFinish: (values: FormData) => Promise<void>;
onFinishFailed?: (errorInfo: any) => void; onFinishFailed?: (errorInfo: any) => void;
} }
const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef( const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
(props, ref) => { (props, ref) => {
const { const { action, currentData, open, onFinish, onFinishFailed } = props;
action,
currentData,
open,
showOrgScope = true,
onScopeChange,
onFinish,
onFinishFailed
} = props;
const [form] = Form.useForm<FormData>(); const [form] = Form.useForm<FormData>();
// `organization_id` is owned by the create-scope picker slot; it only
// exists/changes when a platform admin retargets the form. Watch it so
// the initial/default scope can be propagated once the picker resolves it.
const scopeOrgId = Form.useWatch('organization_id', form);
const scopeInitRef = useRef(true);
// Stable wrapper so the effect / change handler always call the latest
// callback without re-subscribing on the parent's fn identity.
const orgScope = useMemoizedFn((orgId?: number | null) => {
onScopeChange?.(orgId);
});
// Genuine retarget by the create-scope picker: drop the stale type pick
// (it may name a type the newly chosen org can't reference) and reload the
// org-scoped list. Wired to the picker's own onChange, so it fires only on
// a user selection — never on the picker's programmatic default.
const handleOrgScopeChange = useMemoizedFn((orgId?: number | null) => {
scopeInitRef.current = false;
form.setFieldValue(['spec', 'type'], undefined);
orgScope(orgId ?? null);
});
useEffect(() => {
if (!open) {
// Re-arm the initial-scope propagation for the next open.
scopeInitRef.current = true;
return;
}
// Propagate the initial/default scope once the picker resolves it so the
// parent loads the org-scoped type list, leaving any pre-filled type
// intact. User-driven retargets go through handleOrgScopeChange instead.
if (scopeInitRef.current && scopeOrgId != null) {
scopeInitRef.current = false;
orgScope(scopeOrgId);
}
}, [open, scopeOrgId, orgScope]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -110,12 +56,7 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
preserve={false} preserve={false}
initialValues={{}} initialValues={{}}
> >
<Basic <Basic action={action} open={open} />
action={action}
open={open}
showOrgScope={showOrgScope}
onOrgScopeChange={handleOrgScopeChange}
/>
</Form> </Form>
); );
} }
@@ -1,11 +1,11 @@
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column'; import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
import { usePluginListColumns } from '@/plugins/list-extra-columns'; import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip, DropdownButtons, StatusTag } from '@gpustack/core-ui'; import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table'; import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { rowActionList, status, StoragePhaseLabelMap } from '../config'; import { rowActionList } from '../config';
import { ListItem } from '../config/types'; import { ListItem } from '../config/types';
interface ColumnsHookProps { interface ColumnsHookProps {
@@ -70,25 +70,24 @@ const useStorageColumns = ({
sorter: false, sorter: false,
render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-') render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-')
}, },
{
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: ['status', 'phase'],
key: 'status',
sorter: false,
render: (value: string, record: ListItem) =>
value ? (
<StatusTag
statusValue={{
status: status[value],
text: StoragePhaseLabelMap[value] || value,
message: record?.status?.phaseMessage || ''
}}
></StatusTag>
) : (
'-'
)
},
...creatorCols, ...creatorCols,
// {
// title: intl.formatMessage({ id: 'common.table.status' }),
// dataIndex: ['status', 'phase'],
// key: 'status',
// sorter: false,
// render: (value: string) =>
// value ? (
// <StatusTag
// statusValue={{
// status: status[value],
// text: StoragePhaseLabelMap[value] || value
// }}
// ></StatusTag>
// ) : (
// '-'
// )
// },
{ {
title: intl.formatMessage({ id: 'common.table.createTime' }), title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at', dataIndex: 'created_at',
+1 -2
View File
@@ -109,7 +109,6 @@ const GPUServiceStorage: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -151,7 +150,6 @@ const GPUServiceStorage: React.FC = () => {
/> />
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
rowSelection={rowSelection} rowSelection={rowSelection}
@@ -179,6 +177,7 @@ const GPUServiceStorage: React.FC = () => {
action={openStorageModalStatus.action} action={openStorageModalStatus.action}
title={openStorageModalStatus.title} title={openStorageModalStatus.title}
data={openStorageModalStatus.currentData} data={openStorageModalStatus.currentData}
storageClassList={storageClassList}
onCancel={closeStorageModal} onCancel={closeStorageModal}
onOk={handleModalOk} onOk={handleModalOk}
/> />
@@ -28,7 +28,7 @@ import {
TemplateCard, TemplateCard,
ThemeTag ThemeTag
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button, Tag } from 'antd'; import { Button, Tag } from 'antd';
import { useMemo } from 'react'; import { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -144,26 +144,8 @@ interface TemplateCardProps {
const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => { const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
const intl = useIntl(); const intl = useIntl();
const access = useAccess();
const { isDarkTheme } = useUserSettings(); const { isDarkTheme } = useUserSettings();
// Only an explicit NULL owner (Global, admin-curated) is admin-only.
// A principal-owned row reaches a non-admin's list only when they own
// it, and an absent id (single-owner builds omit it on the wire) has
// no tenancy to restrict — both are manageable, so the check is strict
// ``!== null``. Clone stays available regardless — it reads the source
// and creates a fresh copy in the caller's own scope — so a non-admin
// can fork a Global preset into their org.
const canManage = !!access.canSeeAdmin || data.owner_principal_id !== null;
const actions = useMemo(
() =>
canManage
? templateActions
: templateActions.filter((action) => action.key === 'clone'),
[canManage]
);
const manufacturerLabelMap: Record<string, string> = useMemo(() => { const manufacturerLabelMap: Record<string, string> = useMemo(() => {
return Object.values(GPUsConfigs).reduce( return Object.values(GPUsConfigs).reduce(
(acc, item) => { (acc, item) => {
@@ -256,7 +238,7 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
<span onClick={handleonClickAction} className="operations"> <span onClick={handleonClickAction} className="operations">
<DropdownActions <DropdownActions
menu={{ menu={{
items: actions, items: templateActions,
onClick: handleOnSelect onClick: handleOnSelect
}} }}
> >
@@ -114,12 +114,6 @@ export const templateActions: Array<{
locale: true, locale: true,
icon: icons.EditOutlined icon: icons.EditOutlined
}, },
{
label: 'common.button.clone',
key: 'clone',
locale: true,
icon: icons.CopyOutlined
},
{ {
label: 'common.button.delete', label: 'common.button.delete',
key: 'delete', key: 'delete',
@@ -1,3 +1,4 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { Form } from 'antd'; import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react'; import { forwardRef, useEffect, useImperativeHandle } from 'react';
@@ -25,13 +26,11 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
return; return;
} }
// Prefill on Edit and on Clone (Create carrying a source row). if (action === PageAction.EDIT && currentData) {
// A plain Create opens with no ``currentData`` and keeps the
// blank ``initialValues``.
if (currentData) {
form.setFieldsValue({ form.setFieldsValue({
...currentData ...currentData
}); });
return;
} }
}, [action, currentData, form, open]); }, [action, currentData, form, open]);
+7 -46
View File
@@ -45,11 +45,13 @@ const GPUServiceTemplates: React.FC = () => {
isInfiniteScroll: true, isInfiniteScroll: true,
contentForDelete: intl.formatMessage({ id: 'gpuservice.template' }), contentForDelete: intl.formatMessage({ id: 'gpuservice.template' }),
defaultQueryParams: { defaultQueryParams: {
// Default (non-``mine``) scope: Global rows plus rows owned by the perPage: 24,
// caller's current principal, matching the Inference Backend page. // Management view: drop Global rows for non-admin callers — the
// Global rows a non-admin can't edit render read-only — the card // page is a CRUD surface, and admin-curated Global templates
// gates Edit/Delete on ownership. // they can't edit only add visual noise. The instance-create
perPage: 24 // picker (uses ``useQueryTemplates`` separately) doesn't set
// this and so still sees Global presets.
mine: true
} }
}); });
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } = const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
@@ -90,42 +92,6 @@ const GPUServiceTemplates: React.FC = () => {
); );
}; };
const handleCloneTemplate = (row: ListItem) => {
// Clone reuses the create flow: drop the source's identity and
// ownership so the backend assigns a fresh id and scopes the copy
// to the caller's own principal. The prefilled name is editable —
// the create endpoint enforces per-owner name uniqueness.
const source = _.omit(row, [
'id',
'owner_principal_id',
'creator_id',
'created_at',
'updated_at',
'status'
]) as ListItem;
// Default to a ``-clone`` suffix so cloning within the same scope
// (e.g. a Global preset kept Global) doesn't immediately collide on
// the unique name. Keep within the 63-char name limit by trimming
// the base first.
const suffix = '-clone';
const base = (row.name ?? '').slice(0, 63 - suffix.length);
source.name = `${base}${suffix}`;
// The card renders ``displayName || name``, so a copied displayName
// would make the clone indistinguishable from its source. Append a
// localized clone label, trimmed to the same 63-char field limit.
if (row.displayName) {
const cloneLabel = intl.formatMessage({ id: 'common.button.clone' });
const displaySuffix = ` (${cloneLabel})`;
const displayBase = row.displayName.slice(0, 63 - displaySuffix.length);
source.displayName = `${displayBase}${displaySuffix}`;
}
openTemplateModal(
PageAction.CREATE,
intl.formatMessage({ id: 'gpuservice.template.clone' }),
source
);
};
const handleModalOk = async (data: FormData) => { const handleModalOk = async (data: FormData) => {
try { try {
if (openTemplateModalStatus.action === PageAction.EDIT) { if (openTemplateModalStatus.action === PageAction.EDIT) {
@@ -148,10 +114,6 @@ const GPUServiceTemplates: React.FC = () => {
handleEditTemplate(item.data); handleEditTemplate(item.data);
return; return;
} }
if (item.action === 'clone') {
handleCloneTemplate(item.data);
return;
}
if (item.action === 'delete') { if (item.action === 'delete') {
handleDelete({ ...item.data, name: item.data.name }); handleDelete({ ...item.data, name: item.data.name });
} }
@@ -210,7 +172,6 @@ const GPUServiceTemplates: React.FC = () => {
onSelect={handleOnSelect} onSelect={handleOnSelect}
/> />
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
-1
View File
@@ -166,7 +166,6 @@ const Catalog: React.FC = () => {
isFirst={!dataSource.loadend} isFirst={!dataSource.loadend}
></CatalogList> ></CatalogList>
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -6,7 +6,7 @@ import { getGPUStackPlugin } from '@/plugins';
import { DownOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { DownOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { import {
AlertBlockInfo, AlertBlockInfo,
CardRadioGroup, TooltipList,
Transfer as TransferInner Transfer as TransferInner
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
@@ -15,8 +15,9 @@ import {
Dropdown, Dropdown,
DropdownProps, DropdownProps,
Empty, Empty,
Flex,
Form, Form,
Radio,
RadioChangeEvent,
Tooltip Tooltip
} from 'antd'; } from 'antd';
import { import {
@@ -79,9 +80,9 @@ const Label = styled.div`
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-weight: 500; font-weight: 500;
margin-bottom: 16px; margin-block: 8px 12px;
font-size: 13px; font-size: 14px;
color: var(--ant-color-text-secondary); color: var(--ant-color-text-tertiary);
`; `;
interface AccessControlFormProps { interface AccessControlFormProps {
@@ -106,14 +107,6 @@ type AllowedUsersOverride = {
routeId?: number; routeId?: number;
action: PageActionType; action: PageActionType;
}>; }>;
// Optional trigger (e.g. an "Add" button) rendered by the host in its
// own layout slot while `Field` renders the body. Same props as Field
// so the plugin can gate visibility on action/routeId.
Action?: React.ComponentType<{
form: any;
routeId?: number;
action: PageActionType;
}>;
}; };
// Extra policy entries prepended to the radio list (rendered before // Extra policy entries prepended to the radio list (rendered before
@@ -230,7 +223,9 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
} }
}; };
const handleOnPolicyChange = async (policy: string) => { const handleOnPolicyChange = async (e: RadioChangeEvent) => {
console.log('policy changed:', e.target.value);
const policy = e.target.value;
if (policy === ALLOWED_PRINCIPALS_POLICY) { if (policy === ALLOWED_PRINCIPALS_POLICY) {
form.setFieldsValue({ users: formDataCacheRef.current?.users || [] }); form.setFieldsValue({ users: formDataCacheRef.current?.users || [] });
} else { } else {
@@ -396,29 +391,33 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
: undefined : undefined
}} }}
> >
<Label>{intl.formatMessage({ id: 'models.table.accessScope' })}</Label> <Label>
{intl.formatMessage({ id: 'models.table.accessScope' })}
<Tooltip
title={
<TooltipList
list={buildAccessScopeTips(
allowedUsersOverride,
prependedPolicies
)}
></TooltipList>
}
>
<QuestionCircleOutlined />
</Tooltip>
</Label>
<Form.Item<AccessControlFormData> <Form.Item<AccessControlFormData> name="access_policy" noStyle>
name="access_policy" <Radio.Group
style={{
marginBottom: 16
}}
>
<CardRadioGroup
onChange={handleOnPolicyChange} onChange={handleOnPolicyChange}
style={{ marginBottom: 12 }}
options={[ options={[
...prependedPolicies.map((p) => ({ ...prependedPolicies.map((p) => ({
label: intl.formatMessage({ id: p.labelId }), label: intl.formatMessage({ id: p.labelId }),
description: intl.formatMessage({ id: p.tipsId ?? p.labelId }),
value: p.policyValue value: p.policyValue
})), })),
{ {
label: intl.formatMessage({ label: intl.formatMessage({ id: 'models.accessSettings.authed' }),
id: 'models.accessSettings.authed'
}),
description: intl.formatMessage({
id: 'models.accessSettings.authed.tips'
}),
value: 'authed' value: 'authed'
}, },
allowedUsersOverride allowedUsersOverride
@@ -426,52 +425,23 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
label: intl.formatMessage({ label: intl.formatMessage({
id: allowedUsersOverride.labelId id: allowedUsersOverride.labelId
}), }),
description: intl.formatMessage({
id:
allowedUsersOverride.tipsId ??
'models.accessSettings.allowedUsers.tips'
}),
value: allowedUsersOverride.policyValue value: allowedUsersOverride.policyValue
} }
: { : {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.accessSettings.allowedUsers' id: 'models.accessSettings.allowedUsers'
}), }),
description: intl.formatMessage({
id: 'models.accessSettings.allowedUsers.tips'
}),
value: ALLOWED_PRINCIPALS_POLICY value: ALLOWED_PRINCIPALS_POLICY
}, },
{ {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.accessSettings.public' id: 'models.accessSettings.public'
}), }),
description: intl.formatMessage({
id: 'models.accessSettings.public.desc'
}),
value: 'public' value: 'public'
} }
]} ]}
/> ></Radio.Group>
</Form.Item> </Form.Item>
{allowedUsersOverride?.Action && accessPolicy === overridePolicyValue && (
<Flex
justify="space-between"
align="center"
style={{ marginBottom: 8 }}
>
<Label style={{ marginBottom: 0 }}>
{intl.formatMessage({
id: 'models.accessSettings.grantedPrincipals'
})}
</Label>
<allowedUsersOverride.Action
form={form}
routeId={currentData?.id}
action={action}
/>
</Flex>
)}
{accessPolicy === 'public' && ( {accessPolicy === 'public' && (
<div style={{ marginBlock: '16px 12px' }}> <div style={{ marginBlock: '16px 12px' }}>
<AlertBlockInfo <AlertBlockInfo
@@ -479,13 +449,6 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
message={intl.formatMessage({ message={intl.formatMessage({
id: 'models.accessSettings.public.tips' id: 'models.accessSettings.public.tips'
})} })}
overlayScrollerProps={{
styles: {
wrapper: {
paddingLeft: 0
}
}
}}
></AlertBlockInfo> ></AlertBlockInfo>
</div> </div>
)} )}
@@ -130,9 +130,7 @@ const AccessControlModal: React.FC<
<AlertBlockInfo <AlertBlockInfo
type="warning" type="warning"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
contentStyle={{ contentStyle={{ paddingInline: 0 }}
paddingInline: 0
}}
message={intl.formatMessage({ message={intl.formatMessage({
id: 'models.button.accessSettings.tips' id: 'models.button.accessSettings.tips'
})} })}
+5 -2
View File
@@ -25,7 +25,10 @@ const Metric: React.FC<{ label: React.ReactNode; value: React.ReactNode }> = ({
value value
}) => ( }) => (
<span style={{ lineHeight: 1.2 }}> <span style={{ lineHeight: 1.2 }}>
{label}: <span>{value}</span> {label}:{' '}
<span className="font-500" style={{ color: 'var(--ant-color-text)' }}>
{value}
</span>
</span> </span>
); );
@@ -67,7 +70,7 @@ const GPUCard: React.FC<{
} }
description={ description={
info || ( info || (
<Flex wrap gap={8} style={{ fontSize: 11 }}> <Flex wrap gap={8} style={{ fontSize: 13 }}>
<Metric <Metric
label={intl.formatMessage({ id: 'resources.table.total' })} label={intl.formatMessage({ id: 'resources.table.total' })}
value={convertFileSize(data?.memory?.total || 0)} value={convertFileSize(data?.memory?.total || 0)}
@@ -1,5 +1,6 @@
import { ListItem as WorkerListItem } from '@/pages/resources/config/types'; import { ListItem as WorkerListItem } from '@/pages/resources/config/types';
import { AutoTooltip, ExpandedRowGrid } from '@gpustack/core-ui'; import { AutoTooltip, RowChildren } from '@gpustack/core-ui';
import { Col, Row } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import React from 'react'; import React from 'react';
import { ModelInstanceListItem } from '../../config/types'; import { ModelInstanceListItem } from '../../config/types';
@@ -10,17 +11,11 @@ import DistributeInfoCell from '../instance-cells/distribute-info-cell';
import DownloadingStatusCell from '../instance-cells/downloading-status-cell'; import DownloadingStatusCell from '../instance-cells/downloading-status-cell';
import InstanceStatusCell from '../instance-cells/instance-status-cell'; import InstanceStatusCell from '../instance-cells/instance-status-cell';
import NameCell from '../instance-cells/name-cell'; import NameCell from '../instance-cells/name-cell';
interface InstanceItemProps { interface InstanceItemProps {
instanceData: ModelInstanceListItem; instanceData: ModelInstanceListItem;
workerList: WorkerListItem[]; workerList: WorkerListItem[];
modelData?: any; modelData?: any;
defaultOpenId: string; defaultOpenId: string;
// Column grid shared from the parent SealTable so this child row aligns
// its cells to the parent columns instead of guessing paddings.
gridTemplate?: string;
prefixWidth?: number;
columns?: any[];
handleChildSelect: (val: string, item: ModelInstanceListItem) => void; handleChildSelect: (val: string, item: ModelInstanceListItem) => void;
} }
@@ -29,67 +24,79 @@ const InstanceItem: React.FC<InstanceItemProps> = ({
workerList, workerList,
modelData, modelData,
defaultOpenId, defaultOpenId,
gridTemplate,
prefixWidth = 0,
columns,
handleChildSelect handleChildSelect
}) => { }) => {
// The child row shares the parent's column grid; cells flow left-to-right and
// only declare a span, so there is no dependency on parent column keys.
// Parent layout: name (1) | middle plugin/source region | replicas,
// created_at, operation (last 3). The middle absorbs whatever columns sit
// between name and replicas (cluster_id, source, any plugin column).
const columnCount = columns?.length ?? 0;
const middleSpan = Math.max(columnCount - 4, 1);
return ( return (
<ExpandedRowGrid <div style={{ borderRadius: 'var(--ant-table-header-border-radius)' }}>
gridTemplate={gridTemplate} <RowChildren>
prefixWidth={prefixWidth} <Row
style={{ color: 'var(--ant-color-text-secondary)' }} style={{ width: '100%', color: 'var(--ant-color-text-secondary)' }}
> align="middle"
<ExpandedRowGrid.Cell span={1}> >
<NameCell <Col
record={instanceData} span={6}
modelData={modelData} style={{
defaultOpenId={defaultOpenId} paddingInline: 'var(--ant-table-cell-padding-inline)'
></NameCell> }}
</ExpandedRowGrid.Cell> >
<ExpandedRowGrid.Cell <NameCell
span={middleSpan} record={instanceData}
style={{ flexWrap: 'wrap', gap: 8 }} modelData={modelData}
> defaultOpenId={defaultOpenId}
<CPUOffloadingCell record={instanceData}></CPUOffloadingCell> ></NameCell>
<DistributeInfoCell </Col>
record={instanceData} <Col span={7}>
workerList={workerList} <span
></DistributeInfoCell> style={{
</ExpandedRowGrid.Cell> paddingLeft: '58px',
<ExpandedRowGrid.Cell span={1} style={{ gap: 4 }}> flexWrap: 'wrap',
<InstanceStatusCell gap: '8px'
record={instanceData} }}
onSelect={handleChildSelect} className="flex align-center"
/> >
<DownloadingStatusCell <CPUOffloadingCell record={instanceData}></CPUOffloadingCell>
backend={modelData?.backend} <DistributeInfoCell
distributed_servers={instanceData.distributed_servers} record={instanceData}
workerList={workerList} workerList={workerList}
record={instanceData} ></DistributeInfoCell>
></DownloadingStatusCell> </span>
</ExpandedRowGrid.Cell> </Col>
<ExpandedRowGrid.Cell span={1}> <Col span={4}>
<AutoTooltip ghost> <span
{dayjs(instanceData.created_at).format('YYYY-MM-DD HH:mm:ss')} style={{ paddingLeft: '40px', gap: 4 }}
</AutoTooltip> className="flex-center"
</ExpandedRowGrid.Cell> >
<ExpandedRowGrid.Cell span={1}> <InstanceStatusCell
<ActionsCell record={instanceData}
record={instanceData} onSelect={handleChildSelect}
modelData={modelData} />
onSelect={handleChildSelect} <DownloadingStatusCell
></ActionsCell> backend={modelData?.backend}
</ExpandedRowGrid.Cell> distributed_servers={instanceData.distributed_servers}
</ExpandedRowGrid> workerList={workerList}
record={instanceData}
></DownloadingStatusCell>
</span>
</Col>
<Col span={4}>
<span style={{ paddingLeft: 43 }} className="flex">
<AutoTooltip ghost>
{dayjs(instanceData.created_at).format('YYYY-MM-DD HH:mm:ss')}
</AutoTooltip>
</span>
</Col>
<Col span={3}>
<div style={{ paddingLeft: 36 }}>
<ActionsCell
record={instanceData}
modelData={modelData}
onSelect={handleChildSelect}
></ActionsCell>
</div>
</Col>
</Row>
</RowChildren>
</div>
); );
}; };
export default InstanceItem; export default InstanceItem;
@@ -17,9 +17,6 @@ interface InstanceItemProps {
workerList: WorkerListItem[]; workerList: WorkerListItem[];
modelData?: any; modelData?: any;
currentExpanded?: string; currentExpanded?: string;
gridTemplate?: string;
prefixWidth?: number;
columns?: any[];
handleChildSelect: (val: string, item: ModelInstanceListItem) => void; handleChildSelect: (val: string, item: ModelInstanceListItem) => void;
} }
@@ -28,9 +25,6 @@ const Instances: React.FC<InstanceItemProps> = ({
workerList, workerList,
modelData, modelData,
currentExpanded, currentExpanded,
gridTemplate,
prefixWidth,
columns,
handleChildSelect handleChildSelect
}) => { }) => {
const [firstLoad, setFirstLoad] = React.useState(true); const [firstLoad, setFirstLoad] = React.useState(true);
@@ -61,9 +55,6 @@ const Instances: React.FC<InstanceItemProps> = ({
instanceData={item} instanceData={item}
defaultOpenId={firstLoad ? defaultOpenId : ''} defaultOpenId={firstLoad ? defaultOpenId : ''}
handleChildSelect={handleChildSelect} handleChildSelect={handleChildSelect}
gridTemplate={gridTemplate}
prefixWidth={prefixWidth}
columns={columns}
></InstanceItem> ></InstanceItem>
); );
})} })}
+24 -90
View File
@@ -1,13 +1,11 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { import {
IconFont, StatusTag,
StatusDot,
TagsWrapper, TagsWrapper,
TemplateCard, TemplateCard,
ThemeTag ThemeTag
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl, useNavigate } from '@umijs/max'; import { useIntl, useNavigate } from '@umijs/max';
import { Button, Tooltip } from 'antd'; import { Button } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -21,11 +19,7 @@ import {
MyModelsStatusValueMap MyModelsStatusValueMap
} from '../config'; } from '../config';
import { categoryToPathMap } from '../config/button-actions'; import { categoryToPathMap } from '../config/button-actions';
import { import { getModelLogo } from '../utils/model-logo';
defaultModelLogo,
getCategoryLogo,
getModelLogo
} from '../utils/model-logo';
const CardWrapper = styled.div` const CardWrapper = styled.div`
&:hover { &:hover {
@@ -50,7 +44,7 @@ const ModelItemContent = styled.div`
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
width: 100%; width: 100%;
cursor: pointer; cursor: default;
.content { .content {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -91,8 +85,8 @@ const ModelItemContent = styled.div`
`; `;
const ModelLogo = styled.img` const ModelLogo = styled.img`
width: 32px; width: 24px;
height: 32px; height: 24px;
border-radius: 4px; border-radius: 4px;
object-fit: contain; object-fit: contain;
flex: none; flex: none;
@@ -133,23 +127,15 @@ const renderTag = (item: any, index = 0) => {
const ModelItem: React.FC<{ const ModelItem: React.FC<{
model: Record<string, any>; model: Record<string, any>;
onClick?: (model: Record<string, any>) => void;
}> = (props) => { }> = (props) => {
const { model, onClick } = props; const { model } = props;
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigate(); const navigate = useNavigate();
const handleCardClick = () => {
onClick?.(model);
};
// ``model.name`` from ``/v2/my-models`` is the OpenAI-style id // ``model.name`` from ``/v2/my-models`` is the OpenAI-style id
// (org-prefixed for non-platform routes, bare for platform). Use it // (org-prefixed for non-platform routes, bare for platform). Use it
// verbatim — the playground / dispatcher both key off that exact id. // verbatim — the playground / dispatcher both key off that exact id.
const handleOpenPlayGroundClick = (e: React.MouseEvent) => { const handleOpenPlayGroundClick = () => {
// Card is clickable (opens API access info); keep the playground
// action isolated so it doesn't also trigger the card click.
e.stopPropagation();
const modelName = encodeURIComponent(model.name); const modelName = encodeURIComponent(model.name);
for (const [category, path] of Object.entries(categoryToPathMap)) { for (const [category, path] of Object.entries(categoryToPathMap)) {
if ( if (
@@ -170,11 +156,6 @@ const ModelItem: React.FC<{
navigate(`/playground/chat?model=${modelName}`); navigate(`/playground/chat?model=${modelName}`);
}; };
// Logo priority: brand logo matched from the name → tinted category
// icon (from model_icons) → generic default image.
const brandLogo = getModelLogo(model.name);
const categoryLogo = brandLogo ? null : getCategoryLogo(model.categories);
// context length // context length
const maxToken = useMemo(() => { const maxToken = useMemo(() => {
const meta = model.meta || {}; const meta = model.meta || {};
@@ -193,67 +174,29 @@ const ModelItem: React.FC<{
return _.round(max_tokens / 1024); return _.round(max_tokens / 1024);
}, [model]); }, [model]);
// Ready is the normal state — show just the dot (no label); other states
// (Not Ready / Stopped) keep their label so the problem is legible.
const statusNode = (
<StatusDot
statusValue={{
status: MyModelsStatusMap[model.status],
text:
model.status === MyModelsStatusValueMap.Ready ||
!MyModelsStatusLabelMap[model.status]
? ''
: intl.formatMessage({
id: MyModelsStatusLabelMap[model.status]
})
}}
/>
);
return ( return (
<CardWrapper> <CardWrapper>
<TemplateCard <TemplateCard
height={140} height={140}
clickable={true} clickable={false}
hoverable={true} hoverable={true}
ghost ghost
onClick={handleCardClick}
header={ header={
<Header> <Header>
<span className="text gap-16"> <span className="text gap-8">
{brandLogo ? ( <ModelLogo src={getModelLogo(model.name)} alt="" />
<ModelLogo src={brandLogo} alt="" /> <span>{model.name}</span>
) : categoryLogo ? (
<ModelLogo src={categoryLogo} alt="" />
) : (
<ModelLogo src={defaultModelLogo} alt="" />
)}
<span className="flex-center" style={{ gap: 8, minWidth: 0 }}>
<span>{model.name}</span>
{/* Status moved to a compact dot right after the name, freeing
the header's right side for the price summary. The dot keeps
the state message on hover (StatusDot has no built-in one). */}
<span
style={{
fontWeight: 400,
fontSize: 'var(--font-size-small)'
}}
>
{model.state_message ? (
<Tooltip title={model.state_message}>
<span style={{ display: 'inline-flex' }}>
{statusNode}
</span>
</Tooltip>
) : (
statusNode
)}
</span>
</span>
</span>
<span className="flex-center gap-8">
<PluginExtraFields name="ModelPriceSummary" context={{ model }} />
</span> </span>
<StatusTag
maxTooltipWidth={400}
statusValue={{
status: MyModelsStatusMap[model.status],
text: intl.formatMessage({
id: MyModelsStatusLabelMap[model.status] || ''
}),
message: model.state_message
}}
></StatusTag>
</Header> </Header>
} }
> >
@@ -296,23 +239,14 @@ const ModelItem: React.FC<{
</> </>
)} )}
</div> </div>
{[MyModelsStatusValueMap.Ready].includes(model.status) && ( {[MyModelsStatusValueMap.Active].includes(model.status) && (
<Button <Button
size="middle" size="middle"
type="default"
className="btn" className="btn"
style={{ type="primary"
borderRadius: 6,
paddingInline: 12
}}
onClick={handleOpenPlayGroundClick} onClick={handleOpenPlayGroundClick}
> >
{intl.formatMessage({ id: 'models.openinplayground' })} {intl.formatMessage({ id: 'models.openinplayground' })}
<IconFont
type="icon-down2"
rotate={-90}
style={{ marginLeft: 4 }}
></IconFont>
</Button> </Button>
)} )}
</div> </div>
+4 -7
View File
@@ -5,7 +5,9 @@ import { PageActionType } from '@/config/types';
import useBodyScroll from '@/hooks/use-body-scroll'; import useBodyScroll from '@/hooks/use-body-scroll';
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys'; import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
import useTableRowSelection from '@/hooks/use-table-row-selection'; import useTableRowSelection from '@/hooks/use-table-row-selection';
import useWatchList from '@/hooks/use-watch-list';
import useNoResourceResult from '@/pages/llmodels/hooks/use-no-resource-result'; import useNoResourceResult from '@/pages/llmodels/hooks/use-no-resource-result';
import { MODEL_ROUTE_TARGETS } from '@/pages/model-routes/apis';
import { TargetStatusValueMap } from '@/pages/model-routes/config'; import { TargetStatusValueMap } from '@/pages/model-routes/config';
import useOpenPlayground from '@/pages/model-routes/hooks/use-open-playground'; import useOpenPlayground from '@/pages/model-routes/hooks/use-open-playground';
import useGranfanaLink from '@/pages/resources/hooks/use-grafana-link'; import useGranfanaLink from '@/pages/resources/hooks/use-grafana-link';
@@ -90,7 +92,6 @@ interface ModelsProps {
loadend: boolean; loadend: boolean;
total: number; total: number;
filterValues?: Record<string, any>; filterValues?: Record<string, any>;
targetList?: any[];
} }
const getFormattedData = (record: any, extraData = {}) => ({ const getFormattedData = (record: any, extraData = {}) => ({
@@ -128,8 +129,7 @@ const Models: React.FC<ModelsProps> = ({
queryParams, queryParams,
loading, loading,
loadend, loadend,
total, total
targetList = []
}) => { }) => {
const { generateFormValues, clusterList, workerList } = const { generateFormValues, clusterList, workerList } =
useDeploymentsContext(); useDeploymentsContext();
@@ -155,6 +155,7 @@ const Models: React.FC<ModelsProps> = ({
expandedRowKeys expandedRowKeys
} = useExpandedRowKeys(expandAtom); } = useExpandedRowKeys(expandAtom);
const { handleOpenPlayGround } = useOpenPlayground(); const { handleOpenPlayGround } = useOpenPlayground();
const { watchDataList: targetList } = useWatchList(MODEL_ROUTE_TARGETS);
const { openViewLogsModal, openViewLogsModalStatus, closeViewLogsModal } = const { openViewLogsModal, openViewLogsModalStatus, closeViewLogsModal } =
useViewInstanceLogs(); useViewInstanceLogs();
@@ -441,9 +442,6 @@ const Models: React.FC<ModelsProps> = ({
modelData={options.parent} modelData={options.parent}
workerList={workerList} workerList={workerList}
handleChildSelect={handleChildSelect} handleChildSelect={handleChildSelect}
gridTemplate={options.gridTemplate}
prefixWidth={options.prefixWidth}
columns={options.columns}
></Instances> ></Instances>
); );
}, },
@@ -616,7 +614,6 @@ const Models: React.FC<ModelsProps> = ({
></PageTools> ></PageTools>
<SealTable <SealTable
columns={columns} columns={columns}
emptyMinHeight="calc(100vh - 300px)"
sortDirections={TABLE_SORT_DIRECTIONS} sortDirections={TABLE_SORT_DIRECTIONS}
dataSource={dataSource} dataSource={dataSource}
rowSelection={rowSelection} rowSelection={rowSelection}
+9 -9
View File
@@ -196,21 +196,21 @@ export const status: any = {
}; };
export const MyModelsStatusValueMap = { export const MyModelsStatusValueMap = {
Stopped: 'stopped', Inactive: 'stopped',
NotReady: 'not_ready', Degrade: 'not_ready',
Ready: 'ready' Active: 'ready'
}; };
export const MyModelsStatusMap = { export const MyModelsStatusMap = {
[MyModelsStatusValueMap.Stopped]: StatusMaps.inactive, [MyModelsStatusValueMap.Inactive]: StatusMaps.inactive,
[MyModelsStatusValueMap.NotReady]: StatusMaps.error, [MyModelsStatusValueMap.Degrade]: StatusMaps.error,
[MyModelsStatusValueMap.Ready]: StatusMaps.success [MyModelsStatusValueMap.Active]: StatusMaps.success
}; };
export const MyModelsStatusLabelMap = { export const MyModelsStatusLabelMap = {
[MyModelsStatusValueMap.Stopped]: 'models.mymodels.status.inactive', [MyModelsStatusValueMap.Inactive]: 'models.mymodels.status.inactive',
[MyModelsStatusValueMap.NotReady]: 'models.mymodels.status.degrade', [MyModelsStatusValueMap.Degrade]: 'models.mymodels.status.degrade',
[MyModelsStatusValueMap.Ready]: 'models.mymodels.status.active' [MyModelsStatusValueMap.Active]: 'models.mymodels.status.active'
}; };
export const ScheduleValueMap = { export const ScheduleValueMap = {
+1 -31
View File
@@ -3,8 +3,6 @@ import useSetChunkRequest from '@/hooks/use-chunk-request';
import { usePaginationStatus } from '@/hooks/use-pagination-status'; import { usePaginationStatus } from '@/hooks/use-pagination-status';
import { useTableMultiSort } from '@/hooks/use-table-sort'; import { useTableMultiSort } from '@/hooks/use-table-sort';
import useUpdateChunkedList from '@/hooks/use-update-chunk-list'; import useUpdateChunkedList from '@/hooks/use-update-chunk-list';
import useWatchList from '@/hooks/use-watch-list';
import { MODEL_ROUTE_TARGETS } from '@/pages/model-routes/apis';
import { TableOrder, TableProvider } from '@gpustack/core-ui'; import { TableOrder, TableProvider } from '@gpustack/core-ui';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import _ from 'lodash'; import _ from 'lodash';
@@ -33,11 +31,6 @@ const Models = forwardRef((props, ref) => {
const { setChunkRequest, createAxiosToken } = useSetChunkRequest(); const { setChunkRequest, createAxiosToken } = useSetChunkRequest();
const { setChunkRequest: setModelInstanceChunkRequest } = const { setChunkRequest: setModelInstanceChunkRequest } =
useSetChunkRequest(); useSetChunkRequest();
const {
watchDataList: targetList,
startWatch: startTargetsWatch,
cancelWatch: cancelTargetsWatch
} = useWatchList(MODEL_ROUTE_TARGETS);
const [modelInstances, setModelInstances] = useState<any[]>([]); const [modelInstances, setModelInstances] = useState<any[]>([]);
const [dataSource, setDataSource] = useState<{ const [dataSource, setDataSource] = useState<{
dataList: ListItem[]; dataList: ListItem[];
@@ -248,13 +241,7 @@ const Models = forwardRef((props, ref) => {
chunkInstanceRequedtRef.current = setModelInstanceChunkRequest({ chunkInstanceRequedtRef.current = setModelInstanceChunkRequest({
url: `${MODEL_INSTANCE_API}`, url: `${MODEL_INSTANCE_API}`,
params: {}, params: {},
handler: updateInstanceHandler, handler: updateInstanceHandler
beforeReconnect() {
// treat the reconnect snapshot as the new baseline, otherwise
// instances deleted while the stream was down linger in the cache
// (their DELETE events are never re-sent)
cacheInsDataListRef.current = [];
}
}); });
} catch (error) { } catch (error) {
// ignore // ignore
@@ -268,7 +255,6 @@ const Models = forwardRef((props, ref) => {
cacheInsDataListRef.current = []; cacheInsDataListRef.current = [];
chunkInstanceRequedtRef.current?.current?.cancel?.(); chunkInstanceRequedtRef.current?.current?.cancel?.();
instancesToken.current?.cancel?.(); instancesToken.current?.cancel?.();
cancelTargetsWatch();
}); });
const resumeRequestsOnPageActive = useMemoizedFn(async () => { const resumeRequestsOnPageActive = useMemoizedFn(async () => {
@@ -279,7 +265,6 @@ const Models = forwardRef((props, ref) => {
await getAllModelInstances(); await getAllModelInstances();
await createModelsInstanceChunkRequest(); await createModelsInstanceChunkRequest();
await createModelsChunkRequest(); await createModelsChunkRequest();
await startTargetsWatch();
}); });
const handleOnCancelViewLogs = useMemoizedFn(async () => { const handleOnCancelViewLogs = useMemoizedFn(async () => {
@@ -458,20 +443,6 @@ const Models = forwardRef((props, ref) => {
}; };
}, []); }, []);
// watch events can still be lost (stream hiccup, reconnect gap); a low
// frequency relist keeps the instance cache eventually consistent, so a
// missed DELETE event can't leave a stale instance behind for good
useEffect(() => {
const timer = setInterval(() => {
if (!isPageHidden.current) {
getAllModelInstances();
}
}, 60 * 1000);
return () => {
clearInterval(timer);
};
}, []);
const setDisableExpand = useMemoizedFn((record: any) => { const setDisableExpand = useMemoizedFn((record: any) => {
return !record?.replicas; return !record?.replicas;
}); });
@@ -512,7 +483,6 @@ const Models = forwardRef((props, ref) => {
total={dataSource.total} total={dataSource.total}
deleteIds={dataSource.deletedIds} deleteIds={dataSource.deletedIds}
filterValues={filterValues} filterValues={filterValues}
targetList={targetList}
></TableList> ></TableList>
</TableProvider> </TableProvider>
); );
+8 -36
View File
@@ -85,7 +85,6 @@ interface BasicFormProps {
} }
>[]; >[];
handleClusterChange: (value: number) => void; handleClusterChange: (value: number) => void;
onClusterSeed: (value: number) => void;
onSourceChange?: (value: string) => void; onSourceChange?: (value: string) => void;
} }
@@ -95,7 +94,6 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
clusterList, clusterList,
sourceDisable, sourceDisable,
handleClusterChange, handleClusterChange,
onClusterSeed,
onSourceChange onSourceChange
} = props; } = props;
const intl = useIntl(); const intl = useIntl();
@@ -150,53 +148,27 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
// selection and fall back to the scope's default cluster (then a Ready one, // selection and fall back to the scope's default cluster (then a Ready one,
// then the first) so GPU/backend options refetch for it. A selection that's // then the first) so GPU/backend options refetch for it. A selection that's
// still valid is left untouched, so a user's (or edit's) choice is kept. // still valid is left untouched, so a user's (or edit's) choice is kept.
// Use the seed callback (not handleClusterChange) so this auto-pick refreshes
// options without firing an evaluate request before a model is selected.
useEffect(() => { useEffect(() => {
// Options derive from clusterList: an empty source list means clusters if (!clusterOptions?.length) {
// are still loading — leave the field alone until they arrive.
if (!clusterList?.length) {
return; return;
} }
// Scope off the live form value, not the `useWatch` snapshot: the scope
// field's default lands in a child effect that flushes before this one,
// while the watch still reports the previous render's null — scoping off
// the watch would seed a cluster from the unscoped list here and only
// re-scope a render later.
const liveScopeOrgId = form.getFieldValue('organization_id') ?? null;
const scoped = clusterList.filter(
(item) =>
liveScopeOrgId == null || item.owner_principal_id === liveScopeOrgId
);
const current = form.getFieldValue('cluster_id'); const current = form.getFieldValue('cluster_id');
if (!scoped.length) { const stillValid = clusterOptions.some((c) => c.value === current);
// Clusters are loaded but the picked org owns none. Any leftover
// selection points at another org's cluster (seeded before the scope
// settled) and would make requests fail with "Cluster not found" —
// clear it so the required rule surfaces instead. Create only: an
// edit's cluster is existing data, not a seed.
if (action === PageAction.CREATE && current != null) {
form.setFieldValue('cluster_id', undefined);
}
return;
}
const stillValid = scoped.some((c) => c.value === current);
if (current != null && stillValid) { if (current != null && stillValid) {
return; return;
} }
const next = const next =
scoped.find((c) => c.is_default)?.value ?? clusterOptions.find((c) => c.is_default)?.value ??
scoped.find((c) => c.state === ClusterStatusValueMap.Ready)?.value ?? clusterOptions.find((c) => c.state === ClusterStatusValueMap.Ready)
scoped[0]?.value ?? ?.value ??
clusterOptions[0]?.value ??
null; null;
if (next == null || next === current) { if (next == null || next === current) {
return; return;
} }
form.setFieldValue('cluster_id', next); form.setFieldValue('cluster_id', next);
onClusterSeed?.(next); handleClusterChange?.(next);
// `clusterOptions` is the re-run trigger for scope changes: it recomputes }, [clusterOptions, form, handleClusterChange]);
// whenever the watched org scope or the cluster list settles.
}, [clusterOptions, clusterList, action, form, onClusterSeed]);
const clusterOptionRender = (option: any) => { const clusterOptionRender = (option: any) => {
const { data } = option; const { data } = option;
+2 -18
View File
@@ -236,35 +236,20 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
onOk(allValues); onOk(allValues);
}; };
// Shared work when the target cluster changes: refetch the GPU/backend const handleClusterChange = async (value: number) => {
// options for the new cluster and reset schedule/gpu selection. await onClusterChange?.(value);
const applyClusterScopedOptions = (value: number) => {
getGPUOptionList({ clusterId: value }); getGPUOptionList({ clusterId: value });
getBackendOptions({ cluster_id: value }); getBackendOptions({ cluster_id: value });
form.setFieldsValue({ form.setFieldsValue({
scheduleType: ScheduleValueMap.Auto, scheduleType: ScheduleValueMap.Auto,
gpu_selector: null gpu_selector: null
}); });
};
// User explicitly picked a cluster: refresh scoped options and re-evaluate.
const handleClusterChange = async (value: number) => {
await onClusterChange?.(value);
applyClusterScopedOptions(value);
await new Promise((resolve) => { await new Promise((resolve) => {
setTimeout(resolve, 150); setTimeout(resolve, 150);
}); });
onValuesChange?.({}, form.getFieldsValue()); onValuesChange?.({}, form.getFieldsValue());
}; };
// The basic form seeds a default cluster on open, before a model is picked.
// Refresh scoped options for it but don't fire the evaluate request — there
// is no model to evaluate yet.
const handleClusterSeed = async (value: number) => {
await onClusterChange?.(value);
applyClusterScopedOptions(value);
};
const getFieldPaths = (obj: Record<string, any>, prefix = ''): string => { const getFieldPaths = (obj: Record<string, any>, prefix = ''): string => {
const result = Object.entries(obj).flatMap(([key, value]) => { const result = Object.entries(obj).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key; const path = prefix ? `${prefix}.${key}` : key;
@@ -482,7 +467,6 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
clusterList={clusterList} clusterList={clusterList}
sourceDisable={sourceDisable} sourceDisable={sourceDisable}
handleClusterChange={handleClusterChange} handleClusterChange={handleClusterChange}
onClusterSeed={handleClusterSeed}
onSourceChange={onSourceChange} onSourceChange={onSourceChange}
></BasicForm> ></BasicForm>
<CollapsePanel <CollapsePanel
-19
View File
@@ -401,24 +401,6 @@ export const useCheckCompatibility = () => {
); );
}; };
// Evaluation needs a model reference. The basic form seeds a default cluster
// on open, which can fire onValuesChange before the user has picked a model —
// skip evaluation until the current source's model field is filled.
const noModelSelected = (allValues: any) => {
switch (allValues.source) {
case modelSourceMap.huggingface_value:
return !allValues.huggingface_repo_id;
case modelSourceMap.modelscope_value:
return !allValues.model_scope_model_id;
case modelSourceMap.ollama_library_value:
return !allValues.ollama_library_model_name;
case modelSourceMap.local_path_value:
return !allValues.local_path;
default:
return false;
}
};
const handleOnValuesChange = async (params: { const handleOnValuesChange = async (params: {
changedValues: any; changedValues: any;
allValues: any; allValues: any;
@@ -428,7 +410,6 @@ export const useCheckCompatibility = () => {
if ( if (
_.isEqual(cacheFormValuesRef.current, allValues) || _.isEqual(cacheFormValuesRef.current, allValues) ||
noLocalPathValue(allValues) || noLocalPathValue(allValues) ||
noModelSelected(allValues) ||
!allValues.replicas !allValues.replicas
) { ) {
console.log('No changes detected, skipping evaluation.'); console.log('No changes detected, skipping evaluation.');
@@ -24,7 +24,7 @@ const useFilterStatus = (options?: {
const statusOptions = [ const statusOptions = [
{ {
value: MyModelsStatusValueMap.Ready, value: MyModelsStatusValueMap.Active,
color: 'var(--ant-color-success)', color: 'var(--ant-color-success)',
icon: <Dot color="var(--ant-color-success)"></Dot>, icon: <Dot color="var(--ant-color-success)"></Dot>,
label: intl.formatMessage({ label: intl.formatMessage({
@@ -32,7 +32,7 @@ const useFilterStatus = (options?: {
}) })
}, },
{ {
value: MyModelsStatusValueMap.Stopped, value: MyModelsStatusValueMap.Inactive,
color: 'var(--ant-color-fill-secondary)', color: 'var(--ant-color-fill-secondary)',
icon: <Dot color="var(--ant-color-fill-secondary)"></Dot>, icon: <Dot color="var(--ant-color-fill-secondary)"></Dot>,
label: intl.formatMessage({ label: intl.formatMessage({
@@ -40,7 +40,7 @@ const useFilterStatus = (options?: {
}) })
}, },
{ {
value: MyModelsStatusValueMap.NotReady, value: MyModelsStatusValueMap.Degrade,
color: 'var(--ant-color-warning)', color: 'var(--ant-color-warning)',
icon: <Dot color="var(--ant-color-warning)"></Dot>, icon: <Dot color="var(--ant-color-warning)"></Dot>,
label: intl.formatMessage({ label: intl.formatMessage({
@@ -199,13 +199,7 @@ export const useGenerateWorkerOptions = () => {
page: -1 page: -1
}), }),
queryClusterList({ queryClusterList({
page: -1, page: -1
// Own-org clusters only. Feeds the deploy-from-model-file cluster
// picker, which must not offer another org's cluster (e.g. the
// Default org's "shared with everyone" clusters). The worker
// cascader on this page is already own-org via the owner-scoped
// worker list.
mine: true
}) })
]); ]);
const workerList = workerRes.items || ([] as WorkerListItem[]); const workerList = workerRes.items || ([] as WorkerListItem[]);
@@ -278,13 +272,7 @@ export default function useFormInitialValues() {
// Exclude clusters that opt in to GPU-instance handling // Exclude clusters that opt in to GPU-instance handling
// (k8s_options.gpu_instance_options set) — those are for the // (k8s_options.gpu_instance_options set) — those are for the
// GPU-service flow, not model deployment. // GPU-service flow, not model deployment.
gpu_instance_enabled: false, gpu_instance_enabled: false
// Only clusters owned by the current org. Drops cross-org grants
// (e.g. the Default org's "shared with everyone" clusters) so a
// tenant can't deploy onto another org's infrastructure. Platform
// admin in the "All" view bypasses this and is scoped instead by
// the org picker (see basic.tsx).
mine: true
}); });
const list = response.items.map((item) => ({ const list = response.items.map((item) => ({
label: item.name, label: item.name,
@@ -268,7 +268,7 @@ const useModelsColumns = ({
dataIndex: 'created_at', dataIndex: 'created_at',
key: 'created_at', key: 'created_at',
sorter: tableSorter(5), sorter: tableSorter(5),
width: 180, span: spans.createTime,
render: (text: number) => ( render: (text: number) => (
<AutoTooltip ghost> <AutoTooltip ghost>
{dayjs(text).format('YYYY-MM-DD HH:mm:ss')} {dayjs(text).format('YYYY-MM-DD HH:mm:ss')}
@@ -95,7 +95,6 @@ const useNoResourceResult = (props: {
const noResourceResult = ( const noResourceResult = (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={loading} loading={loading}
loadend={loadend} loadend={loadend}
dataSource={dataSource} dataSource={dataSource}
@@ -1,30 +0,0 @@
import { useState } from 'react';
const useViewApIInfo = () => {
const [apiAccessInfo, setAPIAccessInfo] = useState<any>({
show: false,
data: {}
});
const openViewAPIInfo = (row: any) => {
setAPIAccessInfo({
show: true,
data: row
});
};
const closeViewAPIInfo = () => {
setAPIAccessInfo({
show: false,
data: {}
});
};
return {
apiAccessInfo,
openViewAPIInfo,
closeViewAPIInfo
};
};
export default useViewApIInfo;
@@ -31,16 +31,6 @@ const filterOptions = {
label: 'Error', label: 'Error',
value: 'error', value: 'error',
color: 'var(--ant-color-error)' color: 'var(--ant-color-error)'
},
{
label: 'Pending',
value: 'pending',
color: 'var(--ant-color-info)'
},
{
label: 'Starting',
value: 'starting',
color: 'var(--ant-color-info)'
} }
] ]
}; };
@@ -109,7 +99,6 @@ const InstanceView = forwardRef((props, ref) => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
+93 -158
View File
@@ -1,24 +1,21 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import useTableFetch from '@/hooks/use-table-fetch'; import useTableFetch from '@/hooks/use-table-fetch';
import { SyncOutlined } from '@ant-design/icons'; import { SyncOutlined } from '@ant-design/icons';
import { import {
BaseSelect, BaseSelect,
IconFont,
InfiniteScrollerProvider, InfiniteScrollerProvider,
NoResult,
PageTools, PageTools,
TemplateCardList TemplateCardList
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useAccess, useIntl, useNavigate } from '@umijs/max'; import { useIntl } from '@umijs/max';
import useMemoizedFn from 'ahooks/lib/useMemoizedFn'; import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
import { Button, Input, Space } from 'antd'; import { Button, Input, Space } from 'antd';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useMemo } from 'react';
import PageBox from '../_components/page-box'; import PageBox from '../_components/page-box';
import { MY_MODELS_API, queryMyModels } from './apis'; import { MY_MODELS_API, queryMyModels } from './apis';
import APIAccessInfoModal from './components/api-access-info';
import ModelItem from './components/model-item'; import ModelItem from './components/model-item';
import { categoryOptions, MyModelsStatusValueMap } from './config'; import { categoryOptions, MyModelsStatusValueMap } from './config';
import useFormInitialValues from './hooks/use-form-initial-values';
import useNoResourceResult from './hooks/use-no-resource-result';
import useViewApIInfo from './hooks/use-view-api-info';
const Dot = ({ color }: { color: string }) => { const Dot = ({ color }: { color: string }) => {
return ( return (
<span <span
@@ -56,56 +53,29 @@ const UserModels: React.FC = () => {
watch: false, watch: false,
isInfiniteScroll: true, isInfiniteScroll: true,
defaultQueryParams: { defaultQueryParams: {
perPage: 24, perPage: 24
state: MyModelsStatusValueMap.Ready
} }
}); });
const intl = useIntl(); const intl = useIntl();
const access = useAccess();
const navigate = useNavigate();
const { apiAccessInfo, openViewAPIInfo, closeViewAPIInfo } = useViewApIInfo();
// Only managers (platform admin or org owner) can see / manage
// clusters and workers, so only they hit those endpoints. A plain
// user falls straight through to the default "no models" empty state
// without the infra-guidance queries firing.
const canManageResources = access?.canSeeAdmin || access?.canSeeOrgAdmin;
const { getClusterList, getWorkerList, clusterList, workerList } =
useFormInitialValues();
// Managers start in a loading state so the empty state waits for the
// infra queries to resolve — otherwise the initially-empty
// cluster/worker lists briefly flash the "no clusters" / "no workers"
// guidance before the real data lands.
const [infraLoading, setInfraLoading] = useState(!!canManageResources);
useEffect(() => {
if (canManageResources) {
setInfraLoading(true);
Promise.all([getClusterList(), getWorkerList()]).finally(() => {
setInfraLoading(false);
});
}
}, [canManageResources]);
const statusOptions = useMemo(() => { const statusOptions = useMemo(() => {
return [ return [
{ {
value: MyModelsStatusValueMap.Ready, value: MyModelsStatusValueMap.Active,
color: 'var(--ant-color-success)', color: 'var(--ant-color-success)',
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.mymodels.status.active' id: 'models.mymodels.status.active'
}) })
}, },
{ {
value: MyModelsStatusValueMap.Stopped, value: MyModelsStatusValueMap.Inactive,
color: 'var(--ant-color-fill)', color: 'var(--ant-color-fill)',
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.mymodels.status.inactive' id: 'models.mymodels.status.inactive'
}) })
}, },
{ {
value: MyModelsStatusValueMap.NotReady, value: MyModelsStatusValueMap.Degrade,
color: 'var(--ant-color-warning)', color: 'var(--ant-color-warning)',
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.mymodels.status.degrade' id: 'models.mymodels.status.degrade'
@@ -121,7 +91,7 @@ const UserModels: React.FC = () => {
}; };
const renderCard = (data: any) => { const renderCard = (data: any) => {
return <ModelItem model={data} onClick={openViewAPIInfo} />; return <ModelItem model={data} />;
}; };
const loadMore = useMemoizedFn((nextPage: number) => { const loadMore = useMemoizedFn((nextPage: number) => {
@@ -152,17 +122,17 @@ const UserModels: React.FC = () => {
const getStatus = useCallback((model: any) => { const getStatus = useCallback((model: any) => {
if (!model.targets && !model.ready_targets) { if (!model.targets && !model.ready_targets) {
return MyModelsStatusValueMap.Stopped; return MyModelsStatusValueMap.Inactive;
} }
if (model.targets > 0 && !model.ready_targets) { if (model.targets > 0 && !model.ready_targets) {
return MyModelsStatusValueMap.NotReady; return MyModelsStatusValueMap.Degrade;
} }
if (model.ready_targets > 0 && model.targets > 0) { if (model.ready_targets > 0 && model.targets > 0) {
return MyModelsStatusValueMap.Ready; return MyModelsStatusValueMap.Active;
} }
return MyModelsStatusValueMap.NotReady; return MyModelsStatusValueMap.Degrade;
}, []); }, []);
const dataList = useMemo(() => { const dataList = useMemo(() => {
@@ -181,122 +151,87 @@ const UserModels: React.FC = () => {
}); });
}; };
const { noResourceResult } = useNoResourceResult({
// Hold the empty state until infra queries resolve so managers don't
// see a "no clusters/workers" flash before the real data arrives.
loading: dataSource.loading || infraLoading,
loadend: dataSource.loadend,
dataSource: dataList,
// Preserve the original filters heuristic: only treat the current
// query as an active filter when there is data across pages, so a
// truly empty account still shows the full empty state (CTA).
queryParams: dataSource.totalPage > 0 ? queryParams : {},
iconType: 'icon-models',
title: intl.formatMessage({ id: 'noresult.mymodels.title' }),
noClusters: !!canManageResources && !clusterList.length,
noWorkers:
!!canManageResources && workerList.length === 0 && clusterList.length > 0,
defaultContent: {
// Infra is in place but no models yet: guide managers to deploy
// one, reusing the deployments-page copy so the two empty states
// read consistently. Plain users can't deploy (and skip the infra
// queries), so they keep the consumer-facing "ask an admin" copy
// and a button-less empty state.
subTitle: canManageResources
? intl.formatMessage({ id: 'noresult.deployments.subTitle' })
: intl.formatMessage({ id: 'noresult.mymodels.subTitle' }),
noFoundText: intl.formatMessage({ id: 'noresult.mymodels.nofound' }),
buttonText: canManageResources
? intl.formatMessage({ id: 'models.table.button.deploy' })
: '',
onClick: canManageResources ? () => navigate('/models/catalog') : () => {}
}
});
return ( return (
<> <PageBox>
<PageBox> <PageTools
<PageTools marginBottom={22}
marginBottom={22} marginTop={0}
marginTop={0} left={
left={ <Space>
<Space> <Input
<Input placeholder={intl.formatMessage({ id: 'common.filter.name' })}
placeholder={intl.formatMessage({ id: 'common.filter.name' })} style={{ width: 230 }}
style={{ width: 230 }} size="large"
size="large" allowClear
allowClear onClear={() =>
onClear={() => handleNameChange({
handleNameChange({ target: {
target: { value: ''
value: '' }
} })
}) }
} onChange={handleNameChange}
onChange={handleNameChange} ></Input>
></Input> <BaseSelect
<BaseSelect allowClear
allowClear showSearch={false}
showSearch={false} placeholder={intl.formatMessage({ id: 'models.filter.category' })}
placeholder={intl.formatMessage({ style={{ width: 180 }}
id: 'models.filter.category' size="large"
})} maxTagCount={1}
style={{ width: 180 }} options={categoryOptions}
size="large" onChange={handleCategoryChange}
maxTagCount={1} ></BaseSelect>
options={categoryOptions} <BaseSelect
onChange={handleCategoryChange} allowClear
></BaseSelect> showSearch={false}
<BaseSelect placeholder={intl.formatMessage({ id: 'common.filter.status' })}
allowClear style={{ width: 180 }}
showSearch={false} size="large"
placeholder={intl.formatMessage({ id: 'common.filter.status' })} maxTagCount={1}
style={{ width: 180 }} optionRender={optionRender}
size="large" labelRender={labelRender}
maxTagCount={1} options={statusOptions}
optionRender={optionRender} onChange={handleStatusChange}
labelRender={labelRender} ></BaseSelect>
options={statusOptions} <Button
value={queryParams.state} type="text"
onChange={handleStatusChange} style={{ color: 'var(--ant-color-text-tertiary)' }}
></BaseSelect> icon={<SyncOutlined></SyncOutlined>}
<Button onClick={handleRefresh}
type="text" ></Button>
style={{ color: 'var(--ant-color-text-tertiary)' }} </Space>
icon={<SyncOutlined></SyncOutlined>} }
onClick={handleRefresh} ></PageTools>
></Button> <InfiniteScrollerProvider
</Space> value={{
} total: dataSource.totalPage,
></PageTools> current: queryParams.page,
<PluginExtraFields loading: dataSource.loading,
name="ModelBillingProvider" refresh: loadMore
context={{ models: dataList }} }}
/> >
<InfiniteScrollerProvider <TemplateCardList
value={{ dataList={dataList}
total: dataSource.totalPage, loading={dataSource.loading}
current: queryParams.page, activeId={false}
loading: dataSource.loading, isFirst={!dataSource.loadend}
refresh: loadMore renderItem={renderCard}
}} ></TemplateCardList>
> <NoResult
<TemplateCardList loading={dataSource.loading}
dataList={dataList} loadend={dataSource.loadend}
loading={dataSource.loading} dataSource={dataList}
activeId={false} image={<IconFont type="icon-models" />}
isFirst={!dataSource.loadend} filters={{ ...queryParams }}
renderItem={renderCard} noFoundText={intl.formatMessage({
></TemplateCardList> id: 'noresult.mymodels.nofound'
{noResourceResult} })}
</InfiniteScrollerProvider> title={intl.formatMessage({ id: 'noresult.mymodels.title' })}
</PageBox> subTitle={intl.formatMessage({ id: 'noresult.mymodels.subTitle' })}
<APIAccessInfoModal ></NoResult>
open={apiAccessInfo.show} </InfiniteScrollerProvider>
data={apiAccessInfo.data} </PageBox>
onClose={closeViewAPIInfo}
></APIAccessInfoModal>
</>
); );
}; };

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