Compare commits

..
Author SHA1 Message Date
jialin 17527b0fa5 fix: show cluster in table 2026-05-26 23:01:36 +08:00
105 changed files with 1317 additions and 4970 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ node_modules
.umi-production .umi-production
public/static/*.js public/static/*.js
public/static/*.css public/static/*.css
src/components/iconfont/ src/components/icon-font/iconfont/iconfont.js
src/components/icon-font/iconfont/*.css
+1 -1
View File
@@ -3,5 +3,5 @@ module.exports = {
rules: { rules: {
'selector-class-pattern': null 'selector-class-pattern': null
}, },
ignoreFiles: ['public/static/*.css', 'src/components/iconfont/iconfont.css'] ignoreFiles: ['public/static/*.css']
}; };
+20 -26
View File
@@ -179,25 +179,6 @@ const baseRoutes = [
access: 'canSeeOrgAdmin', access: 'canSeeOrgAdmin',
hideInMenu: true, hideInMenu: true,
component: './benchmark/details' component: './benchmark/details'
},
{
name: 'backendsList',
path: '/models/backends',
key: 'backendsList',
icon: 'icon-backend',
selectedIcon: 'icon-backend-filled',
defaultIcon: 'icon-backend',
access: 'canSeeOrgAdmin',
component: './backends/index'
},
{
name: 'modelfiles',
path: '/models/modelfiles',
key: 'modelfiles',
icon: 'icon-files',
selectedIcon: 'icon-files-filled',
defaultIcon: 'icon-files',
component: './resources/components/model-files'
} }
] ]
}, },
@@ -205,7 +186,6 @@ const baseRoutes = [
name: 'gpuService', name: 'gpuService',
path: '/gpu-service', path: '/gpu-service',
key: 'gpuService', key: 'gpuService',
access: 'canSeeGpuService',
routes: [ routes: [
{ {
path: '/gpu-service', path: '/gpu-service',
@@ -243,12 +223,7 @@ const baseRoutes = [
path: '/gpu-service/storage-types', path: '/gpu-service/storage-types',
key: 'gpuServiceStorageTypes', key: 'gpuServiceStorageTypes',
icon: 'icon-storage-outlined', icon: 'icon-storage-outlined',
// Storage types are tenant-scoped on the backend (Org owners access: 'canSeeAdmin',
// can create/list their own), so the menu shouldn't be
// platform-admin-only. ``canSeeOrgAdmin`` keeps the gate at
// "admin or current-org owner" — Org members still don't see
// it, which matches the read/write model in the route.
access: 'canSeeOrgAdmin',
selectedIcon: 'icon-storage-filled', selectedIcon: 'icon-storage-filled',
defaultIcon: 'icon-storage-outlined', defaultIcon: 'icon-storage-outlined',
component: './gpu-service/storage-types' component: './gpu-service/storage-types'
@@ -291,6 +266,25 @@ const baseRoutes = [
selectedIcon: 'icon-gpu-filled', selectedIcon: 'icon-gpu-filled',
defaultIcon: 'icon-gpu1', defaultIcon: 'icon-gpu1',
component: './resources/components/gpus' component: './resources/components/gpus'
},
{
name: 'backendsList',
path: '/resources/backends',
key: 'backendsList',
icon: 'icon-backend',
selectedIcon: 'icon-backend-filled',
defaultIcon: 'icon-backend',
access: 'canSeeOrgAdmin',
component: './backends/index'
},
{
name: 'modelfiles',
path: '/resources/modelfiles',
key: 'modelfiles',
icon: 'icon-files',
selectedIcon: 'icon-files-filled',
defaultIcon: 'icon-files',
component: './resources/components/model-files'
} }
] ]
}, },
+1 -2
View File
@@ -14,8 +14,7 @@ export default defineConfig([
'dist', 'dist',
'src/.umi/', 'src/.umi/',
'src/.umi-production/', 'src/.umi-production/',
'src/.umi-test/', 'src/.umi-test/'
'src/components/iconfont/'
]), ]),
{ {
files: ['**/*.{ts,tsx,js,jsx}'], files: ['**/*.{ts,tsx,js,jsx}'],
+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.19", "@gpustack/core-ui": "^1.0.16",
"@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",
+7 -7
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.19 specifier: ^1.0.16
version: 1.0.19(czdvzceysqw7iv6pct2ucnb23e) version: 1.0.16(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf': '@huggingface/gguf':
specifier: ^0.1.7 specifier: ^0.1.7
version: 0.1.18 version: 0.1.18
@@ -1484,8 +1484,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.19': '@gpustack/core-ui@1.0.16':
resolution: {integrity: sha512-EI2inYoTDdWYdxXXm0BM2DQ7chysnbIXICivBMeUxxhc8IkEcwaQfNqo/KUtA/bkqjDieRxFC1JlsDIIhbcL3A==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.19.tgz} resolution: {integrity: sha512-wFKDv7X0FXRmAmZPt4WKYV0+aGHcx82/3EZSJkrut/49XQd5XdrqKaimGtlFWFCNEWqsouwzs4uqaC7JFqfOkQ==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.16.tgz}
peerDependencies: peerDependencies:
'@ant-design/icons': '>=6.0.0' '@ant-design/icons': '>=6.0.0'
'@ant-design/pro-components': 3.1.0-0 '@ant-design/pro-components': 3.1.0-0
@@ -10808,7 +10808,7 @@ snapshots:
'@formatjs/intl-utils@2.3.0': {} '@formatjs/intl-utils@2.3.0': {}
'@gpustack/core-ui@1.0.19(czdvzceysqw7iv6pct2ucnb23e)': '@gpustack/core-ui@1.0.16(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)
@@ -11829,7 +11829,7 @@ snapshots:
'@types/history@5.0.0': '@types/history@5.0.0':
dependencies: dependencies:
history: 5.3.0 history: 4.10.1
'@types/hoist-non-react-statics@3.3.7(@types/react@18.3.28)': '@types/hoist-non-react-statics@3.3.7(@types/react@18.3.28)':
dependencies: dependencies:
@@ -11915,7 +11915,7 @@ snapshots:
'@types/history': 4.7.11 '@types/history': 4.7.11
'@types/react': 18.3.29 '@types/react': 18.3.29
'@types/react-router': 5.1.20 '@types/react-router': 5.1.20
redux: 4.2.1 redux: 3.7.2
'@types/react-router@5.1.20': '@types/react-router@5.1.20':
dependencies: dependencies:
+1 -16
View File
@@ -1,9 +1,6 @@
import { applyAccessExtensions } from './access.extensions'; import { applyAccessExtensions } from './access.extensions';
export default (initialState: { export default (initialState: { currentUser?: Global.UserInfo }) => {
currentUser?: Global.UserInfo;
hasKubernetesCluster?: boolean;
}) => {
const isPlatformAdmin = !!( const isPlatformAdmin = !!(
initialState && initialState &&
initialState.currentUser && initialState.currentUser &&
@@ -14,12 +11,6 @@ export default (initialState: {
initialState.currentUser && initialState.currentUser &&
!initialState.currentUser.is_admin !initialState.currentUser.is_admin
); );
// GPU Service is Kubernetes-only. We only gate visibility down when
// the probe in `getInitialState` came back with a definitive answer;
// `undefined` (probe failed / not yet ready) collapses to the
// role-based default so a transient network blip can't lock anyone
// out of the menu.
const hasKubernetesCluster = initialState?.hasKubernetesCluster;
// Predicate roles, top-down by strictness: // Predicate roles, top-down by strictness:
// * `canSeeAdmin` — strictly platform admin (`users.is_admin`). // * `canSeeAdmin` — strictly platform admin (`users.is_admin`).
@@ -27,11 +18,6 @@ export default (initialState: {
// * `canSeeOrgAdmin` — admin-style menus that work cross-org // * `canSeeOrgAdmin` — admin-style menus that work cross-org
// (Dashboard, Resources, Models, Cluster Management). Defaults // (Dashboard, Resources, Models, Cluster Management). Defaults
// to platform admin; extensions widen to include org admins. // to platform admin; extensions widen to include org admins.
// * `canSeeGpuService` — GPU Service menu. Anyone allowed to
// manage clusters (admins, Org owners) sees it; non-admins fall
// through to "show only if a Kubernetes cluster is actually
// reachable" so Org members without scheduling access don't see
// a dead-end menu item.
// * `canManageCurrentOrg` — pages that only make sense inside a // * `canManageCurrentOrg` — pages that only make sense inside a
// specific org context (member / group management). Defaults to // specific org context (member / group management). Defaults to
// `false`; extensions widen when both an org is selected AND // `false`; extensions widen when both an org is selected AND
@@ -41,7 +27,6 @@ export default (initialState: {
return applyAccessExtensions({ return applyAccessExtensions({
canSeeAdmin: isPlatformAdmin, canSeeAdmin: isPlatformAdmin,
canSeeOrgAdmin: isPlatformAdmin, canSeeOrgAdmin: isPlatformAdmin,
canSeeGpuService: isPlatformAdmin || hasKubernetesCluster !== false,
canManageCurrentOrg: false, canManageCurrentOrg: false,
canSeeUser, canSeeUser,
canDelete: true, canDelete: true,
+2 -42
View File
@@ -3,8 +3,6 @@ import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user';
import { setAtomStorage } from '@/atoms/utils'; import { setAtomStorage } from '@/atoms/utils';
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings'; import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
import { COLOR_PRIMARY } from '@/config/theme/constants'; import { COLOR_PRIMARY } from '@/config/theme/constants';
import { queryClusterList } from '@/pages/cluster-management/apis';
import { ProviderValueMap } from '@/pages/cluster-management/config';
import { enterprisePluginReady } from '@/plugins/enterprise-ready'; import { enterprisePluginReady } from '@/plugins/enterprise-ready';
import { GPUStackPluginManager } from '@/plugins/manager'; import { GPUStackPluginManager } from '@/plugins/manager';
import { requestConfig } from '@/request-config'; import { requestConfig } from '@/request-config';
@@ -36,45 +34,11 @@ const checkDefaultPage = async (userInfo: any) => {
} }
}; };
// Probes the caller's cluster list once so access predicates can gate
// GPU Service (Kubernetes-only). Cheap (one list request) and never
// blocks login — any failure just falls back to `undefined`, which
// the predicate treats as "unknown / don't restrict beyond role".
// The result is also mirrored into sessionStorage so access extensions
// that run without the initialState argument can read it (e.g. to
// override the admin shortcut in scopes where the menu shouldn't
// show even for admins).
const HAS_K8S_CLUSTER_KEY = 'hasKubernetesCluster';
const probeHasKubernetesCluster = async (): Promise<boolean | undefined> => {
try {
const res = await queryClusterList({ page: -1 });
const value = (res?.items ?? []).some(
(c) => c?.provider === ProviderValueMap.Kubernetes
);
try {
window.sessionStorage.setItem(HAS_K8S_CLUSTER_KEY, JSON.stringify(value));
} catch {
// sessionStorage may be unavailable (Safari private mode); the
// access predicate already handles a missing value as "unknown".
}
return value;
} catch (error) {
console.error('probeHasKubernetesCluster error', error);
try {
window.sessionStorage.removeItem(HAS_K8S_CLUSTER_KEY);
} catch {
// ignore
}
return undefined;
}
};
// runtime configuration // runtime configuration
export async function getInitialState(): Promise<{ export async function getInitialState(): Promise<{
fetchUserInfo: () => Promise<Global.UserInfo>; fetchUserInfo: () => Promise<Global.UserInfo>;
currentUser?: Global.UserInfo; currentUser?: Global.UserInfo;
pluginData?: Record<string, any>; pluginData?: Record<string, any>;
hasKubernetesCluster?: boolean;
}> { }> {
const { location } = history; const { location } = history;
@@ -158,16 +122,12 @@ export async function getInitialState(): Promise<{
getAppVersionInfo(); getAppVersionInfo();
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) { if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
const [userInfo, hasKubernetesCluster] = await Promise.all([ const userInfo = await fetchUserInfo();
fetchUserInfo(),
probeHasKubernetesCluster()
]);
checkDefaultPage(userInfo); checkDefaultPage(userInfo);
return { return {
fetchUserInfo, fetchUserInfo,
currentUser: userInfo, currentUser: userInfo,
pluginData, pluginData
hasKubernetesCluster
}; };
} }
return { return {
-6
View File
@@ -63,12 +63,6 @@ export const fromClusterCreationAtom = atom(false);
export const clusterSessionAtom = atom<{ export const clusterSessionAtom = atom<{
firstAddWorker: boolean; firstAddWorker: boolean;
firstAddCluster: boolean; firstAddCluster: boolean;
// Provider to preselect when the create flow opens — set by the
// empty-state CTA on feature pages that need a specific provider
// (e.g. GPU Service can only schedule on Kubernetes, so its
// "Add Cluster" button skips provider catalog and lands on the
// K8s configure step). Consumed once by ClusterCreate on mount.
providerHint?: string;
} | null>(null); } | null>(null);
export const clusterDetailAtom = atom<ClusterListItem | null>(null); export const clusterDetailAtom = atom<ClusterListItem | null>(null);
+10
View File
@@ -0,0 +1,10 @@
import { ClusterListItem } from '@/pages/cluster-management/config/types';
import { atom } from 'jotai';
export const currentClusterAtom = atom<
(Partial<ClusterListItem> & { label?: string; value?: number }) | null
>(null);
export const addSSHKeyPageAtom = atom<{ create: boolean }>({
create: false
});
-24
View File
@@ -3,15 +3,6 @@ import { atomWithStorage } from 'jotai/utils';
export const userAtom = atomWithStorage<any>('userInfo', null); export const userAtom = atomWithStorage<any>('userInfo', null);
// Backs the `currentOrganizationId` localStorage key. Stays null in
// builds with no Org context (single-tenant), and is shared with any
// extension that persists the same key so both sides stay in sync
// without one side having to import from the other.
export const currentOrganizationIdAtom = atomWithStorage<number | null>(
'currentOrganizationId',
null
);
export const GPUStackVersionAtom = atom<{ export const GPUStackVersionAtom = atom<{
version: string; version: string;
git_commit: string; git_commit: string;
@@ -110,18 +101,3 @@ const lookupOrgNamespace = (id: number | null): string | null => {
} }
return null; return null;
}; };
export const getAllOrganizations = (): Array<{ id: number; name?: string }> => {
const allOrganizations = localStorage.getItem('allOrganizations');
if (!allOrganizations) return [];
try {
const list = JSON.parse(allOrganizations) as Array<{
id: number;
name?: string;
}>;
if (!Array.isArray(list)) return [];
return list;
} catch {
return [];
}
};
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
-6
View File
@@ -92,12 +92,6 @@ declare namespace Global {
interface InitialStateType { interface InitialStateType {
fetchUserInfo: () => Promise<UserInfo>; fetchUserInfo: () => Promise<UserInfo>;
currentUser?: UserInfo; currentUser?: UserInfo;
// Captured at app boot so access predicates can gate GPU Service —
// the feature is Kubernetes-only, and Org members without a K8s
// cluster they can schedule on shouldn't see the menu. Refreshed
// by full page reload (e.g. OrgSwitcher) which re-runs
// getInitialState.
hasKubernetesCluster?: boolean;
} }
type SearchParams = Pagination & { search?: string; [key: string]: any }; type SearchParams = Pagination & { search?: string; [key: string]: any };
+2 -4
View File
@@ -54,8 +54,7 @@ export default {
itemHoverColor: 'rgba(0,0,0,1)', itemHoverColor: 'rgba(0,0,0,1)',
itemColor: 'rgba(0,0,0,1)', itemColor: 'rgba(0,0,0,1)',
itemHoverBg: 'rgb(24 25 27)', itemHoverBg: 'rgb(24 25 27)',
itemActiveBg: 'rgb(24 25 27)', itemActiveBg: 'rgb(24 25 27)'
menuItemSelectedBg: '#292929'
}, },
Progress: { Progress: {
lineBorderRadius: 4 lineBorderRadius: 4
@@ -109,7 +108,6 @@ export default {
fontSize: 14, fontSize: 14,
motion: true, motion: true,
colorFill: '#0A0A0A', colorFill: '#0A0A0A',
colorBgBase: '#0A0A0A', colorBgBase: '#0A0A0A'
menuItemSelectedBg: '#292929'
} }
}; };
+1 -2
View File
@@ -56,8 +56,7 @@ export default {
itemHoverColor: 'rgba(0,0,0,1)', itemHoverColor: 'rgba(0,0,0,1)',
itemColor: 'rgba(0,0,0,1)', itemColor: 'rgba(0,0,0,1)',
itemHoverBg: 'rgba(0,0,0,0.04)', itemHoverBg: 'rgba(0,0,0,0.04)',
itemActiveBg: 'rgba(0,0,0,0.04)', itemActiveBg: 'rgba(0,0,0,0.04)'
menuItemSelectedBg: '#e8eaed'
}, },
Progress: { Progress: {
lineBorderRadius: 3 lineBorderRadius: 3
+1 -2
View File
@@ -1,7 +1,6 @@
import { routeCacheAtom, setRouteCache } from '@/atoms/route-cache'; import { routeCacheAtom, setRouteCache } from '@/atoms/route-cache';
import { userAtom } from '@/atoms/user'; import { userAtom } from '@/atoms/user';
import DarkMask from '@/components/dark-mask'; import DarkMask from '@/components/dark-mask';
import '@/components/iconfont/iconfont.js';
import PluginExtraFields from '@/components/plugin-extra-fields'; import PluginExtraFields from '@/components/plugin-extra-fields';
import routeCachekey from '@/config/route-cachekey'; import routeCachekey from '@/config/route-cachekey';
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings'; import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
@@ -357,7 +356,7 @@ export default (props: any) => {
config={{ config={{
apiBaseUrl: GPUSTACK_API_BASE_URL, apiBaseUrl: GPUSTACK_API_BASE_URL,
theme: userSettings.theme, theme: userSettings.theme,
iconUrl: '', iconUrl: '//at.alicdn.com/t/c/font_4613488_jmdepcs90im.js',
isDarkTheme: userSettings.isDarkTheme, isDarkTheme: userSettings.isDarkTheme,
defaultColorPrimary: COLOR_PRIMARY defaultColorPrimary: COLOR_PRIMARY
}} }}
+101 -104
View File
@@ -2,7 +2,7 @@ import { CaretDownOutlined } from '@ant-design/icons';
import { IconFont } from '@gpustack/core-ui'; import { IconFont } from '@gpustack/core-ui';
import { Link, useLocation } from '@umijs/max'; import { Link, useLocation } from '@umijs/max';
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
import { createStyles, type FullToken } from 'antd-style'; import { createStyles } from 'antd-style';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
interface MenuItem { interface MenuItem {
@@ -20,120 +20,117 @@ interface SiderMenuProps {
initialState: Global.InitialStateType; initialState: Global.InitialStateType;
} }
const useStyles = createStyles( const useStyles = createStyles(({ css, token }) => {
({ css, token }: { css: any; token: FullToken }) => { console.log('useStyles', token);
console.log('useStyles', token);
// @ts-ignore // @ts-ignore
const { Menu } = token; const { Menu } = token;
return { return {
siderMenu: css` siderMenu: css`
&.sider-menu-collapsed { &.sider-menu-collapsed {
.menu-item { .menu-item {
justify-content: center; justify-content: center;
padding: 0; padding: 0;
}
}
`,
groupTitle: css`
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
white-space: nowrap;
padding: var(--ant-padding-xs) var(--ant-padding);
font-size: 12px;
padding-bottom: 4px;
overflow: hidden;
height: 30px;
&:hover {
.group-title-text {
color: var(--ant-color-text);
}
}
.anticon {
transform: scale(0.8);
} }
}
`,
groupTitle: css`
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
white-space: nowrap;
padding: var(--ant-padding-xs) var(--ant-padding);
font-size: 12px;
padding-bottom: 4px;
overflow: hidden;
height: 30px;
&:hover {
.group-title-text { .group-title-text {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--ant-color-text); color: var(--ant-color-text);
font-weight: 400;
} }
}
&.menu-item-group-title-collapsed { .anticon {
position: relative; transform: scale(0.8);
height: 1px; }
padding-block: 0; .group-title-text {
padding-inline: 0;
justify-content: center;
}
`,
menuItemContent: css`
margin: 2px 0;
border-radius: 4px;
overflow: hidden;
`,
menuItemWrapper: css`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; gap: 4px;
gap: 12px; font-size: 12px;
cursor: pointer;
position: relative;
padding-inline: calc(var(--ant-font-size) * 2) var(--ant-padding);
padding-left: 16px;
overflow: hidden;
white-space: nowrap;
height: ${Menu.itemHeight}px;
line-height: ${Menu.itemHeight}px;
color: var(--ant-color-text-tertiary); color: var(--ant-color-text-tertiary);
&:hover { font-weight: 400;
background-color: ${Menu.itemHoverBg}; }
color: ${Menu.itemHoverColor};
}
&.menu-item-selected {
background-color: ${Menu.menuItemSelectedBg};
color: ${Menu.itemSelectedColor};
.anticon { &.menu-item-group-title-collapsed {
color: ${Menu.itemSelectedColor}; position: relative;
}
}
&:active {
background-color: ${Menu.itemActiveBg};
color: ${Menu.itemActiveColor};
}
.anticon {
font-size: 16px;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
`,
menuItemGroup: css`
&.menu-item-group-hidden {
display: none;
}
`,
line: css`
height: 1px; height: 1px;
margin-block: 6px; padding-block: 0;
background-color: ${token.colorSplit}; padding-inline: 0;
position: absolute; justify-content: center;
left: -2px; }
right: -2px; `,
` menuItemContent: css`
}; margin: 2px 0;
} border-radius: 4px;
); overflow: hidden;
`,
menuItemWrapper: css`
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
cursor: pointer;
position: relative;
padding-inline: calc(var(--ant-font-size) * 2) var(--ant-padding);
padding-left: 16px;
overflow: hidden;
white-space: nowrap;
height: ${Menu.itemHeight}px;
line-height: ${Menu.itemHeight}px;
color: var(--ant-color-text-tertiary);
&:hover {
background-color: ${Menu.itemHoverBg};
color: ${Menu.itemHoverColor};
}
&.menu-item-selected {
background-color: ${Menu.itemSelectedBg};
color: ${Menu.itemSelectedColor};
.anticon {
color: ${Menu.itemSelectedColor};
}
}
&:active {
background-color: ${Menu.itemActiveBg};
color: ${Menu.itemActiveColor};
}
.anticon {
font-size: 16px;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
`,
menuItemGroup: css`
&.menu-item-group-hidden {
display: none;
}
`,
line: css`
height: 1px;
margin-block: 6px;
background-color: ${token.colorSplit};
position: absolute;
left: -2px;
right: -2px;
`
};
});
const SiderMenu: React.FC<SiderMenuProps> = (props) => { const SiderMenu: React.FC<SiderMenuProps> = (props) => {
const { menuData, collapsed } = props; const { menuData, collapsed } = props;
+14 -15
View File
@@ -70,6 +70,8 @@ export default {
'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.', 'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.',
'clusters.addworker.selectGPU': 'Select GPU Vendor', 'clusters.addworker.selectGPU': 'Select GPU Vendor',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.singleOnly':
'The selected vendor is not in this clusters GPU vendor overrides — single-select only. Pick a vendor configured in the overrides to enable multi-select.',
'clusters.addworker.checkEnv': 'Check Environment', 'clusters.addworker.checkEnv': 'Check Environment',
'clusters.addworker.specifyArgs': 'Specify Arguments', 'clusters.addworker.specifyArgs': 'Specify Arguments',
'clusters.addworker.runCommand': 'Run Command', 'clusters.addworker.runCommand': 'Run Command',
@@ -162,10 +164,6 @@ export default {
'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.name': 'ConfigMap Name',
'clusters.volume.configMap.optional': 'Optional', 'clusters.volume.configMap.optional': 'Optional',
'clusters.volume.add': 'Add Volume Mount', 'clusters.volume.add': 'Add Volume Mount',
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials', 'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -174,15 +172,16 @@ export default {
'clusters.nodeSelector.title': 'Node Selector', 'clusters.nodeSelector.title': 'Node Selector',
'clusters.nodeSelector.tip': 'clusters.nodeSelector.tip':
'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', 'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.',
'clusters.operatorImage.title': 'Operator Image', 'clusters.gpuVendorOverrides.title': 'GPU Vendor Overrides',
'clusters.operatorImage.tip': 'clusters.gpuVendorOverrides.validate.emptySelector':
'Override for the GPUStack Operator container image. Leave empty to use the server default.', 'The override for {vendor} needs at least one nodeSelector entry — an empty override would leave the multi-vendor manifest unable to target nodes for this runtime.',
'clusters.namespace.title': 'Namespace', 'clusters.gpuVendorOverrides.validate.duplicate':
'clusters.namespace.tip': 'Overrides for {v1} and {v2} use the same nodeSelector. Each vendor must scope to a distinct node set, otherwise their worker DaemonSets would compete for the same nodes.',
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.', 'clusters.gpuVendorOverrides.validate.keyConflict':
'clusters.gpuInstances.title': 'GPU Instances', 'Override for {vendor} reuses key(s) {keys} from the base Node Selector — the CPU worker would simultaneously require and forbid the key and never schedule.',
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.', 'clusters.gpuVendorOverrides.tip':
'clusters.gpuInstances.staticAddress': 'Static Access Address', 'Required when running multiple GPU vendors in one cluster. Each entry pins that vendors worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.',
'clusters.gpuInstances.staticAddress.tip': 'clusters.gpuVendorOverrides.add': 'Add Override',
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.' 'clusters.gpuVendorOverrides.vendor': 'GPU Vendor',
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
}; };
+3 -3
View File
@@ -109,7 +109,7 @@ export default {
'CPU-only setup for environment preparation.', 'CPU-only setup for environment preparation.',
'gpuservice.instance.stock': 'Stock', 'gpuservice.instance.stock': 'Stock',
'gpuservice.instance.sliced': 'Sliced', 'gpuservice.instance.sliced': 'Sliced',
'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.memory': 'Memory',
'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.disk': 'Disk', 'gpuservice.instance.disk': 'Disk',
'gpuservice.instance.search.type.placeholder': 'Search by name', 'gpuservice.instance.search.type.placeholder': 'Search by name',
@@ -142,8 +142,8 @@ export default {
'gpuservice.storage.capacity': 'Capacity', 'gpuservice.storage.capacity': 'Capacity',
'gpuservice.storage.accessMode': 'Access Mode', 'gpuservice.storage.accessMode': 'Access Mode',
'gpuservice.storage.persistent': 'Persistent', 'gpuservice.storage.persistent': 'Persistent',
'gpuservice.storage.temporary': 'Ephemeral', 'gpuservice.storage.temporary': 'Temporary',
'gpuservice.storage.persistentVolume': 'Persistent', 'gpuservice.storage.persistentVolume': 'Persistent Volume',
'gpuservice.storage.temporary.tips': 'gpuservice.storage.temporary.tips':
'Data is cleared when the instance stops.', 'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips': 'gpuservice.storage.persistentVolume.tips':
+3 -3
View File
@@ -8,7 +8,7 @@ export default {
'menu.playground.text2images': 'Image', 'menu.playground.text2images': 'Image',
'menu.playground.video': 'Video', 'menu.playground.video': 'Video',
'menu.compare': 'Compare', 'menu.compare': 'Compare',
'menu.models': 'Model Service', 'menu.models': 'Models',
'menu.models.modelList': 'Deploy & Manage', 'menu.models.modelList': 'Deploy & Manage',
'menu.models.modelCatalog': 'Catalog', 'menu.models.modelCatalog': 'Catalog',
'menu.models.catalog': 'Model Catalog', 'menu.models.catalog': 'Model Catalog',
@@ -27,7 +27,7 @@ export default {
'menu.users': 'Users', 'menu.users': 'Users',
'menu.resources.workers': 'Workers', 'menu.resources.workers': 'Workers',
'menu.resources.gpus': 'GPUs', 'menu.resources.gpus': 'GPUs',
'menu.models.modelfiles': 'Model Files', 'menu.resources.modelfiles': 'Model Files',
'menu.accessControl': 'Access Control', 'menu.accessControl': 'Access Control',
'menu.accessControl.apikeys': 'API Keys', 'menu.accessControl.apikeys': 'API Keys',
'menu.accessControl.users': 'Users', 'menu.accessControl.users': 'Users',
@@ -40,7 +40,7 @@ export default {
'menu.clusterManagement.credentials': 'Cloud Credentials', 'menu.clusterManagement.credentials': 'Cloud Credentials',
'menu.clusterManagement.clusterDetail': 'Cluster Detail', 'menu.clusterManagement.clusterDetail': 'Cluster Detail',
'menu.clusterManagement.clusterCreate': 'Create Cluster', 'menu.clusterManagement.clusterCreate': 'Create Cluster',
'menu.models.backendsList': 'Inference Backends', 'menu.resources.backendsList': 'Inference Backends',
'menu.gpuService': 'GPU Service', 'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances', 'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates', 'menu.gpuService.templates': 'Instance Templates',
-3
View File
@@ -36,12 +36,9 @@ export default {
'noresult.catalog.nofound': 'No matching models found.', 'noresult.catalog.nofound': 'No matching models found.',
'noresult.resources.cluster': 'noresult.resources.cluster':
'No clusters available. Add a cluster to get started.', 'No clusters available. Add a cluster to get started.',
'noresult.resources.k8sCluster':
'No clusters available. Add a Kubernetes cluster to get started.',
'noresult.resources.worker': 'noresult.resources.worker':
'No workers available. Add a worker to get started.', 'No workers available. Add a worker to get started.',
'noresult.resources.gotocluster': 'Create Your First Cluster', 'noresult.resources.gotocluster': 'Create Your First Cluster',
'noresult.resources.addk8scluster': 'Add a Kubernetes Cluster',
'noresult.resources.gotoworker': 'Add Worker', 'noresult.resources.gotoworker': 'Add Worker',
'noresult.benchmark.title': 'No Benchmarks', 'noresult.benchmark.title': 'No Benchmarks',
'noresult.benchmark.subTitle': 'No benchmarks have been added yet.', 'noresult.benchmark.subTitle': 'No benchmarks have been added yet.',
+14 -15
View File
@@ -70,6 +70,8 @@ export default {
'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.', 'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.',
'clusters.addworker.selectGPU': 'Select GPU Vendor', 'clusters.addworker.selectGPU': 'Select GPU Vendor',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.singleOnly':
'The selected vendor is not in this clusters GPU vendor overrides — single-select only. Pick a vendor configured in the overrides to enable multi-select.',
'clusters.addworker.checkEnv': 'Check Environment', 'clusters.addworker.checkEnv': 'Check Environment',
'clusters.addworker.specifyArgs': 'Specify Arguments', 'clusters.addworker.specifyArgs': 'Specify Arguments',
'clusters.addworker.runCommand': 'Run Command', 'clusters.addworker.runCommand': 'Run Command',
@@ -162,10 +164,6 @@ export default {
'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.name': 'ConfigMap Name',
'clusters.volume.configMap.optional': 'Optional', 'clusters.volume.configMap.optional': 'Optional',
'clusters.volume.add': 'Add Volume Mount', 'clusters.volume.add': 'Add Volume Mount',
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials', 'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -174,17 +172,18 @@ export default {
'clusters.nodeSelector.title': 'Node Selector', 'clusters.nodeSelector.title': 'Node Selector',
'clusters.nodeSelector.tip': 'clusters.nodeSelector.tip':
'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', 'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.',
'clusters.operatorImage.title': 'Operator Image', 'clusters.gpuVendorOverrides.title': 'GPU Vendor Overrides',
'clusters.operatorImage.tip': 'clusters.gpuVendorOverrides.validate.emptySelector':
'Override for the GPUStack Operator container image. Leave empty to use the server default.', 'The override for {vendor} needs at least one nodeSelector entry — an empty override would leave the multi-vendor manifest unable to target nodes for this runtime.',
'clusters.namespace.title': 'Namespace', 'clusters.gpuVendorOverrides.validate.duplicate':
'clusters.namespace.tip': 'Overrides for {v1} and {v2} use the same nodeSelector. Each vendor must scope to a distinct node set, otherwise their worker DaemonSets would compete for the same nodes.',
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.', 'clusters.gpuVendorOverrides.validate.keyConflict':
'clusters.gpuInstances.title': 'GPU Instances', 'Override for {vendor} reuses key(s) {keys} from the base Node Selector — the CPU worker would simultaneously require and forbid the key and never schedule.',
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.', 'clusters.gpuVendorOverrides.tip':
'clusters.gpuInstances.staticAddress': 'Static Access Address', 'Required when running multiple GPU vendors in one cluster. Each entry pins that vendors worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.',
'clusters.gpuInstances.staticAddress.tip': 'clusters.gpuVendorOverrides.add': 'Add Override',
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.' 'clusters.gpuVendorOverrides.vendor': 'GPU Vendor',
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
}; };
// ========== To-Do: Translate Keys (Remove After Translation) ========== // ========== To-Do: Translate Keys (Remove After Translation) ==========
+2 -2
View File
@@ -107,7 +107,7 @@ export default {
'gpuservice.instance.gpuCount.zero': 'CPU のみを使用し、環境準備用です。', 'gpuservice.instance.gpuCount.zero': 'CPU のみを使用し、環境準備用です。',
'gpuservice.instance.stock': '在庫', 'gpuservice.instance.stock': '在庫',
'gpuservice.instance.sliced': '分割', 'gpuservice.instance.sliced': '分割',
'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.memory': 'Memory',
'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.disk': 'ディスク', 'gpuservice.instance.disk': 'ディスク',
'gpuservice.instance.search.type.placeholder': '名前で検索', 'gpuservice.instance.search.type.placeholder': '名前で検索',
@@ -141,7 +141,7 @@ export default {
'gpuservice.storage.accessMode': 'アクセスモード', 'gpuservice.storage.accessMode': 'アクセスモード',
'gpuservice.storage.persistent': '永続', 'gpuservice.storage.persistent': '永続',
'gpuservice.storage.temporary': '一時', 'gpuservice.storage.temporary': '一時',
'gpuservice.storage.persistentVolume': '永続', 'gpuservice.storage.persistentVolume': '永続ボリューム',
'gpuservice.storage.persistentVolume.required': 'gpuservice.storage.persistentVolume.required':
'永続ボリュームを選択してください', '永続ボリュームを選択してください',
'gpuservice.storage.persistentVolume.capacity': '容量 (GB)', 'gpuservice.storage.persistentVolume.capacity': '容量 (GB)',
+5 -5
View File
@@ -8,7 +8,7 @@ export default {
'menu.playground.text2images': '画像生成', 'menu.playground.text2images': '画像生成',
'menu.playground.video': '動画', 'menu.playground.video': '動画',
'menu.compare': '比較', 'menu.compare': '比較',
'menu.models': 'モデルサービス', 'menu.models': 'モデル',
'menu.models.modelList': 'デプロイと管理', 'menu.models.modelList': 'デプロイと管理',
'menu.models.modelCatalog': 'カタログ', 'menu.models.modelCatalog': 'カタログ',
'menu.models.catalog': 'モデルカタログ', 'menu.models.catalog': 'モデルカタログ',
@@ -30,7 +30,7 @@ export default {
'menu.settings': 'Settings', 'menu.settings': 'Settings',
'menu.resources.workers': 'Workers', 'menu.resources.workers': 'Workers',
'menu.resources.gpus': 'GPUs', 'menu.resources.gpus': 'GPUs',
'menu.models.modelfiles': 'Model Files', 'menu.resources.modelfiles': 'Model Files',
'menu.accessControl': 'Access Control', 'menu.accessControl': 'Access Control',
'menu.accessControl.apikeys': 'API Keys', 'menu.accessControl.apikeys': 'API Keys',
'menu.accessControl.users': 'Users', 'menu.accessControl.users': 'Users',
@@ -40,7 +40,7 @@ export default {
'menu.models.userModels': 'My Models', 'menu.models.userModels': 'My Models',
'menu.clusterManagement.clusterDetail': 'Cluster Detail', 'menu.clusterManagement.clusterDetail': 'Cluster Detail',
'menu.clusterManagement.clusterCreate': 'Create Cluster', 'menu.clusterManagement.clusterCreate': 'Create Cluster',
'menu.models.backendsList': 'Inference Backends', 'menu.resources.backendsList': 'Inference Backends',
'menu.gpuService': 'GPU Service', 'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances', 'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates', 'menu.gpuService.templates': 'Instance Templates',
@@ -53,7 +53,7 @@ export default {
// 1. 'menu.models.deployment': 'Deployment', // 1. 'menu.models.deployment': 'Deployment',
// 2. 'menu.resources.workers': 'Workers', // 2. 'menu.resources.workers': 'Workers',
// 3. 'menu.resources.gpus': 'GPUs', // 3. 'menu.resources.gpus': 'GPUs',
// 4. 'menu.models.modelfiles': 'Model Files', // 4. 'menu.resources.modelfiles': 'Model Files',
// 5. 'menu.accessControl': 'Access Control', // 5. 'menu.accessControl': 'Access Control',
// 6. 'menu.accessControl.apikeys': 'API Keys', // 6. 'menu.accessControl.apikeys': 'API Keys',
// 7. 'menu.accessControl.users': 'Users', // 7. 'menu.accessControl.users': 'Users',
@@ -63,7 +63,7 @@ export default {
// 11. 'menu.models.userModels': 'My Models' // 11. 'menu.models.userModels': 'My Models'
// 12. 'menu.clusterManagement.clusterDetail': 'Cluster Detail', // 12. 'menu.clusterManagement.clusterDetail': 'Cluster Detail',
// 13. 'menu.clusterManagement.clusterCreate': 'Create Cluster', // 13. 'menu.clusterManagement.clusterCreate': 'Create Cluster',
// 14. 'menu.models.backendsList': 'Inference Backends', // 14. 'menu.resources.backendsList': 'Inference Backends',
// 15. 'menu.models.benchmark': 'Benchmarks', // 15. 'menu.models.benchmark': 'Benchmarks',
// 15. 'menu.models.provider': 'Provider', // 15. 'menu.models.provider': 'Provider',
// 15. 'menu.models.providers': 'Provider', // 15. 'menu.models.providers': 'Provider',
-3
View File
@@ -36,12 +36,9 @@ export default {
'noresult.catalog.nofound': 'No matching models found.', 'noresult.catalog.nofound': 'No matching models found.',
'noresult.resources.cluster': 'noresult.resources.cluster':
'No clusters available. Add a cluster to get started.', 'No clusters available. Add a cluster to get started.',
'noresult.resources.k8sCluster':
'No clusters available. Add a Kubernetes cluster to get started.',
'noresult.resources.worker': 'noresult.resources.worker':
'No workers available. Add a worker to get started.', 'No workers available. Add a worker to get started.',
'noresult.resources.gotocluster': 'Create Your First Cluster', 'noresult.resources.gotocluster': 'Create Your First Cluster',
'noresult.resources.addk8scluster': 'Add a Kubernetes Cluster',
'noresult.resources.gotoworker': 'Add Worker', 'noresult.resources.gotoworker': 'Add Worker',
'noresult.benchmark.title': 'No Benchmarks', 'noresult.benchmark.title': 'No Benchmarks',
'noresult.benchmark.subTitle': 'No benchmarks have been added yet.', 'noresult.benchmark.subTitle': 'No benchmarks have been added yet.',
+14 -15
View File
@@ -70,6 +70,8 @@ export default {
'Для <span class="bold-text">не-Docker</span> кластеров, пожалуйста, регистрируйте кластеры или управляйте пулами воркеров на странице Кластеры.', 'Для <span class="bold-text">не-Docker</span> кластеров, пожалуйста, регистрируйте кластеры или управляйте пулами воркеров на странице Кластеры.',
'clusters.addworker.selectGPU': 'Выбрать производителя GPU', 'clusters.addworker.selectGPU': 'Выбрать производителя GPU',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.singleOnly':
'The selected vendor is not in this clusters GPU vendor overrides — single-select only. Pick a vendor configured in the overrides to enable multi-select.',
'clusters.addworker.checkEnv': 'Проверить окружение', 'clusters.addworker.checkEnv': 'Проверить окружение',
'clusters.addworker.specifyArgs': 'Указать аргументы', 'clusters.addworker.specifyArgs': 'Указать аргументы',
'clusters.addworker.runCommand': 'Выполнить команду', 'clusters.addworker.runCommand': 'Выполнить команду',
@@ -163,10 +165,6 @@ export default {
'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.name': 'ConfigMap Name',
'clusters.volume.configMap.optional': 'Optional', 'clusters.volume.configMap.optional': 'Optional',
'clusters.volume.add': 'Add Volume Mount', 'clusters.volume.add': 'Add Volume Mount',
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials', 'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -175,17 +173,18 @@ export default {
'clusters.nodeSelector.title': 'Node Selector', 'clusters.nodeSelector.title': 'Node Selector',
'clusters.nodeSelector.tip': 'clusters.nodeSelector.tip':
'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', 'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.',
'clusters.operatorImage.title': 'Operator Image', 'clusters.gpuVendorOverrides.title': 'GPU Vendor Overrides',
'clusters.operatorImage.tip': 'clusters.gpuVendorOverrides.validate.emptySelector':
'Override for the GPUStack Operator container image. Leave empty to use the server default.', 'The override for {vendor} needs at least one nodeSelector entry — an empty override would leave the multi-vendor manifest unable to target nodes for this runtime.',
'clusters.namespace.title': 'Namespace', 'clusters.gpuVendorOverrides.validate.duplicate':
'clusters.namespace.tip': 'Overrides for {v1} and {v2} use the same nodeSelector. Each vendor must scope to a distinct node set, otherwise their worker DaemonSets would compete for the same nodes.',
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.', 'clusters.gpuVendorOverrides.validate.keyConflict':
'clusters.gpuInstances.title': 'GPU Instances', 'Override for {vendor} reuses key(s) {keys} from the base Node Selector — the CPU worker would simultaneously require and forbid the key and never schedule.',
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.', 'clusters.gpuVendorOverrides.tip':
'clusters.gpuInstances.staticAddress': 'Static Access Address', 'Required when running multiple GPU vendors in one cluster. Each entry pins that vendors worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.',
'clusters.gpuInstances.staticAddress.tip': 'clusters.gpuVendorOverrides.add': 'Add Override',
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.' 'clusters.gpuVendorOverrides.vendor': 'GPU Vendor',
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
}; };
// ========== To-Do: Translate Keys (Remove After Translation) ========== // ========== To-Do: Translate Keys (Remove After Translation) ==========
+2 -2
View File
@@ -109,7 +109,7 @@ export default {
'gpuservice.instance.gpuCount.zero': 'Только CPU, для подготовки окружения.', 'gpuservice.instance.gpuCount.zero': 'Только CPU, для подготовки окружения.',
'gpuservice.instance.stock': 'Остаток', 'gpuservice.instance.stock': 'Остаток',
'gpuservice.instance.sliced': 'Разделено', 'gpuservice.instance.sliced': 'Разделено',
'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.memory': 'Память',
'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.disk': 'Диск', 'gpuservice.instance.disk': 'Диск',
'gpuservice.instance.search.type.placeholder': 'Поиск по имени', 'gpuservice.instance.search.type.placeholder': 'Поиск по имени',
@@ -143,7 +143,7 @@ export default {
'gpuservice.storage.accessMode': 'Режим доступа', 'gpuservice.storage.accessMode': 'Режим доступа',
'gpuservice.storage.persistent': 'Постоянное', 'gpuservice.storage.persistent': 'Постоянное',
'gpuservice.storage.temporary': 'Временное', 'gpuservice.storage.temporary': 'Временное',
'gpuservice.storage.persistentVolume': 'Постоянное', 'gpuservice.storage.persistentVolume': 'Постоянный том',
'gpuservice.storage.persistentVolume.required': 'Выберите постоянный том', 'gpuservice.storage.persistentVolume.required': 'Выберите постоянный том',
'gpuservice.storage.persistentVolume.capacity': 'Ёмкость (ГБ)', 'gpuservice.storage.persistentVolume.capacity': 'Ёмкость (ГБ)',
'gpuservice.storage.persistentVolume.capacity.required': 'Введите ёмкость', 'gpuservice.storage.persistentVolume.capacity.required': 'Введите ёмкость',
+3 -3
View File
@@ -8,7 +8,7 @@ export default {
'menu.playground.text2images': 'Генерация изображений', 'menu.playground.text2images': 'Генерация изображений',
'menu.playground.video': 'Видео', 'menu.playground.video': 'Видео',
'menu.compare': 'Сравнение', 'menu.compare': 'Сравнение',
'menu.models': 'Сервисы моделей', 'menu.models': 'Модели',
'menu.models.modelList': 'Развертывание и управление', 'menu.models.modelList': 'Развертывание и управление',
'menu.models.modelCatalog': 'Каталог', 'menu.models.modelCatalog': 'Каталог',
'menu.models.catalog': 'Каталог моделей', 'menu.models.catalog': 'Каталог моделей',
@@ -29,7 +29,7 @@ export default {
'menu.404': 'Ошибка 404', 'menu.404': 'Ошибка 404',
'menu.resources.workers': 'Воркеры', 'menu.resources.workers': 'Воркеры',
'menu.resources.gpus': 'GPUs', 'menu.resources.gpus': 'GPUs',
'menu.models.modelfiles': 'Файлы модлей', 'menu.resources.modelfiles': 'Файлы модлей',
'menu.accessControl': 'Управление доступом', 'menu.accessControl': 'Управление доступом',
'menu.accessControl.apikeys': 'API Ключи', 'menu.accessControl.apikeys': 'API Ключи',
'menu.accessControl.users': 'Пользователи', 'menu.accessControl.users': 'Пользователи',
@@ -39,7 +39,7 @@ export default {
'menu.models.userModels': 'Мои модели', 'menu.models.userModels': 'Мои модели',
'menu.clusterManagement.clusterDetail': 'Детали кластера', 'menu.clusterManagement.clusterDetail': 'Детали кластера',
'menu.clusterManagement.clusterCreate': 'Создать кластер', 'menu.clusterManagement.clusterCreate': 'Создать кластер',
'menu.models.backendsList': 'Бэкенды запуска', 'menu.resources.backendsList': 'Бэкенды запуска',
'menu.settings': 'Settings', 'menu.settings': 'Settings',
'menu.gpuService': 'GPU Service', 'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances', 'menu.gpuService.instances': 'GPU Instances',
+2 -4
View File
@@ -37,12 +37,9 @@ export default {
'noresult.catalog.nofound': 'Подходящие модели не найдены.', 'noresult.catalog.nofound': 'Подходящие модели не найдены.',
'noresult.resources.cluster': 'noresult.resources.cluster':
'No clusters available. Add a cluster to get started.', 'No clusters available. Add a cluster to get started.',
'noresult.resources.k8sCluster':
'No clusters available. Add a Kubernetes cluster to get started.',
'noresult.resources.worker': 'noresult.resources.worker':
'No workers available. Add a worker to get started.', 'No workers available. Add a worker to get started.',
'noresult.resources.gotocluster': 'Create Your First Cluster', 'noresult.resources.gotocluster': 'Create Your First Cluster',
'noresult.resources.addk8scluster': 'Add a Kubernetes Cluster',
'noresult.resources.gotoworker': 'Add Worker', 'noresult.resources.gotoworker': 'Add Worker',
'noresult.benchmark.title': 'No Benchmarks', 'noresult.benchmark.title': 'No Benchmarks',
'noresult.benchmark.subTitle': 'No benchmarks have been added yet.', 'noresult.benchmark.subTitle': 'No benchmarks have been added yet.',
@@ -70,7 +67,8 @@ export default {
'noresult.gpuservice.storage.subTitle': 'Хранилища ещё не добавлены.', 'noresult.gpuservice.storage.subTitle': 'Хранилища ещё не добавлены.',
'noresult.gpuservice.storage.nofound': 'Подходящие хранилища не найдены.', 'noresult.gpuservice.storage.nofound': 'Подходящие хранилища не найдены.',
'noresult.gpuservice.storageType.title': 'Нет типов хранилищ', 'noresult.gpuservice.storageType.title': 'Нет типов хранилищ',
'noresult.gpuservice.storageType.subTitle': 'Типы хранилищ ещё не добавлены.', 'noresult.gpuservice.storageType.subTitle':
'Типы хранилищ ещё не добавлены.',
'noresult.gpuservice.storageType.nofound': 'noresult.gpuservice.storageType.nofound':
'Подходящие типы хранилищ не найдены.', 'Подходящие типы хранилищ не найдены.',
'noresult.gpuservice.sshkey.title': 'Нет открытых ключей SSH', 'noresult.gpuservice.sshkey.title': 'Нет открытых ключей SSH',
+14 -15
View File
@@ -70,6 +70,8 @@ export default {
'<span class="bold-text">Docker dışı</span> kümeler için lütfen Kümeler sayfasından küme kaydı oluşturun veya işçi havuzlarını yönetin.', '<span class="bold-text">Docker dışı</span> kümeler için lütfen Kümeler sayfasından küme kaydı oluşturun veya işçi havuzlarını yönetin.',
'clusters.addworker.selectGPU': 'GPU Üreticisi Seç', 'clusters.addworker.selectGPU': 'GPU Üreticisi Seç',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.singleOnly':
'The selected vendor is not in this clusters GPU vendor overrides — single-select only. Pick a vendor configured in the overrides to enable multi-select.',
'clusters.addworker.checkEnv': 'Ortamı Kontrol Et', 'clusters.addworker.checkEnv': 'Ortamı Kontrol Et',
'clusters.addworker.specifyArgs': 'Argümanları Belirle', 'clusters.addworker.specifyArgs': 'Argümanları Belirle',
'clusters.addworker.runCommand': 'Komutu Çalıştır', 'clusters.addworker.runCommand': 'Komutu Çalıştır',
@@ -163,10 +165,6 @@ export default {
'clusters.volume.configMap.name': 'ConfigMap Name', 'clusters.volume.configMap.name': 'ConfigMap Name',
'clusters.volume.configMap.optional': 'Optional', 'clusters.volume.configMap.optional': 'Optional',
'clusters.volume.add': 'Add Volume Mount', 'clusters.volume.add': 'Add Volume Mount',
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials', 'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -175,15 +173,16 @@ export default {
'clusters.nodeSelector.title': 'Node Selector', 'clusters.nodeSelector.title': 'Node Selector',
'clusters.nodeSelector.tip': 'clusters.nodeSelector.tip':
'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.', 'Pod nodeSelector applied to every worker DaemonSet — only nodes whose labels match are eligible to run the worker.',
'clusters.operatorImage.title': 'Operator Image', 'clusters.gpuVendorOverrides.title': 'GPU Vendor Overrides',
'clusters.operatorImage.tip': 'clusters.gpuVendorOverrides.validate.emptySelector':
'Override for the GPUStack Operator container image. Leave empty to use the server default.', 'The override for {vendor} needs at least one nodeSelector entry — an empty override would leave the multi-vendor manifest unable to target nodes for this runtime.',
'clusters.namespace.title': 'Namespace', 'clusters.gpuVendorOverrides.validate.duplicate':
'clusters.namespace.tip': 'Overrides for {v1} and {v2} use the same nodeSelector. Each vendor must scope to a distinct node set, otherwise their worker DaemonSets would compete for the same nodes.',
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.', 'clusters.gpuVendorOverrides.validate.keyConflict':
'clusters.gpuInstances.title': 'GPU Instances', 'Override for {vendor} reuses key(s) {keys} from the base Node Selector — the CPU worker would simultaneously require and forbid the key and never schedule.',
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.', 'clusters.gpuVendorOverrides.tip':
'clusters.gpuInstances.staticAddress': 'Static Access Address', 'Required when running multiple GPU vendors in one cluster. Each entry pins that vendors worker DaemonSet to nodes matching its nodeSelector, and the CPU worker is steered away from those nodes via a DoesNotExist node-affinity.',
'clusters.gpuInstances.staticAddress.tip': 'clusters.gpuVendorOverrides.add': 'Add Override',
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.' 'clusters.gpuVendorOverrides.vendor': 'GPU Vendor',
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
}; };
+2 -2
View File
@@ -106,7 +106,7 @@ export default {
'gpuservice.instance.gpuCount.zero': 'Yalnızca CPU, ortam hazırlığı için.', 'gpuservice.instance.gpuCount.zero': 'Yalnızca CPU, ortam hazırlığı için.',
'gpuservice.instance.stock': 'Stok', 'gpuservice.instance.stock': 'Stok',
'gpuservice.instance.sliced': 'Bölünmüş', 'gpuservice.instance.sliced': 'Bölünmüş',
'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.memory': 'Bellek',
'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.disk': 'Disk', 'gpuservice.instance.disk': 'Disk',
'gpuservice.instance.search.type.placeholder': 'Ada göre ara', 'gpuservice.instance.search.type.placeholder': 'Ada göre ara',
@@ -141,7 +141,7 @@ export default {
'gpuservice.storage.accessMode': 'Erişim Modu', 'gpuservice.storage.accessMode': 'Erişim Modu',
'gpuservice.storage.persistent': 'Kalıcı', 'gpuservice.storage.persistent': 'Kalıcı',
'gpuservice.storage.temporary': 'Geçici', 'gpuservice.storage.temporary': 'Geçici',
'gpuservice.storage.persistentVolume': 'Kalıcı', 'gpuservice.storage.persistentVolume': 'Kalıcı Hacim',
'gpuservice.storage.persistentVolume.required': 'gpuservice.storage.persistentVolume.required':
'Lütfen bir kalıcı hacim seçin', 'Lütfen bir kalıcı hacim seçin',
'gpuservice.storage.persistentVolume.capacity': 'Kapasite (GB)', 'gpuservice.storage.persistentVolume.capacity': 'Kapasite (GB)',
+3 -3
View File
@@ -8,7 +8,7 @@ export default {
'menu.playground.text2images': 'Görsel', 'menu.playground.text2images': 'Görsel',
'menu.playground.video': 'Video', 'menu.playground.video': 'Video',
'menu.compare': 'Karşılaştır', 'menu.compare': 'Karşılaştır',
'menu.models': 'Model Hizmetleri', 'menu.models': 'Modeller',
'menu.models.modelList': 'Dağıt ve Yönet', 'menu.models.modelList': 'Dağıt ve Yönet',
'menu.models.modelCatalog': 'Katalog', 'menu.models.modelCatalog': 'Katalog',
'menu.models.catalog': 'Model Kataloğu', 'menu.models.catalog': 'Model Kataloğu',
@@ -25,7 +25,7 @@ export default {
'menu.users': 'Kullanıcılar', 'menu.users': 'Kullanıcılar',
'menu.resources.workers': 'İşçi Düğümler', 'menu.resources.workers': 'İşçi Düğümler',
'menu.resources.gpus': "GPU'lar", 'menu.resources.gpus': "GPU'lar",
'menu.models.modelfiles': 'Model Dosyaları', 'menu.resources.modelfiles': 'Model Dosyaları',
'menu.accessControl': 'Erişim Kontrolü', 'menu.accessControl': 'Erişim Kontrolü',
'menu.accessControl.apikeys': 'API Anahtarları', 'menu.accessControl.apikeys': 'API Anahtarları',
'menu.accessControl.users': 'Kullanıcılar', 'menu.accessControl.users': 'Kullanıcılar',
@@ -38,7 +38,7 @@ export default {
'menu.clusterManagement.credentials': 'Bulut Kimlik Bilgileri', 'menu.clusterManagement.credentials': 'Bulut Kimlik Bilgileri',
'menu.clusterManagement.clusterDetail': 'Küme Detayı', 'menu.clusterManagement.clusterDetail': 'Küme Detayı',
'menu.clusterManagement.clusterCreate': 'Küme Oluştur', 'menu.clusterManagement.clusterCreate': 'Küme Oluştur',
'menu.models.backendsList': 'Çıkarım Altyapıları', 'menu.resources.backendsList': 'Çıkarım Altyapıları',
'menu.models.instances': 'Instances', 'menu.models.instances': 'Instances',
'menu.settings': 'Settings', 'menu.settings': 'Settings',
'menu.gpuService': 'GPU Service', 'menu.gpuService': 'GPU Service',
-3
View File
@@ -38,12 +38,9 @@ export default {
'noresult.catalog.nofound': 'Eşleşen model bulunamadı.', 'noresult.catalog.nofound': 'Eşleşen model bulunamadı.',
'noresult.resources.cluster': 'noresult.resources.cluster':
'Kullanılabilir küme yok. Başlamak için bir küme ekleyin.', 'Kullanılabilir küme yok. Başlamak için bir küme ekleyin.',
'noresult.resources.k8sCluster':
'Kullanılabilir küme yok. Başlamak için bir Kubernetes kümesi ekleyin.',
'noresult.resources.worker': 'noresult.resources.worker':
'Kullanılabilir işçi düğüm yok. Başlamak için bir işçi düğüm ekleyin.', 'Kullanılabilir işçi düğüm yok. Başlamak için bir işçi düğüm ekleyin.',
'noresult.resources.gotocluster': 'İlk Kümenizi Oluşturun', 'noresult.resources.gotocluster': 'İlk Kümenizi Oluşturun',
'noresult.resources.addk8scluster': 'Kubernetes Kümesi Ekle',
'noresult.resources.gotoworker': 'İşçi Düğüm Ekle', 'noresult.resources.gotoworker': 'İşçi Düğüm Ekle',
'noresult.benchmark.title': 'Kıyaslama Yok', 'noresult.benchmark.title': 'Kıyaslama Yok',
'noresult.benchmark.subTitle': 'Henüz kıyaslama eklenmedi.', 'noresult.benchmark.subTitle': 'Henüz kıyaslama eklenmedi.',
+14 -15
View File
@@ -68,6 +68,8 @@ export default {
'<span class="bold-text">非 Docker</span> 集群请前往集群页面注册集群或管理节点池。', '<span class="bold-text">非 Docker</span> 集群请前往集群页面注册集群或管理节点池。',
'clusters.addworker.selectGPU': '选择 GPU 厂商', 'clusters.addworker.selectGPU': '选择 GPU 厂商',
'clusters.addworker.selectGPU.multiTag': '可多选', 'clusters.addworker.selectGPU.multiTag': '可多选',
'clusters.addworker.selectGPU.singleOnly':
'当前所选厂商未在集群的 GPU 厂商覆盖(override node selector)中配置,仅支持单选。如需多选,请先选择 override 中已配置的厂商。',
'clusters.addworker.checkEnv': '检查环境', 'clusters.addworker.checkEnv': '检查环境',
'clusters.addworker.specifyArgs': '指定参数', 'clusters.addworker.specifyArgs': '指定参数',
'clusters.addworker.runCommand': '运行指令', 'clusters.addworker.runCommand': '运行指令',
@@ -155,10 +157,6 @@ export default {
'clusters.volume.configMap.name': '配置名称', 'clusters.volume.configMap.name': '配置名称',
'clusters.volume.configMap.optional': '可选', 'clusters.volume.configMap.optional': '可选',
'clusters.volume.add': '添加卷挂载', 'clusters.volume.add': '添加卷挂载',
'clusters.systemDefaultContainerRegistry.title': '默认容器镜像仓库',
'clusters.systemDefaultContainerRegistry.tip':
'用于解析该集群 GPUStack 镜像的默认镜像仓库。未设置时回退到服务端默认值。',
'clusters.k8sOptions.title': 'K8s 部署选项',
'clusters.imageCredentials.title': '镜像仓库凭证', 'clusters.imageCredentials.title': '镜像仓库凭证',
'clusters.imageCredentials.add': '添加凭证', 'clusters.imageCredentials.add': '添加凭证',
'clusters.imageCredentials.registry': '镜像仓库地址', 'clusters.imageCredentials.registry': '镜像仓库地址',
@@ -167,15 +165,16 @@ export default {
'clusters.nodeSelector.title': '节点选择器', 'clusters.nodeSelector.title': '节点选择器',
'clusters.nodeSelector.tip': 'clusters.nodeSelector.tip':
'应用到每个 worker DaemonSet 的 Pod nodeSelector,只有标签匹配的节点才会被调度运行 worker。', '应用到每个 worker DaemonSet 的 Pod nodeSelector,只有标签匹配的节点才会被调度运行 worker。',
'clusters.operatorImage.title': 'Operator 镜像', 'clusters.gpuVendorOverrides.title': 'GPU 厂商覆盖配置',
'clusters.operatorImage.tip': 'clusters.gpuVendorOverrides.validate.emptySelector':
'GPUStack Operator 容器镜像的覆盖值。留空则使用服务端默认值。', '{vendor} 的覆盖配置至少需要一项 nodeSelector —— 空覆盖会让多 vendor manifest 无法定位该 runtime 的节点。',
'clusters.namespace.title': '命名空间', 'clusters.gpuVendorOverrides.validate.duplicate':
'clusters.namespace.tip': '{v1} 与 {v2} 的覆盖配置使用了完全相同的 nodeSelector。每个 vendor 必须对应不同的节点集,否则它们的 worker DaemonSet 会相互争抢同一批节点。',
'集群清单渲染所使用的 Kubernetes 命名空间。留空则使用 gpustack-system。', 'clusters.gpuVendorOverrides.validate.keyConflict':
'clusters.gpuInstances.title': 'GPU 实例', '{vendor} 的覆盖配置重用了基础节点选择器中已有的 key({keys})—— 这会让 CPU worker 同时"要求"和"禁止"这些 key,永远无法被调度。',
'clusters.gpuInstances.tip': '为该集群启用 GPU 实例支持。', 'clusters.gpuVendorOverrides.tip':
'clusters.gpuInstances.staticAddress': '静态访问地址', '集群中存在多个 GPU 厂商时必填。每项的 nodeSelector 将该厂商的 worker DaemonSet 固定到匹配的节点,同时 CPU worker 通过 DoesNotExist 节点亲和性避开这些节点。',
'clusters.gpuInstances.staticAddress.tip': 'clusters.gpuVendorOverrides.add': '添加覆盖配置',
'Operator 访问该集群 GPU 实例所使用的静态地址(例如 LoadBalancer VIP)。可选。' 'clusters.gpuVendorOverrides.vendor': 'GPU 厂商',
'clusters.gpuVendorOverrides.nodeSelector': '节点选择器'
}; };
+1 -1
View File
@@ -134,7 +134,7 @@ export default {
'gpuservice.storage.accessMode': '访问模式', 'gpuservice.storage.accessMode': '访问模式',
'gpuservice.storage.persistent': '持久', 'gpuservice.storage.persistent': '持久',
'gpuservice.storage.temporary': '临时', 'gpuservice.storage.temporary': '临时',
'gpuservice.storage.persistentVolume': '持久', 'gpuservice.storage.persistentVolume': '持久',
'gpuservice.storage.temporary.tips': '实例停止后,数据将被清除。', 'gpuservice.storage.temporary.tips': '实例停止后,数据将被清除。',
'gpuservice.storage.persistentVolume.tips': 'gpuservice.storage.persistentVolume.tips':
'数据在实例重启后仍会保留,仅在实例被终止时删除。无法被其它实例共享。', '数据在实例重启后仍会保留,仅在实例被终止时删除。无法被其它实例共享。',
+3 -3
View File
@@ -8,7 +8,7 @@ export default {
'menu.playground.text2images': '图像', 'menu.playground.text2images': '图像',
'menu.playground.video': '视频', 'menu.playground.video': '视频',
'menu.compare': '多模型对比', 'menu.compare': '多模型对比',
'menu.models': '模型服务', 'menu.models': '模型',
'menu.models.modelList': '部署与管理', 'menu.models.modelList': '部署与管理',
'menu.models.modelCatalog': '模型库', 'menu.models.modelCatalog': '模型库',
'menu.models.deployment': '部署', 'menu.models.deployment': '部署',
@@ -31,7 +31,7 @@ export default {
'menu.404': '404', 'menu.404': '404',
'menu.resources.workers': '节点', 'menu.resources.workers': '节点',
'menu.resources.gpus': 'GPUs', 'menu.resources.gpus': 'GPUs',
'menu.models.modelfiles': '模型文件', 'menu.resources.modelfiles': '模型文件',
'menu.accessControl': '访问控制', 'menu.accessControl': '访问控制',
'menu.accessControl.apikeys': 'API 密钥', 'menu.accessControl.apikeys': 'API 密钥',
'menu.accessControl.users': '用户', 'menu.accessControl.users': '用户',
@@ -40,7 +40,7 @@ export default {
'menu.clusterManagement.credentials': '云凭证', 'menu.clusterManagement.credentials': '云凭证',
'menu.clusterManagement.clusterDetail': '集群详情', 'menu.clusterManagement.clusterDetail': '集群详情',
'menu.clusterManagement.clusterCreate': '创建集群', 'menu.clusterManagement.clusterCreate': '创建集群',
'menu.models.backendsList': '推理后端', 'menu.resources.backendsList': '推理后端',
'menu.gpuService': 'GPU 服务', 'menu.gpuService': 'GPU 服务',
'menu.gpuService.instances': 'GPU 实例', 'menu.gpuService.instances': 'GPU 实例',
'menu.gpuService.templates': '实例模板', 'menu.gpuService.templates': '实例模板',
-3
View File
@@ -35,11 +35,8 @@ export default {
'noresult.catalog.subTitle': '尚未配置任何模型。', 'noresult.catalog.subTitle': '尚未配置任何模型。',
'noresult.catalog.nofound': '未找到匹配的模型', 'noresult.catalog.nofound': '未找到匹配的模型',
'noresult.resources.cluster': '暂无可用集群,请添加集群以开始使用。', 'noresult.resources.cluster': '暂无可用集群,请添加集群以开始使用。',
'noresult.resources.k8sCluster':
'暂无可用集群,请添加 Kubernetes 集群以开始使用。',
'noresult.resources.worker': '暂无可用节点,请添加节点以开始使用。', 'noresult.resources.worker': '暂无可用节点,请添加节点以开始使用。',
'noresult.resources.gotocluster': '创建您的第一个集群', 'noresult.resources.gotocluster': '创建您的第一个集群',
'noresult.resources.addk8scluster': '添加 Kubernetes 集群',
'noresult.resources.gotoworker': '添加节点', 'noresult.resources.gotoworker': '添加节点',
'noresult.benchmark.title': '暂无基准测试', 'noresult.benchmark.title': '暂无基准测试',
'noresult.benchmark.subTitle': '尚未添加任何基准测试。', 'noresult.benchmark.subTitle': '尚未添加任何基准测试。',
@@ -1,4 +1,4 @@
import { currentOrganizationIdAtom } from '@/atoms/user'; import { currentClusterAtom } from '@/atoms/gpuservice';
import { Input as CInput } from '@gpustack/core-ui'; import { Input as CInput } from '@gpustack/core-ui';
import { Form } from 'antd'; import { Form } from 'antd';
import type { NamePath } from 'antd/es/form/interface'; import type { NamePath } from 'antd/es/form/interface';
@@ -9,22 +9,18 @@ interface OwnerPrincipalIdFieldProps {
name?: NamePath; name?: NamePath;
} }
// Pins `owner_principal_id` to the Org the caller is currently acting
// under. Cluster ownership is irrelevant here: cluster_access grants
// let one Org schedule on another Org's cluster, but the resource the
// caller creates still belongs to *their* Org, and the backend enforces
// `owner_principal_id == ctx.current_principal_id`.
const OwnerPrincipalIdField: React.FC<OwnerPrincipalIdFieldProps> = ({ const OwnerPrincipalIdField: React.FC<OwnerPrincipalIdFieldProps> = ({
name = 'owner_principal_id' name = 'owner_principal_id'
}) => { }) => {
const currentOrgId = useAtomValue(currentOrganizationIdAtom); const currentCluster = useAtomValue(currentClusterAtom);
const ownerPrincipalId = currentCluster?.owner_principal_id;
const form = Form.useFormInstance(); const form = Form.useFormInstance();
useEffect(() => { useEffect(() => {
if (currentOrgId != null) { if (ownerPrincipalId != null) {
form.setFieldValue(name, currentOrgId); form.setFieldValue(name, ownerPrincipalId);
} }
}, [currentOrgId, name, form]); }, [ownerPrincipalId, name, form]);
return ( return (
<Form.Item name={name} hidden> <Form.Item name={name} hidden>
@@ -55,22 +55,17 @@ const MainWrapper = styled.div`
const ClusterCreate: React.FC<{ const ClusterCreate: React.FC<{
action: PageActionType; action: PageActionType;
// Preselect a provider and skip the provider-catalog step. Set by
// empty-state CTAs that already know which kind of cluster the user
// is heading for (e.g. GPU Service's "Add a Kubernetes Cluster").
providerHint?: string;
setCurrentTitle?: (title: string) => void; setCurrentTitle?: (title: string) => void;
onClose?: () => void; onClose?: () => void;
}> = ({ onClose, action, providerHint, setCurrentTitle }) => { }> = ({ onClose, action, setCurrentTitle }) => {
const startStep = 0;
const stepList = useStepList(); const stepList = useStepList();
const [systemConfigState] = useAtom(systemConfigAtom); const [systemConfigState] = useAtom(systemConfigAtom);
const intl = useIntl(); const intl = useIntl();
const [credentialList, setCredentialList] = useState< const [credentialList, setCredentialList] = useState<
Global.BaseOption<number, { provider: ProviderType }>[] Global.BaseOption<number, { provider: ProviderType }>[]
>([]); >([]);
// When the caller already picked a provider for us, start one step const [currentStep, setCurrentStep] = useState<number>(startStep);
// in — provider catalog is step 0; configure is step 1.
const [currentStep, setCurrentStep] = useState<number>(providerHint ? 1 : 0);
const [registrationInfo, setRegistrationInfo] = useState<{ const [registrationInfo, setRegistrationInfo] = useState<{
token: string; token: string;
image: string; image: string;
@@ -84,7 +79,7 @@ const ClusterCreate: React.FC<{
cluster_id: 0 cluster_id: 0
}); });
const [extraData, setExtraData] = useState<ClusterFormData>({ const [extraData, setExtraData] = useState<ClusterFormData>({
provider: (providerHint as ProviderType) ?? ProviderValueMap.Docker provider: ProviderValueMap.Docker
} as ClusterFormData); } as ClusterFormData);
const [formValues, setFormValues] = useState<Record<string, any>>({}); const [formValues, setFormValues] = useState<Record<string, any>>({});
const [submitLoading, setSubmitLoading] = useState<boolean>(false); const [submitLoading, setSubmitLoading] = useState<boolean>(false);
@@ -331,11 +326,7 @@ const ClusterCreate: React.FC<{
<MainWrapper> <MainWrapper>
{!isAddWorkerStep && ( {!isAddWorkerStep && (
<StepWrapper> <StepWrapper>
<ClusterSteps <ClusterSteps steps={steps} currentStep={currentStep}></ClusterSteps>
steps={steps}
currentStep={currentStep}
selectedProvider={extraData.provider}
></ClusterSteps>
</StepWrapper> </StepWrapper>
)} )}
<ColumnWrapper <ColumnWrapper
@@ -360,12 +351,7 @@ const ClusterCreate: React.FC<{
} }
> >
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
{currentStep === 0 && ( {currentStep === startStep && (
// Catalog belongs to step 0 only. The previous gate used
// ``startStep`` which is 1 when ``providerHint`` skips the
// catalog — that wrongly re-rendered it on top of the
// configure form for entry points like "Add a Kubernetes
// Cluster".
<ProviderCatalog <ProviderCatalog
cols={2} cols={2}
dataList={providerList} dataList={providerList}
@@ -6,18 +6,13 @@ import ClusterCreate from './cluster-create';
interface ClusterModalProps { interface ClusterModalProps {
open: boolean; open: boolean;
title: string; title: string;
// When set, ClusterCreate preselects this provider and skips the
// provider-catalog step. Used by feature pages (e.g. GPU Service)
// whose empty state already implies which kind of cluster is needed.
providerHint?: string;
onClose: () => void; onClose: () => void;
} }
const ClusterModal: React.FC<ClusterModalProps> = ({ const ClusterModal: React.FC<ClusterModalProps> = ({
open, open,
onClose, onClose,
title, title
providerHint
}) => { }) => {
const [currentTitle, setCurrentTitle] = React.useState<string>(title); const [currentTitle, setCurrentTitle] = React.useState<string>(title);
const handleCancel = () => { const handleCancel = () => {
@@ -46,7 +41,6 @@ const ClusterModal: React.FC<ClusterModalProps> = ({
<ClusterCreate <ClusterCreate
onClose={handleCancel} onClose={handleCancel}
action={PageAction.CREATE} action={PageAction.CREATE}
providerHint={providerHint}
setCurrentTitle={setCurrentTitle} setCurrentTitle={setCurrentTitle}
></ClusterCreate> ></ClusterCreate>
</GSDrawer> </GSDrawer>
+2 -24
View File
@@ -73,14 +73,7 @@ const Clusters: React.FC = () => {
deleteAPI: deleteCluster, deleteAPI: deleteCluster,
watch: true, watch: true,
API: CLUSTERS_API, API: CLUSTERS_API,
contentForDelete: 'menu.clusterManagement.clusters', contentForDelete: 'menu.clusterManagement.clusters'
defaultQueryParams: {
// Management view: drop cross-Org cluster_access grants. Org
// Owner only sees the clusters they own here. Pickers that
// need "everything I can use" (GPU-instance create, etc.)
// query without ``mine`` and still see granted clusters.
mine: true
}
}); });
const navigate = useNavigate(); const navigate = useNavigate();
const { goToGrafana, ActionButton } = useGranfanaLink({ const { goToGrafana, ActionButton } = useGranfanaLink({
@@ -317,28 +310,14 @@ const Clusters: React.FC = () => {
} }
}, [clusterSession, dataSource.loadend, dataSource.dataList]); }, [clusterSession, dataSource.loadend, dataSource.dataList]);
// Provider hint follows the auto-open from one render to the next:
// the session atom is cleared right after we open the modal, but
// ClusterCreate mounts a tick later and needs the value to skip the
// provider-catalog step. Cache it locally and clear on close.
const [pendingProviderHint, setPendingProviderHint] = useState<
string | undefined
>(undefined);
useEffect(() => { useEffect(() => {
if (clusterSession?.firstAddCluster && dataSource.loadend) { if (clusterSession?.firstAddCluster && dataSource.loadend) {
setPendingProviderHint(clusterSession.providerHint);
openClusterModal(); openClusterModal();
// reset session // reset session
setClusterSession(null); setClusterSession(null);
} }
}, [clusterSession, dataSource.loadend]); }, [clusterSession, dataSource.loadend]);
const handleClusterModalClose = () => {
setPendingProviderHint(undefined);
closeClusterModal();
};
const renderChildren = ( const renderChildren = (
list: any, list: any,
options: { parent?: any; [key: string]: any } options: { parent?: any; [key: string]: any }
@@ -464,8 +443,7 @@ const Clusters: React.FC = () => {
id: 'menu.clusterManagement.clusterCreate' id: 'menu.clusterManagement.clusterCreate'
})} })}
open={clusterModalStatus.open} open={clusterModalStatus.open}
providerHint={pendingProviderHint} onClose={closeClusterModal}
onClose={handleClusterModalClose}
></ClusterModal> ></ClusterModal>
{AddWorkerModal} {AddWorkerModal}
</> </>
@@ -3,10 +3,13 @@ import {
GPUDriverMap, GPUDriverMap,
GPUsConfigs GPUsConfigs
} from '@/pages/resources/config/gpu-driver'; } from '@/pages/resources/config/gpu-driver';
import { BulbOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Tag } from 'antd'; import { Alert, Tag } from 'antd';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { queryClusterItem } from '../../apis';
import { ProviderValueMap } from '../../config'; import { ProviderValueMap } from '../../config';
import { ClusterListItem } from '../../config/types';
import SupportedGPUs from '../support-gpus'; import SupportedGPUs from '../support-gpus';
import { useAddWorkerContext } from './add-worker-context'; import { useAddWorkerContext } from './add-worker-context';
import { AddWorkerStepProps, StepNamesMap } from './config'; import { AddWorkerStepProps, StepNamesMap } from './config';
@@ -23,25 +26,75 @@ const buildWorkerCommand = (
}); });
const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => { const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
const { stepList, registerField, updateField, provider } = const { stepList, registerField, updateField, provider, registrationInfo } =
useAddWorkerContext(); useAddWorkerContext();
const intl = useIntl(); const intl = useIntl();
const stepIndex = stepList.indexOf(StepNamesMap.SelectGPU) + 1; const stepIndex = stepList.indexOf(StepNamesMap.SelectGPU) + 1;
// K8s clusters render one worker DaemonSet per requested GPU runtime and // Pull the cluster so we know whether gpuVendorOverrides was configured.
// derive each DaemonSet's nodeSelector from the vendor's PCI-presence label // The K8s register flow gates multi-select on that being present, and
// at manifest time, so multiple vendors can be registered without any // restricts available runtimes to its keys.
// per-cluster override config. Multi-select is therefore always available const [overrideRuntimes, setOverrideRuntimes] = useState<string[]>([]);
// for the Kubernetes provider; other providers stay single-select. useEffect(() => {
const multiCapable = provider === ProviderValueMap.Kubernetes; const id = registrationInfo?.cluster_id;
if (!id) {
// Reset when the cluster context goes away so we don't leak the
// previous cluster's overrides into the next session.
setOverrideRuntimes([]);
return;
}
let cancelled = false;
queryClusterItem({ id })
.then((c) => {
if (cancelled) return;
const overrides = (c as ClusterListItem)?.k8s_options
?.gpuVendorOverrides;
setOverrideRuntimes(overrides ? Object.keys(overrides) : []);
})
.catch((err) => {
if (cancelled) return;
console.error('Failed to query cluster for vendor overrides:', err);
setOverrideRuntimes([]);
});
return () => {
cancelled = true;
};
}, [registrationInfo?.cluster_id]);
// Set of GPU *driver keys* (e.g. "cuda", "cann") that match the cluster's
// override runtimes. Used to detect when multi-add becomes available.
const overrideKeys = useMemo(() => {
const set = new Set<string>();
if (!overrideRuntimes.length) return set;
Object.values(GPUsConfigs).forEach((cfg) => {
if (cfg.gpuVendor && overrideRuntimes.includes(cfg.gpuVendor)) {
set.add(cfg.value);
}
});
return set;
}, [overrideRuntimes]);
// Multi-add is only meaningful when the cluster has 2+ vendor overrides
// AND the current selection already includes one. Until both hold, the
// picker behaves like a single-select (so the user can freely land on
// any vendor — including ones outside the override list).
const multiCapable =
provider === ProviderValueMap.Kubernetes && overrideKeys.size >= 2;
const [selectedKeys, setSelectedKeys] = useState<string[]>([ const [selectedKeys, setSelectedKeys] = useState<string[]>([
GPUDriverMap.NVIDIA GPUDriverMap.NVIDIA
]); ]);
// No vendor is gated anymore — every card stays selectable. const isMultiActive = useMemo(
const availableKeys = undefined; () => multiCapable && selectedKeys.some((k) => overrideKeys.has(k)),
[multiCapable, selectedKeys, overrideKeys]
);
// In multi-active mode, non-override vendors are disabled — picking one
// would break the backend invariant that a multi-vendor manifest must
// only target configured runtimes. Otherwise everything stays enabled.
const availableKeys = isMultiActive ? overrideKeys : undefined;
// Cache vendor metadata (label/link from SupportedGPUs items) so we can // Cache vendor metadata (label/link from SupportedGPUs items) so we can
// rebuild workerCommand on toggle without re-clicking the card. // rebuild workerCommand on toggle without re-clicking the card.
@@ -73,6 +126,10 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
}, []); }, []);
const handleSelect = (key: string, item: any) => { const handleSelect = (key: string, item: any) => {
// Disabled cards are already blocked by TemplateCard; this is a
// defensive check for the multi-active case (only override runtimes
// can be added once multi-add is open).
if (availableKeys && !availableKeys.has(key)) return;
if (item) { if (item) {
itemMetaRef.current[key] = { itemMetaRef.current[key] = {
label: item.label, label: item.label,
@@ -85,13 +142,21 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
// Clicking a selected card always toggles it off. // Clicking a selected card always toggles it off.
return prev.filter((v) => v !== key); return prev.filter((v) => v !== key);
} }
// K8s clusters support multiple GPU runtimes, so accumulate picks. // Multi-add only when the current state already includes an override
// Other providers stay single-select and replace the current pick. // pick. Otherwise (still in single-select land), replace.
if (multiCapable) return [...prev, key]; if (isMultiActive) return [...prev, key];
return [key]; return [key];
}); });
}; };
// Warn when multi-select is possible on this cluster but the user's
// current pick lands outside the override list — they're effectively
// locked into single-select until they switch to an override vendor.
const showSingleOnlyHint =
multiCapable &&
selectedKeys.length > 0 &&
selectedKeys.every((k) => !overrideKeys.has(k));
return ( return (
<StepCollapse <StepCollapse
disabled={disabled} disabled={disabled}
@@ -117,6 +182,17 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
</Title> </Title>
} }
> >
{showSingleOnlyHint && (
<Alert
type="info"
showIcon
icon={<BulbOutlined />}
style={{ marginBottom: 8 }}
message={intl.formatMessage({
id: 'clusters.addworker.selectGPU.singleOnly'
})}
/>
)}
<SupportedGPUs <SupportedGPUs
onSelect={handleSelect} onSelect={handleSelect}
current={selectedKeys} current={selectedKeys}
@@ -9,8 +9,9 @@ import {
Textarea as SealTextArea Textarea as SealTextArea
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form } from 'antd'; import { Form, message } from 'antd';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import _ from 'lodash';
import React, { forwardRef, useEffect, useImperativeHandle } from 'react'; import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import { ProviderType, ProviderValueMap } from '../config'; import { ProviderType, ProviderValueMap } from '../config';
import { import {
@@ -19,7 +20,6 @@ import {
} from '../config/types'; } from '../config/types';
import AdvanceConfig from '../step-forms/advance-config'; import AdvanceConfig from '../step-forms/advance-config';
import CloudProvider from './cloud-provider-form'; import CloudProvider from './cloud-provider-form';
import K8sPodSpec from './k8s-pod-spec';
type AddModalProps = { type AddModalProps = {
action: PageActionType; action: PageActionType;
@@ -34,11 +34,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const [form] = Form.useForm(); const [form] = Form.useForm();
const intl = useIntl(); const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]); const [activeKey, setActiveKey] = React.useState<string[]>([]);
// K8s deployment options is its own top-level section (sibling of Advanced),
// open by default so the fields are visible without an extra click.
const [k8sActiveKey, setK8sActiveKey] = React.useState<string[]>([
'k8sOptions'
]);
const advanceConfigRef = React.useRef<any>(null); const advanceConfigRef = React.useRef<any>(null);
const systemConfig = useAtomValue(systemConfigAtom); const systemConfig = useAtomValue(systemConfigAtom);
@@ -59,58 +54,94 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
} }
}, [activeKey, action]); }, [activeKey, action]);
// The backend models the optional k8s_options string knobs as // Mirror the backend's `_validate_multi_vendor_overrides` check so the
// Optional[str] and treats null/absent as "use the server default" or // user sees the problem at save time instead of as a 400 when they
// "no auth". Coerce empty form values to null before sending so a blank // run the register command. Returns the i18n'd message of the first
// input is unambiguous rather than an empty string that defeats fallbacks. // problem found, or null when clean.
const validateGpuVendorOverrides = (values: any): string | null => {
const opts = values?.k8s_options;
const overrides = opts?.gpuVendorOverrides;
if (!overrides) return null;
const entries = Object.entries(overrides) as [string, any][];
if (entries.length === 0) return null;
// 1. Every override entry must declare a non-empty nodeSelector,
// otherwise it can't actually pin its DaemonSet to anything.
for (const [vendor, override] of entries) {
const sel = override?.nodeSelector;
if (!sel || Object.keys(sel).length === 0) {
return intl.formatMessage(
{ id: 'clusters.gpuVendorOverrides.validate.emptySelector' },
{ vendor }
);
}
}
// 2. Two vendors with the same selector would fight for the same
// nodes — backend rejects this at manifest render.
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [v1, o1] = entries[i];
const [v2, o2] = entries[j];
if (_.isEqual(o1?.nodeSelector, o2?.nodeSelector)) {
return intl.formatMessage(
{ id: 'clusters.gpuVendorOverrides.validate.duplicate' },
{ v1, v2 }
);
}
}
}
// 3. Base nodeSelector keys can't be reused in any override —
// the CPU worker would require AND forbid the same key.
const baseKeys = Object.keys(opts?.nodeSelector || {});
if (baseKeys.length > 0) {
for (const [vendor, override] of entries) {
const overrideKeys = Object.keys(override?.nodeSelector || {});
const clash = overrideKeys.filter((k) => baseKeys.includes(k));
if (clash.length > 0) {
return intl.formatMessage(
{ id: 'clusters.gpuVendorOverrides.validate.keyConflict' },
{ vendor, keys: clash.join(', ') }
);
}
}
}
return null;
};
// Empty username/password aren't meaningful as credentials — the backend
// models them as Optional[str] and treats null as "no auth". Coerce
// before sending so a public registry placeholder stays unambiguous.
const normalizeOutgoing = (values: any): any => { const normalizeOutgoing = (values: any): any => {
const base: any = { ...values }; const creds = values?.k8s_options?.imageCredentials;
// Top-level cluster field shared by Docker and K8s. Trim then coerce a if (!Array.isArray(creds)) return values;
// blank input to null so clearing it on edit (or a whitespace-only const fixed = creds.map((c: any) => ({
// value) falls back to the server default rather than persisting an ...c,
// empty string. username: c?.username ? c.username : null,
if (base.system_default_container_registry !== undefined) { password: c?.password ? c.password : null
base.system_default_container_registry = }));
base.system_default_container_registry?.trim() || null; return {
} ...values,
k8s_options: { ...values.k8s_options, imageCredentials: fixed }
const opts = base.k8s_options; };
if (!opts) return base;
const next: any = { ...opts };
const creds = opts.imageCredentials;
if (Array.isArray(creds)) {
next.imageCredentials = creds.map((c: any) => ({
...c,
username: c?.username || null,
password: c?.password || null
}));
}
next.operatorImage = opts.operatorImage || null;
next.namespace = opts.namespace || null;
// Presence of gpuInstanceOptions is the enable flag; keep it only when
// the toggle left an object behind, coercing a blank address to null.
if (opts.gpuInstanceOptions) {
next.gpuInstanceOptions = {
gpuInstancesAccessStaticAddress:
opts.gpuInstanceOptions.gpuInstancesAccessStaticAddress || null
};
}
return { ...base, k8s_options: next };
}; };
const handleOnFinish = (_values: FormData) => { const handleOnFinish = (_values: FormData) => {
const workerConfig = yaml2Json(advanceConfigRef.current?.getYamlValue()); const workerConfig = yaml2Json(advanceConfigRef.current?.getYamlValue());
// antd's onFinish only delivers values for registered Form.Items. // antd's onFinish only delivers values for registered Form.Items.
// Spreading those on top of `getFieldsValue(true)` clobbers nested // Spreading those on top of `getFieldsValue(true)` clobbers nested
// objects (e.g. `k8s_options` would lose values set via setFieldValue), // objects (e.g. `k8s_options` would lose `gpuVendorOverrides`, which is
// so we go straight to the full store. // only set via setFieldValue), so we go straight to the full store.
const fullValues = form.getFieldsValue(true); const fullValues = form.getFieldsValue(true);
const overridesErr = validateGpuVendorOverrides(fullValues);
if (overridesErr) {
message.error(overridesErr);
return;
}
onFinish( onFinish(
normalizeOutgoing({ normalizeOutgoing({
...fullValues, ...fullValues,
@@ -205,11 +236,21 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
validateFields: async () => { validateFields: async () => {
// Run validation first to display any field errors. Then read the // Run validation first to display any field errors. Then read the
// FULL store via `getFieldsValue(true)` so values that were set via // FULL store via `getFieldsValue(true)` so values that were set via
// setFieldValue on non-registered paths are still included in what we // setFieldValue on non-registered paths (e.g. gpuVendorOverrides)
// hand to the API. // are still included in what we hand to the API.
await form.validateFields(); await form.validateFields();
const values = form.getFieldsValue(true); const values = form.getFieldsValue(true);
// Mirror the backend invariants for gpuVendorOverrides so the user
// sees the same constraint before submit instead of at register time.
const overridesErr = validateGpuVendorOverrides(values);
if (overridesErr) {
message.error(overridesErr);
// Reject so the step-flow's Promise.allSettled marks this form
// as failed and the outer onNext skips the submit callback.
throw new Error(overridesErr);
}
const workerConfig = yaml2Json( const workerConfig = yaml2Json(
advanceConfigRef.current?.getYamlValue() advanceConfigRef.current?.getYamlValue()
); );
@@ -273,37 +314,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
></SealTextArea> ></SealTextArea>
</Form.Item> </Form.Item>
{provider === ProviderValueMap.Kubernetes && (
<CollapsePanel
accordion={false}
activeKey={k8sActiveKey}
onChange={(keys) =>
setK8sActiveKey(Array.isArray(keys) ? keys : [keys])
}
items={[
{
key: 'k8sOptions',
label: intl.formatMessage({ id: 'clusters.k8sOptions.title' }),
forceRender: true,
children: (
// Key by cluster id so the section fully remounts when the
// active cluster changes. GpuInstanceOptionsForm seeds its
// local state from initialValue only once (initializedRef),
// so without a remount a reused form instance could carry a
// previous cluster's GPU instance config into the next one.
<K8sPodSpec
key={currentData?.id ?? 'new'}
action={action}
initialGpuInstanceOptions={
currentData?.k8s_options?.gpuInstanceOptions
}
></K8sPodSpec>
)
}
]}
></CollapsePanel>
)}
<CollapsePanel <CollapsePanel
accordion={false} accordion={false}
activeKey={activeKey} activeKey={activeKey}
@@ -1,18 +1,13 @@
import { Steps, Typography } from 'antd'; import { Steps } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { ProviderType } from '../config';
const { Text } = Typography; // `description` is intentionally omitted: the upstream step list ships
// hardcoded English copy that isn't translated. Keeping it would cause the
const ANTD_STEP_KEYS = [ // step to render both the localized title and the English description side
'title', // by side. Same reason we don't surface `subTitle`.
'icon', const ANTD_STEP_KEYS = ['title', 'icon', 'status', 'disabled'] as const;
'status',
'disabled',
'subTitle'
] as const;
const Wrapper = styled.div` const Wrapper = styled.div`
display: flex; display: flex;
@@ -36,11 +31,6 @@ const Box = styled.div`
.ant-steps-item-rail-wait { .ant-steps-item-rail-wait {
--steps-item-solid-line-color: var(--ant-color-split); --steps-item-solid-line-color: var(--ant-color-split);
} }
.ant-steps-item-subtitle {
margin-left: 6px;
font-size: 13px;
color: var(--ant-color-text-tertiary);
}
&:not(.ant-steps-panel) { &:not(.ant-steps-panel) {
.ant-steps-item-finish { .ant-steps-item-finish {
--steps-item-icon-bg-color: var(--ant-color-primary); --steps-item-icon-bg-color: var(--ant-color-primary);
@@ -53,23 +43,17 @@ const ClusterSteps: React.FC<{
currentStep: number; currentStep: number;
onChange?: (step: number) => void; onChange?: (step: number) => void;
steps: any[]; steps: any[];
selectedProvider?: ProviderType;
}> = (props) => { }> = (props) => {
const { steps, currentStep = 0, onChange } = props; const { steps, currentStep = 0, onChange } = props;
// Pick only props antd's Step accepts — the upstream step objects carry
// custom keys (showModules/showForms/showButtons/...) that would otherwise
// be forwarded to the DOM and trigger "React does not recognize the X
// prop on a DOM element" warnings. _.pick keeps missing keys missing
// (rather than explicitly `undefined`) so antd's defaults still kick in.
const visibleSteps = steps const visibleSteps = steps
.filter((step) => !step.hideInSteps) .filter((step) => !step.hideInSteps)
.map((step, index) => { .map((step) => _.pick(step, ANTD_STEP_KEYS));
return {
..._.pick(step, ANTD_STEP_KEYS),
subTitle:
index === 0 && props.selectedProvider ? (
<span>[{props.selectedProvider}]</span>
) : (
''
)
};
});
const styles: Record<string, any> = { const styles: Record<string, any> = {
root: { root: {
@@ -1,16 +1,21 @@
import { PageActionType } from '@/config/types';
import { import {
MinusOutlined, MinusOutlined,
PlusOutlined, PlusOutlined,
QuestionCircleOutlined QuestionCircleOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Input as CInput, LabelSelector, useAppUtils } from '@gpustack/core-ui'; import {
Input as CInput,
CollapseContainer,
LabelSelector,
Select as SealSelect,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button, Form, Switch, Tooltip } from 'antd'; import { Button, Form, Tooltip } from 'antd';
import React, { useEffect, useRef, useState } from 'react'; import _ from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { GpuInstanceOptions } from '../config/types'; import { GPUsConfigs } from '../../resources/config/gpu-driver';
import K8SVolumeMount from './k8s-volume-mount';
const Title = styled.div` const Title = styled.div`
display: flex; display: flex;
@@ -23,6 +28,13 @@ const Title = styled.div`
padding-bottom: 8px; padding-bottom: 8px;
`; `;
const Label = styled.span`
display: flex;
align-items: center;
gap: 4px;
color: var(--ant-color-text-secondary);
`;
const SectionWrap = styled.div` const SectionWrap = styled.div`
margin-bottom: 16px; margin-bottom: 16px;
`; `;
@@ -170,107 +182,106 @@ const NodeSelectorForm: React.FC = () => {
); );
}; };
// Render namespace. Kept as the first field of the section so the most const vendorOptions = Object.values(GPUsConfigs)
// fundamental K8s deployment knob is set before the rest. .filter((c) => !!c.gpuVendor)
const NamespaceForm: React.FC = () => { .map((c) => ({ label: c.label, value: c.gpuVendor as string }));
const intl = useIntl();
return ( const GpuVendorOverridesForm: React.FC<{
<SectionWrap> initialValue?: Record<string, { nodeSelector?: Record<string, string> }>;
<Form.Item
name={['k8s_options', 'namespace']}
style={{ marginBottom: 0 }}
>
<CInput.Input
label={intl.formatMessage({ id: 'clusters.namespace.title' })}
description={intl.formatMessage({ id: 'clusters.namespace.tip' })}
placeholder="gpustack-system"
></CInput.Input>
</Form.Item>
</SectionWrap>
);
};
// Operator-image override. A plain string knob that used to ride along inside
// worker_config; it now lives directly on k8s_options.
const OperatorImageForm: React.FC = () => {
const intl = useIntl();
return (
<SectionWrap>
<Form.Item
name={['k8s_options', 'operatorImage']}
style={{ marginBottom: 0 }}
>
<CInput.Input
label={intl.formatMessage({ id: 'clusters.operatorImage.title' })}
description={intl.formatMessage({ id: 'clusters.operatorImage.tip' })}
></CInput.Input>
</Form.Item>
</SectionWrap>
);
};
// GPU-instance support. The backend treats the mere presence of
// `gpuInstanceOptions` as the enable flag, so the switch toggles the whole
// object in/out of the form rather than setting a boolean field; the static
// address (optional even when enabled) is nested underneath.
//
// We drive the toggle from local state (not Form.useWatch) because the
// gpuInstanceOptions path has no registered Form.Item of its own — useWatch
// doesn't reliably re-render on setFieldValue for such paths, which left the
// switch unresponsive. Local state owns the visible state and we mirror it
// into the form via setFieldValue so submit still collects it.
const GpuInstanceOptionsForm: React.FC<{
initialValue?: GpuInstanceOptions;
}> = ({ initialValue }) => { }> = ({ initialValue }) => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance(); const form = Form.useFormInstance();
const [enabled, setEnabled] = useState<boolean>(!!initialValue);
const [address, setAddress] = useState<string>(
initialValue?.gpuInstancesAccessStaticAddress || ''
);
const initializedRef = useRef<boolean>(!!initialValue);
const writeForm = (en: boolean, addr: string) => { // Single source of truth for the cards while the user is editing.
form.setFieldValue( // We mirror it into the form via setFieldValue so submit picks it up.
['k8s_options', 'gpuInstanceOptions'], // Seed local state directly from the parent's currentData — Form.useWatch
en ? { gpuInstancesAccessStaticAddress: addr } : undefined // on a non-registered nested path proved unreliable for picking up
); // initialValues, so we cut out that indirection.
}; const [overrides, setOverrides] = useState<
Record<string, { nodeSelector?: Record<string, string> }>
>(() => initialValue || {});
const [collapseKey, setCollapseKey] = useState<Set<string>>(new Set());
const initializedRef = useRef(!!initialValue);
// Mirror a seeded initial value into the form on mount so submit collects it. // On mount: if we seeded with an initialValue, mirror it into the form so
// submit collects it. (When seeded, initializedRef is already true.)
useEffect(() => { useEffect(() => {
if (initialValue) { if (initialValue && Object.keys(initialValue).length > 0) {
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || ''); form.setFieldValue(['k8s_options', 'gpuVendorOverrides'], initialValue);
} }
}, []); }, []);
// Adopt currentData arriving after mount (async edit load), once. After the // If the parent currentData arrives after mount (e.g. async fetch), adopt
// user has interacted (`initializedRef`), local state owns the section. // it once. After the user has interacted (`initializedRef`), local state
// owns the visible list.
useEffect(() => { useEffect(() => {
if (initializedRef.current) return; if (initializedRef.current) return;
if (initialValue) { if (initialValue && Object.keys(initialValue).length > 0) {
setEnabled(true); setOverrides(initialValue);
setAddress(initialValue.gpuInstancesAccessStaticAddress || ''); form.setFieldValue(['k8s_options', 'gpuVendorOverrides'], initialValue);
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
initializedRef.current = true; initializedRef.current = true;
} }
}, [initialValue]); }, [initialValue, form]);
const handleToggle = (checked: boolean) => { // Once the user has touched this section, keep the form mirror in sync
// so submit collects what's currently on screen even if the form was
// reset externally (e.g., parent re-renders that touch initialValues).
useEffect(() => {
if (!initializedRef.current) return;
form.setFieldValue(
['k8s_options', 'gpuVendorOverrides'],
Object.keys(overrides).length > 0 ? overrides : undefined
);
}, [overrides, form]);
const vendorKeys = useMemo(() => Object.keys(overrides), [overrides]);
const availableVendors = useMemo(
() => vendorOptions.filter((opt) => !vendorKeys.includes(opt.value)),
[vendorKeys]
);
const writeOverrides = (next: Record<string, any>) => {
setOverrides(next);
form.setFieldValue(
['k8s_options', 'gpuVendorOverrides'],
Object.keys(next).length > 0 ? next : undefined
);
initializedRef.current = true; initializedRef.current = true;
setEnabled(checked);
if (!checked) {
setAddress('');
}
writeForm(checked, checked ? address : '');
}; };
const handleAddressChange = (e: any) => { const handleAdd = () => {
const next = typeof e === 'string' ? e : (e?.target?.value ?? ''); if (availableVendors.length === 0) return;
setAddress(next); const next = availableVendors[0].value;
writeForm(true, next); writeOverrides({ ...overrides, [next]: { nodeSelector: {} } });
setCollapseKey(new Set([next]));
};
const handleRemove = (vendor: string) => {
writeOverrides(_.omit(overrides, vendor));
};
const handleVendorChange = (oldKey: string, newKey: string) => {
if (oldKey === newKey) return;
const value = overrides[oldKey] ?? { nodeSelector: {} };
const next = _.omit(overrides, oldKey);
next[newKey] = value;
writeOverrides(next);
setCollapseKey(new Set([newKey]));
};
const handleNodeSelectorChange = (
vendor: string,
labels: Record<string, string>
) => {
writeOverrides({
...overrides,
[vendor]: { ...overrides[vendor], nodeSelector: labels }
});
};
const onToggle = (open: boolean, key: string) => {
setCollapseKey(open ? new Set([key]) : new Set());
}; };
return ( return (
@@ -279,48 +290,122 @@ const GpuInstanceOptionsForm: React.FC<{
<div className="flex-center gap-8"> <div className="flex-center gap-8">
<span className="flex-center gap-4"> <span className="flex-center gap-4">
<span> <span>
{intl.formatMessage({ id: 'clusters.gpuInstances.title' })} {intl.formatMessage({ id: 'clusters.gpuVendorOverrides.title' })}
</span> </span>
<Tooltip <Tooltip
title={intl.formatMessage({ id: 'clusters.gpuInstances.tip' })} title={intl.formatMessage({
id: 'clusters.gpuVendorOverrides.tip'
})}
> >
<QuestionCircleOutlined <QuestionCircleOutlined
style={{ color: 'var(--ant-color-text-secondary)' }} style={{ color: 'var(--ant-color-text-secondary)' }}
/> />
</Tooltip> </Tooltip>
</span> </span>
<Switch checked={enabled} onChange={handleToggle} /> <Button
type="link"
onClick={handleAdd}
disabled={availableVendors.length === 0}
>
<PlusOutlined />{' '}
{intl.formatMessage({ id: 'clusters.gpuVendorOverrides.add' })}
</Button>
</div> </div>
</Title> </Title>
{enabled && ( <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<CInput.Input {vendorKeys.map((vendor) => {
isInFormItems={false} const optionsForThisRow = [
value={address} ...vendorOptions.filter(
onChange={handleAddressChange} (opt) => opt.value === vendor || !vendorKeys.includes(opt.value)
label={intl.formatMessage({ )
id: 'clusters.gpuInstances.staticAddress' ];
})} const vendorLabel =
description={intl.formatMessage({ vendorOptions.find((o) => o.value === vendor)?.label || vendor;
id: 'clusters.gpuInstances.staticAddress.tip' return (
})} <div
></CInput.Input> key={vendor}
)} style={{
border: '1px solid var(--ant-color-split)',
borderRadius: 'var(--ant-border-radius-lg)'
}}
>
<CollapseContainer
collapsible={true}
showExpandIcon={true}
open={collapseKey.has(vendor)}
onToggle={(open: boolean) => onToggle(open, vendor)}
styles={{
body: collapseKey.has(vendor) ? { padding: 16 } : {},
content: { paddingTop: 0 },
header: { backgroundColor: 'unset' }
}}
title={
<Label>
<span>
{intl.formatMessage({
id: 'clusters.gpuVendorOverrides.vendor'
})}
:
</span>
<span>{vendorLabel}</span>
</Label>
}
right={
<Button
size="small"
shape="circle"
onClick={() => handleRemove(vendor)}
>
<MinusOutlined />
</Button>
}
>
<div style={{ marginBottom: 16, width: '100%' }}>
<SealSelect
isInFormItems={false}
required
style={{ width: '100%' }}
label={intl.formatMessage({
id: 'clusters.gpuVendorOverrides.vendor'
})}
value={vendor}
options={optionsForThisRow}
onChange={(value: string) =>
handleVendorChange(vendor, value)
}
></SealSelect>
</div>
<LabelSelector
label={intl.formatMessage({
id: 'clusters.gpuVendorOverrides.nodeSelector'
})}
value={overrides[vendor]?.nodeSelector || {}}
onChange={(labels) =>
handleNodeSelectorChange(vendor, labels)
}
></LabelSelector>
</CollapseContainer>
</div>
);
})}
</div>
</SectionWrap> </SectionWrap>
); );
}; };
type GpuVendorOverridesValue = Record<
string,
{ nodeSelector?: Record<string, string> }
>;
const K8sPodSpec: React.FC<{ const K8sPodSpec: React.FC<{
action: PageActionType; initialOverrides?: GpuVendorOverridesValue;
initialGpuInstanceOptions?: GpuInstanceOptions; }> = ({ initialOverrides }) => {
}> = ({ action, initialGpuInstanceOptions }) => {
return ( return (
<> <>
<NamespaceForm />
<K8SVolumeMount action={action}></K8SVolumeMount>
<ImageCredentialsForm /> <ImageCredentialsForm />
<NodeSelectorForm /> <NodeSelectorForm />
<OperatorImageForm /> <GpuVendorOverridesForm initialValue={initialOverrides} />
<GpuInstanceOptionsForm initialValue={initialGpuInstanceOptions} />
</> </>
); );
}; };
+4 -23
View File
@@ -74,31 +74,17 @@ export interface ImageCredential {
password: string; password: string;
} }
export interface GpuInstanceOptions {
// The mere presence of `gpuInstanceOptions` on `k8s_options` signals
// "GPU instances enabled" for the cluster — absence opts the cluster out,
// so there's no separate boolean flag on the wire.
gpuInstancesAccessStaticAddress?: string | null;
}
export interface K8sOptions { export interface K8sOptions {
// Backend serializes K8sOptions with camelCase aliases (by_alias=True on // Backend serializes K8sOptions with camelCase aliases (by_alias=True on
// the SQL JSON column). The top-level `k8s_options` field on the cluster // the SQL JSON column). The top-level `k8s_options` field on the cluster
// stays snake_case, but everything inside follows the backend wire shape. // stays snake_case, but everything inside follows the backend wire shape.
volumeMounts?: VolumeMount[]; volumeMounts?: VolumeMount[];
imageCredentials?: ImageCredential[]; imageCredentials?: ImageCredential[];
// Base nodeSelector applied to every worker DaemonSet; each per-runtime
// DaemonSet additionally gets a vendor PCI-presence label merged on top at
// render time, so per-vendor overrides are no longer configured here.
nodeSelector?: Record<string, string>; nodeSelector?: Record<string, string>;
// Override for the gpustack-operator container image. Falls back to the gpuVendorOverrides?: Record<
// server's default when unset. string,
operatorImage?: string | null; { nodeSelector?: Record<string, string> }
// GPU-instance support knobs; presence enables GPU instance handling. >;
gpuInstanceOptions?: GpuInstanceOptions;
// Kubernetes namespace the cluster's manifests render into. Falls back to
// `gpustack-system` at render time when unset.
namespace?: string | null;
} }
export interface ClusterListItem { export interface ClusterListItem {
@@ -107,10 +93,6 @@ export interface ClusterListItem {
is_default: boolean; is_default: boolean;
description: string; description: string;
worker_config: Record<string, any>; worker_config: Record<string, any>;
// Per-cluster default container registry, promoted out of worker_config to
// a top-level column on the backend (image resolution / registration token
// read it directly). Falls back to the server default when unset.
system_default_container_registry?: string | null;
provider: ProviderType; provider: ProviderType;
credential_id: number; credential_id: number;
created_at: string; created_at: string;
@@ -140,7 +122,6 @@ export interface ClusterFormData {
region: string; region: string;
server_url?: string; server_url?: string;
worker_config?: Record<string, any>; worker_config?: Record<string, any>;
system_default_container_registry?: string | null;
worker_pools?: NodePoolFormData[]; worker_pools?: NodePoolFormData[];
k8s_options?: K8sOptions; k8s_options?: K8sOptions;
} }
@@ -1,106 +0,0 @@
{
"type": "object",
"additionalProperties": false,
"properties": {
"debug": {
"type": "boolean",
"description": "Enable debug mode"
},
"cache_dir": {
"type": "string",
"description": "Cache directory path"
},
"log_dir": {
"type": "string",
"description": "Log directory path"
},
"bin_dir": {
"type": "string",
"description": "Binary directory path"
},
"system_default_container_registry": {
"type": "string",
"description": "Default container registry"
},
"image_name_override": {
"type": "string",
"description": "Override image name"
},
"image_repo": {
"type": "string",
"description": "Container image repository"
},
"huggingface_token": {
"type": "string",
"description": "Hugging Face access token"
},
"disable_worker_metrics": {
"type": "boolean",
"description": "Disable worker metrics collection"
},
"worker_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"description": "Worker service port"
},
"worker_metrics_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"description": "Worker metrics port"
},
"service_port_range": {
"type": "string",
"pattern": "^\\d+-\\d+$",
"description": "Service port range, e.g. 30000-31000"
},
"ray_port_range": {
"type": "string",
"pattern": "^\\d+-\\d+$",
"description": "Ray port range, e.g. 20000-20100"
},
"system_reserved": {
"type": "object",
"description": "System reserved resources",
"properties": {
"ram": {
"type": "number",
"minimum": 0,
"description": "Reserved RAM in GiB"
},
"vram": {
"type": "number",
"minimum": 0,
"description": "Reserved VRAM in GiB"
}
}
},
"resources": {
"type": "object",
"description": "Additional resource definitions",
"additionalProperties": {}
},
"pipx_path": {
"type": "string",
"description": "pipx installation path"
},
"tools_download_base_url": {
"type": "string",
"description": "Base URL for downloading tools"
},
"enable_hf_transfer": {
"type": "boolean",
"description": "Enable Hugging Face transfer acceleration"
},
"enable_hf_xet": {
"type": "boolean",
"description": "Enable Hugging Face Xet support"
},
"proxy_mode": {
"type": "string",
"enum": ["worker", "system", "disabled"],
"description": "Proxy mode"
}
}
}
@@ -0,0 +1,117 @@
{
"type": "object",
"properties": {
"worker_config": {
"type": "object",
"additionalProperties": false,
"properties": {
"debug": {
"type": "boolean",
"description": "Enable debug mode"
},
"cache_dir": {
"type": "string",
"description": "Cache directory path"
},
"log_dir": {
"type": "string",
"description": "Log directory path"
},
"bin_dir": {
"type": "string",
"description": "Binary directory path"
},
"system_default_container_registry": {
"type": "string",
"description": "Default container registry"
},
"image_name_override": {
"type": "string",
"description": "Override image name"
},
"image_repo": {
"type": "string",
"description": "Container image repository"
},
"gateway_mode": {
"type": "string",
"description": "Gateway mode",
"enum": ["worker", "gateway", "disabled", "auto"]
},
"gateway_kubeconfig": {
"type": "string",
"description": "Path to kubeconfig used by gateway"
},
"gateway_concurrency": {
"type": "integer",
"minimum": 0,
"description": "Gateway concurrency limit"
},
"service_discovery_name": {
"type": "string",
"description": "Service discovery name"
},
"namespace": {
"type": "string",
"description": "Kubernetes namespace"
},
"huggingface_token": {
"type": "string",
"description": "Hugging Face access token"
},
"disable_worker_metrics": {
"type": "boolean",
"description": "Disable worker metrics collection"
},
"worker_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"description": "Worker service port"
},
"worker_metrics_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"description": "Worker metrics port"
},
"service_port_range": {
"type": "string",
"pattern": "^\\d+-\\d+$",
"description": "Service port range, e.g. 30000-31000"
},
"ray_port_range": {
"type": "string",
"pattern": "^\\d+-\\d+$",
"description": "Ray port range, e.g. 20000-20100"
},
"resources": {
"type": "object",
"description": "Additional resource definitions",
"additionalProperties": {}
},
"pipx_path": {
"type": "string",
"description": "pipx installation path"
},
"tools_download_base_url": {
"type": "string",
"description": "Base URL for downloading tools"
},
"enable_hf_transfer": {
"type": "boolean",
"description": "Enable Hugging Face transfer acceleration"
},
"enable_hf_xet": {
"type": "boolean",
"description": "Enable Hugging Face Xet support"
},
"proxy_mode": {
"type": "string",
"enum": ["worker", "system", "disabled"],
"description": "Proxy mode"
}
}
}
}
}
@@ -1,112 +0,0 @@
{
"type": "object",
"additionalProperties": false,
"properties": {
"debug": {
"type": "boolean",
"description": "Enable debug mode"
},
"cache_dir": {
"type": "string",
"description": "Cache directory path"
},
"log_dir": {
"type": "string",
"description": "Log directory path"
},
"system_default_container_registry": {
"type": "string",
"description": "Default container registry"
},
"image_name_override": {
"type": "string",
"description": "Override image name"
},
"image_repo": {
"type": "string",
"description": "Container image repository"
},
"gateway_mode": {
"type": "string",
"description": "Gateway mode",
"enum": ["worker", "gateway", "disabled", "auto"]
},
"gateway_kubeconfig": {
"type": "string",
"description": "Path to kubeconfig used by gateway"
},
"gateway_concurrency": {
"type": "integer",
"minimum": 0,
"description": "Gateway concurrency limit"
},
"service_discovery_name": {
"type": "string",
"description": "Service discovery name"
},
"huggingface_token": {
"type": "string",
"description": "Hugging Face access token"
},
"disable_worker_metrics": {
"type": "boolean",
"description": "Disable worker metrics collection"
},
"worker_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"description": "Worker service port"
},
"worker_metrics_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"description": "Worker metrics port"
},
"service_port_range": {
"type": "string",
"pattern": "^\\d+-\\d+$",
"description": "Service port range, e.g. 30000-31000"
},
"ray_port_range": {
"type": "string",
"pattern": "^\\d+-\\d+$",
"description": "Ray port range, e.g. 20000-20100"
},
"system_reserved": {
"type": "object",
"description": "System reserved resources",
"properties": {
"ram": {
"type": "number",
"minimum": 0,
"description": "Reserved RAM in GiB"
},
"vram": {
"type": "number",
"minimum": 0,
"description": "Reserved VRAM in GiB"
}
}
},
"resources": {
"type": "object",
"description": "Additional resource definitions",
"additionalProperties": {}
},
"enable_hf_transfer": {
"type": "boolean",
"description": "Enable Hugging Face transfer acceleration"
},
"enable_hf_xet": {
"type": "boolean",
"description": "Enable Hugging Face Xet support"
},
"proxy_mode": {
"type": "string",
"enum": ["worker", "system", "disabled"],
"description": "Proxy mode"
}
}
}
@@ -9,6 +9,7 @@ export const dockerConfig = `# This is a template for worker_config.
# ========= container & image =========== # ========= container & image ===========
# system_default_container_registry: "docker.io"
# image_name_override: "gpustack/gpustack:dev" # image_name_override: "gpustack/gpustack:dev"
# image_repo: "gpustack/gpustack" # image_repo: "gpustack/gpustack"
@@ -52,12 +53,14 @@ export const kubernetesConfig = `# This is a template for worker_config.
# ========= container & image =========== # ========= container & image ===========
# system_default_container_registry: "docker.io"
# image_name_override: "gpustack/gpustack:dev" # image_name_override: "gpustack/gpustack:dev"
# image_repo: "gpustack/gpustack" # image_repo: "gpustack/gpustack"
# ========= service & networking =========== # ========= service & networking ===========
# service_discovery_name: "worker" # service_discovery_name: "worker"
# namespace: "gpustack-system"
# worker_port: 10150 # worker_port: 10150
# worker_metrics_port: 10150 # worker_metrics_port: 10150
# service_port_range: "40000-40063" # service_port_range: "40000-40063"
@@ -86,8 +86,8 @@ const useClusterColumns = (
// `clusterDetail.linkableName`. Without a plugin we render the // `clusterDetail.linkableName`. Without a plugin we render the
// name as plain text (matches the pre-restore behaviour); with one // name as plain text (matches the pre-restore behaviour); with one
// we use Typography.Link wired to the parent's `onCellClick`. // we use Typography.Link wired to the parent's `onCellClick`.
const nameLinkable: boolean = const nameLinkable: boolean = !!getGPUStackPlugin()?.clusterDetail
!!getGPUStackPlugin()?.clusterDetail?.linkableName; ?.linkableName;
const setActionsItems = (row: ClusterListItem) => { const setActionsItems = (row: ClusterListItem) => {
return clusterActionList.filter((item) => { return clusterActionList.filter((item) => {
@@ -112,7 +112,9 @@ const useClusterColumns = (
<> <>
<AutoTooltip ghost title={text}> <AutoTooltip ghost title={text}>
{nameLinkable ? ( {nameLinkable ? (
<Typography.Link onClick={() => onCellClick?.(record, 'name')}> <Typography.Link
onClick={() => onCellClick?.(record, 'name')}
>
{record.name} {record.name}
</Typography.Link> </Typography.Link>
) : ( ) : (
@@ -7,13 +7,14 @@ import { useIntl } from '@umijs/max';
import { Button, Form } from 'antd'; import { Button, Form } from 'antd';
import React, { forwardRef, useEffect, useImperativeHandle } from 'react'; import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import K8sPodSpec from '../components/k8s-pod-spec';
import K8SVolumeMount from '../components/k8s-volume-mount';
import { ProviderType, ProviderValueMap } from '../config'; import { ProviderType, ProviderValueMap } from '../config';
import { import {
ClusterFormData as FormData, ClusterFormData as FormData,
ClusterListItem as ListItem ClusterListItem as ListItem
} from '../config/types'; } from '../config/types';
import dockerSchema from '../config/worker-config.docker.json'; import schema from '../config/worker-config.json';
import kubernetesSchema from '../config/worker-config.kubernetes.json';
import { dockerConfig, kubernetesConfig } from '../config/yaml-template'; import { dockerConfig, kubernetesConfig } from '../config/yaml-template';
const Title = styled.div` const Title = styled.div`
@@ -39,8 +40,6 @@ const ClusterAdvanceConfig: React.FC<{
const editorRef = React.useRef<any>(null); const editorRef = React.useRef<any>(null);
const { isDarkTheme } = useUserSettings(); const { isDarkTheme } = useUserSettings();
const [fileContent, setFileContent] = React.useState<string>(''); const [fileContent, setFileContent] = React.useState<string>('');
const schema =
provider === ProviderValueMap.Kubernetes ? kubernetesSchema : dockerSchema;
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getYamlValue: () => { getYamlValue: () => {
@@ -63,16 +62,8 @@ const ClusterAdvanceConfig: React.FC<{
? kubernetesConfig ? kubernetesConfig
: dockerConfig : dockerConfig
); );
} else if (action === PageAction.EDIT) {
const workerConfig = currentData?.worker_config;
const content =
workerConfig ||
(provider === ProviderValueMap.Kubernetes
? kubernetesConfig
: dockerConfig);
setFileContent(content as string);
} }
}, [provider, action, currentData?.worker_config]); }, [provider, action]);
return ( return (
<> <>
@@ -88,24 +79,14 @@ const ClusterAdvanceConfig: React.FC<{
> >
<CInput.TextArea required={false} trim={false}></CInput.TextArea> <CInput.TextArea required={false} trim={false}></CInput.TextArea>
</Form.Item> </Form.Item>
{/* Default container registry used to resolve images for this cluster. {provider === ProviderValueMap.Kubernetes && (
A top-level cluster field shared by both Docker and Kubernetes <>
providers (the backend hoists any legacy worker_config value onto <K8SVolumeMount action={action}></K8SVolumeMount>
this column). */} <K8sPodSpec
<Form.Item<FormData> initialOverrides={currentData?.k8s_options?.gpuVendorOverrides}
name="system_default_container_registry" ></K8sPodSpec>
style={{ marginBottom: 16 }} </>
> )}
<CInput.Input
label={intl.formatMessage({
id: 'clusters.systemDefaultContainerRegistry.title'
})}
description={intl.formatMessage({
id: 'clusters.systemDefaultContainerRegistry.tip'
})}
placeholder="docker.io"
></CInput.Input>
</Form.Item>
<Title> <Title>
{intl.formatMessage({ id: 'clusters.create.workerConfig' })} {intl.formatMessage({ id: 'clusters.create.workerConfig' })}
</Title> </Title>
@@ -115,12 +115,16 @@ const AddModal: React.FC<AddModalProps> = ({
}; };
const saveInstanceDataInDescription = (instanceType: InstanceTypeItem) => { const saveInstanceDataInDescription = (instanceType: InstanceTypeItem) => {
return JSON.stringify({ return JSON.stringify(
name: instanceType.name, {
spec: { name: instanceType.name,
...instanceType.spec spec: {
} ...instanceType.spec
}); }
},
null,
2
);
}; };
// apply the selection of instance type and template // apply the selection of instance type and template
@@ -155,6 +159,7 @@ const AddModal: React.FC<AddModalProps> = ({
description: saveInstanceDataInDescription(instanceType) description: saveInstanceDataInDescription(instanceType)
}); });
} }
// update form // update form
form.current?.applyInstanceType?.(instanceType); form.current?.applyInstanceType?.(instanceType);
}; };
@@ -290,8 +295,8 @@ const AddModal: React.FC<AddModalProps> = ({
...item.spec, ...item.spec,
sshPublicKeys: formValues?.spec?.sshPublicKeys, sshPublicKeys: formValues?.spec?.sshPublicKeys,
resources: { resources: {
...formValues?.spec?.resources, ...item?.spec?.resources,
localStorage: item?.spec?.resources?.localStorage accelerator: formValues?.spec?.resources?.accelerator
}, },
volume: { volume: {
...formValues?.spec?.volume ...formValues?.spec?.volume
@@ -1,4 +1,3 @@
import { ceilMilliToCore } from '@/pages/gpu-service/utils';
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 } from 'antd'; import { Flex } from 'antd';
@@ -22,30 +21,28 @@ const Title = styled.div`
const Meta = styled.div` const Meta = styled.div`
display: grid; display: grid;
grid-template-columns: repeat(7, auto); gap: 8px;
justify-content: start;
column-gap: 4px;
row-gap: 8px;
align-items: center;
color: var(--ant-color-text-secondary); color: var(--ant-color-text-secondary);
font-size: 13px; font-size: 13px;
.meta-row { .meta-row {
display: grid; display: flex;
grid-template-columns: subgrid;
grid-column: 1 / -1;
align-items: center; align-items: center;
min-height: 15px; gap: 8px;
color: var(--ant-color-text-tertiary); color: var(--ant-color-text-tertiary);
} }
.dot { .dot {
width: 3px; width: 3px;
height: 3px; height: 3px;
border-radius: 50%; border-radius: 50%;
background-color: var(--ant-color-text-quaternary); background-color: var(--ant-color-text-quaternary);
margin: 0 4px; flex: none;
justify-self: center; }
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
} }
.meta-icon { .meta-icon {
@@ -61,7 +58,7 @@ interface InstanceTypeItemProps {
const MetaItem: React.FC<{ const MetaItem: React.FC<{
icon: string; icon: string;
label?: string; label: string;
value?: string | null; value?: string | null;
showDot?: boolean; showDot?: boolean;
show?: boolean; show?: boolean;
@@ -69,10 +66,14 @@ const MetaItem: React.FC<{
if (!show) return null; if (!show) return null;
return ( return (
<> <>
{showDot && <span className="dot" />} {showDot && <span className="dot"></span>}
<IconFont type={icon} className="meta-icon" /> <span className="meta-item">
<span className="meta-label">{label}</span> <IconFont type={icon} className="meta-icon" />
<span className="meta-value">{value || '-'}</span> <Flex align="center" gap={4}>
<span>{label}</span>
<span>{value || '-'}</span>
</Flex>
</span>
</> </>
); );
}; };
@@ -80,27 +81,15 @@ const MetaItem: React.FC<{
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => { const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
const intl = useIntl(); const intl = useIntl();
const specData = item.spec || {}; const specData = item.spec || {};
// false: CPU; true: GPU
const acceleratable = specData.acceleratable; const acceleratable = specData.acceleratable;
const manufacturer = acceleratable ? specData.manufacturer || '' : 'cpu'; const manufacturerKey = specData.manufacturer || (acceleratable ? '' : 'cpu');
const manufacturerColor = manufactureColorMap[manufacturer] ?? 'purple'; const manufacturer = manufacturerKey?.toUpperCase();
const manufacturerColor =
(manufacturerKey && manufactureColorMap[manufacturerKey]) ?? 'purple';
// resource once-max-request status // resource remaining status
const onceMaxRequestData = item.status?.onceMaxRequest || {}; const remainingData = item.status?.remaining || {};
// RAM resource
const ramRaw = acceleratable
? specData.unitResources?.ram
: onceMaxRequestData.ram;
console.log('InstanceTypeItem', item.name, 'ramRaw', ramRaw);
// CPU resource
const cpuRaw = acceleratable
? specData.unitResources?.cpu
: onceMaxRequestData.cpu;
const renderName = () => { const renderName = () => {
const displayName = specData.acceleratable const displayName = specData.acceleratable
@@ -124,16 +113,16 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
}} }}
> >
<ThemeTag color={manufacturerColor} disabled={false}> <ThemeTag color={manufacturerColor} disabled={false}>
{manufacturer?.toUpperCase()} {manufacturer}
</ThemeTag> </ThemeTag>
</span> </span>
)} )}
</Flex> </Flex>
</Title> </Title>
<Meta> <Meta>
<span className="meta-row"> <span style={{ display: 'flex', height: 15 }}>
{acceleratable && ( {acceleratable && (
<> <span className="meta-row">
<MetaItem <MetaItem
showDot={false} showDot={false}
icon="icon-gpu1" icon="icon-gpu1"
@@ -142,7 +131,7 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
toDisplayUnit(convertKiToGi(specData?.memory ?? undefined)) ?? toDisplayUnit(convertKiToGi(specData?.memory ?? undefined)) ??
'-' '-'
} }
/> ></MetaItem>
<MetaItem <MetaItem
show={!!specData?.sliced} show={!!specData?.sliced}
icon="icon-sliced" icon="icon-sliced"
@@ -150,18 +139,16 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
id: 'gpuservice.instance.sliced' id: 'gpuservice.instance.sliced'
})} })}
value={specData?.sliced} value={specData?.sliced}
/> ></MetaItem>
<MetaItem <MetaItem
show={!!remainingData.accelerator}
icon="icon-database" icon="icon-database"
label={intl.formatMessage( label={intl.formatMessage({
{ id: 'gpuservice.instance.stock'
id: 'common.max' })}
}, value={remainingData.accelerator}
{ count: '' } ></MetaItem>
)} </span>
value={`${item.maxAccelerator || '-'}`}
/>
</>
)} )}
</span> </span>
<span className="meta-row"> <span className="meta-row">
@@ -169,15 +156,15 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
showDot={false} showDot={false}
icon="icon-ram-02" icon="icon-ram-02"
label={intl.formatMessage({ id: 'gpuservice.instance.ram' })} label={intl.formatMessage({ id: 'gpuservice.instance.ram' })}
value={toDisplayUnit(convertKiToGi(ramRaw)) ?? '-'} value={toDisplayUnit(convertKiToGi(remainingData.ram)) ?? '-'}
/> ></MetaItem>
<MetaItem <MetaItem
show={!!cpuRaw} show={!!remainingData.cpu}
showDot={true} showDot={true}
icon="icon-cpu" icon="icon-cpu"
label="CPU" label="CPU"
value={ceilMilliToCore(cpuRaw) ?? '-'} value={remainingData.cpu ?? '-'}
/> ></MetaItem>
</span> </span>
</Meta> </Meta>
</> </>
+18 -39
View File
@@ -14,7 +14,7 @@ export const InstanceStatusValueMap = {
Preparing: 'Preparing', Preparing: 'Preparing',
NotReady: 'NotReady', NotReady: 'NotReady',
Ready: 'Ready', Ready: 'Ready',
Starting: 'Starting' Staring: 'Staring'
}; };
export const InstanceStatusLabelMap: Record<string, string> = { export const InstanceStatusLabelMap: Record<string, string> = {
@@ -27,7 +27,7 @@ export const InstanceStatusLabelMap: Record<string, string> = {
[InstanceStatusValueMap.Preparing]: 'Preparing', [InstanceStatusValueMap.Preparing]: 'Preparing',
[InstanceStatusValueMap.NotReady]: 'NotReady', [InstanceStatusValueMap.NotReady]: 'NotReady',
[InstanceStatusValueMap.Ready]: 'Ready', [InstanceStatusValueMap.Ready]: 'Ready',
[InstanceStatusValueMap.Starting]: 'Starting' [InstanceStatusValueMap.Staring]: 'Staring'
}; };
export const status: Record<string, StatusType> = { export const status: Record<string, StatusType> = {
@@ -40,7 +40,7 @@ export const status: Record<string, StatusType> = {
[InstanceStatusValueMap.Preparing]: StatusMaps.transitioning, [InstanceStatusValueMap.Preparing]: StatusMaps.transitioning,
[InstanceStatusValueMap.NotReady]: StatusMaps.error, [InstanceStatusValueMap.NotReady]: StatusMaps.error,
[InstanceStatusValueMap.Ready]: StatusMaps.success, [InstanceStatusValueMap.Ready]: StatusMaps.success,
[InstanceStatusValueMap.Starting]: StatusMaps.transitioning [InstanceStatusValueMap.Staring]: StatusMaps.transitioning
}; };
// Lifecycle actions (logs/events/start/stop) require K8s proxy endpoints that // Lifecycle actions (logs/events/start/stop) require K8s proxy endpoints that
@@ -172,8 +172,8 @@ export const convertKiToGi = (value?: string): string | undefined => {
const match = /^(-?\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)$/.exec(value); const match = /^(-?\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)$/.exec(value);
if (!match) return value; if (!match) return value;
const [, num, unit] = match; const [, num, unit] = match;
if (unit === 'Ti') return `${_.floor(Number(num), 0)}Ti`; if (unit === 'Ti') return `${_.round(Number(num), 2)}Ti`;
return `${_.floor(Number(num) / GI_DIVISOR[unit], 0)}Gi`; return `${_.round(Number(num) / GI_DIVISOR[unit], 2)}Gi`;
}; };
const parseQuantity = (value?: string | null): number => { const parseQuantity = (value?: string | null): number => {
@@ -183,58 +183,37 @@ const parseQuantity = (value?: string | null): number => {
}; };
// Returns the slider max for the accelerator count: the largest // Returns the slider max for the accelerator count: the largest
// tier.onceMaxRequest.accelerator across all acceleratorTiers (not from candidates). // tier.onceMaxRequest across all acceleratorTiers (not from candidates).
export const getAcceleratorMax = ( export const getAcceleratorMax = (
tiers?: { onceMaxRequest: { accelerator?: string } }[] | null tiers?: { onceMaxRequest: string }[] | null
) => { ) => {
if (!tiers?.length) return 0; if (!tiers?.length) return 0;
return tiers.reduce((acc, tier) => { return tiers.reduce((acc, tier) => {
const n = parseQuantity(tier.onceMaxRequest?.accelerator); const n = parseQuantity(tier.onceMaxRequest);
return n > acc ? n : acc; return n > acc ? n : acc;
}, 0); }, 0);
}; };
// Picks the candidate (cluster + type name) that should fulfill a requested // Picks the candidate (cluster + type name) that should fulfill a requested
// accelerator count: the first candidate of the smallest tier whose // accelerator count: the first candidate of the smallest tier whose
// onceMaxRequest.accelerator is >= the requested count and whose cpu/ram/localStorage // onceMaxRequest is >= the requested count.
// remaining are all > 0.
export const pickCandidateForAccelerator = < export const pickCandidateForAccelerator = <
C extends { C extends { cluster: string; name: string }
cluster: string;
name: string;
cpu?: { remaining?: string | null } | null;
ram?: { remaining?: string | null } | null;
localStorage?: { remaining?: string | null } | null;
}
>( >(
tiers: tiers:
| { | { onceMaxRequest: string; candidates?: C[] | null }[]
onceMaxRequest: { accelerator?: string };
candidates?: C[] | null;
}[]
| undefined | undefined
| null, | null,
count: number count: number
): C | null => { ): C | null => {
if (!tiers?.length) return null; if (!tiers?.length) return null;
const hasResources = (c: C) =>
parseQuantity(c.cpu?.remaining) > 0 &&
parseQuantity(c.ram?.remaining) > 0 &&
parseQuantity(c.localStorage?.remaining) > 0;
const sorted = [...tiers].sort( const sorted = [...tiers].sort(
(a, b) => (a, b) => parseQuantity(a.onceMaxRequest) - parseQuantity(b.onceMaxRequest)
parseQuantity(a.onceMaxRequest?.accelerator) -
parseQuantity(b.onceMaxRequest?.accelerator)
); );
const tier = sorted.find((t) =>
// count === 0 ? parseQuantity(tier.onceMaxRequest.accelerator) > count; this is CPU-only case. count === 0
for (const tier of sorted) { ? parseQuantity(t.onceMaxRequest) > count
const fits = parseQuantity(tier.onceMaxRequest?.accelerator) >= count; : parseQuantity(t.onceMaxRequest) >= count
if (!fits) continue; );
const candidate = tier.candidates?.find(hasResources); return tier?.candidates?.[0] ?? null;
if (candidate) return candidate;
}
return null;
}; };
+15 -51
View File
@@ -1,21 +1,5 @@
export type ImagePullPolicy = 'Always' | 'IfNotPresent' | 'Never'; export type ImagePullPolicy = 'Always' | 'IfNotPresent' | 'Never';
type QuanityCPU = `${number}m` | string;
type QuanityMemory =
| `${number}Ki`
| `${number}Mi`
| `${number}Gi`
| `${number}Ti`
| string;
type QuanityLocalStorage =
| `${number}Ki`
| `${number}Mi`
| `${number}Gi`
| `${number}Ti`
| string;
// instance form data // instance form data
export interface FormData { export interface FormData {
name: string; name: string;
@@ -44,6 +28,9 @@ export interface FormData {
cpu?: string; cpu?: string;
ram?: string; ram?: string;
localStorage?: string; localStorage?: string;
// Stored in the form as a number so NumberSelection's strict
// equality picks up the active item. Serialized to a string at the
// API boundary in handleFinish.
accelerator?: number | string; accelerator?: number | string;
}; };
volume: { volume: {
@@ -79,29 +66,13 @@ export interface InstanceServicePort {
protocol?: InstanceServicePortProtocol; protocol?: InstanceServicePortProtocol;
} }
export interface InstanceStatusAllocationItem {
id: string;
manufacturer: string;
accelerators: [
{
id: string;
index: number;
mode: string;
allocated: number;
}
];
}
export interface InstanceStatus { export interface InstanceStatus {
namespace?: string; namespace?: string;
phase?: string; phase?: string;
phaseMessage?: string; phaseMessage?: string;
nodeName?: string;
accessAddresses?: string[];
hostIPs?: InstanceIP[]; hostIPs?: InstanceIP[];
podIPs?: InstanceIP[]; podIPs?: InstanceIP[];
ports?: InstanceServicePort[]; ports?: InstanceServicePort[];
allocations?: InstanceStatusAllocationItem[];
} }
// instance list item // instance list item
@@ -130,23 +101,16 @@ export interface InstanceTypeCandidate {
localStorage: InstanceTypeResource; localStorage: InstanceTypeResource;
} }
export interface InstanceTypeTierOnceMaxRequestResource {
accelerator?: string;
cpu: QuanityCPU;
ram: QuanityMemory;
localStorage: QuanityLocalStorage;
}
export interface InstanceTypeTier { export interface InstanceTypeTier {
onceMaxRequest: InstanceTypeTierOnceMaxRequestResource; onceMaxRequest: string;
candidates?: InstanceTypeCandidate[] | null; candidates?: InstanceTypeCandidate[] | null;
} }
export interface InstanceTypeOnceMaxRequestResource { export interface InstanceTypeRemainingResource {
accelerator?: `${number}` | null; accelerator?: string | null;
cpu: QuanityCPU; cpu: string;
ram: QuanityMemory; ram: string;
localStorage: QuanityLocalStorage; localStorage: string;
} }
export interface InstanceTypeSpec { export interface InstanceTypeSpec {
@@ -158,14 +122,10 @@ export interface InstanceTypeSpec {
family?: string | null; family?: string | null;
computeCapability?: string | null; computeCapability?: string | null;
sliced?: string | null; sliced?: string | null;
unitResources?: {
cpu: QuanityCPU;
ram: QuanityMemory;
};
} }
export interface InstanceTypeStatus { export interface InstanceTypeStatus {
onceMaxRequest: InstanceTypeOnceMaxRequestResource; remaining: InstanceTypeRemainingResource;
acceleratorTiers?: InstanceTypeTier[] | null; acceleratorTiers?: InstanceTypeTier[] | null;
} }
@@ -173,10 +133,14 @@ export interface InstanceTypeItem {
name: string; name: string;
spec: InstanceTypeSpec; spec: InstanceTypeSpec;
disabled?: boolean; disabled?: boolean;
maxAccelerator?: number;
status: InstanceTypeStatus; status: InstanceTypeStatus;
} }
// =========== Events / logs (placeholder) ===========
// The /v2/gpu-instances API does not yet expose log/event endpoints. These
// shapes are retained as minimal placeholders so disabled UI continues to
// compile until backend support lands.
export interface InstanceEventItem { export interface InstanceEventItem {
type?: string; type?: string;
reason?: string; reason?: string;
+122 -156
View File
@@ -1,10 +1,6 @@
import { addSSHKeyPageAtom } from '@/atoms/gpuservice';
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import {
ceilMilliToCore,
parseQuantityToGi,
parseQuantityToNumber
} from '@/pages/gpu-service/utils';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { import {
CheckboxField, CheckboxField,
@@ -18,8 +14,9 @@ import {
useScrollActiveChange, useScrollActiveChange,
useWrapperContext useWrapperContext
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl, useNavigate } from '@umijs/max';
import { Button, Flex, Form } from 'antd'; import { Button, Empty, Form } from 'antd';
import { useSetAtom } from 'jotai';
import _ from 'lodash'; import _ from 'lodash';
import { import {
forwardRef, forwardRef,
@@ -29,7 +26,6 @@ import {
useRef, useRef,
useState useState
} from 'react'; } from 'react';
import { FormData as PublicKeyFormData } from '../../public-keys/config/types';
import useQuerySshkeys from '../../public-keys/services/use-query-sshkeys'; import useQuerySshkeys from '../../public-keys/services/use-query-sshkeys';
import { DefaultImagePullPolicy } from '../../templates/config'; import { DefaultImagePullPolicy } from '../../templates/config';
import TemplateBasicForm, { import TemplateBasicForm, {
@@ -40,7 +36,6 @@ import { FormData, InstanceTypeItem, ListItem } from '../config/types';
import instanceStyles from '../styles/instances.module.less'; import instanceStyles from '../styles/instances.module.less';
import Basic from './basic'; import Basic from './basic';
import InstanceTypeFormItem from './instance-type'; import InstanceTypeFormItem from './instance-type';
import PublicKeyOverlay from './public-key-overlay';
import StorageVolume from './storage-volume'; import StorageVolume from './storage-volume';
const SSH_PORT = 22; const SSH_PORT = 22;
@@ -62,6 +57,35 @@ interface InstanceFormProps {
onFinish: (values: FormData) => Promise<void>; onFinish: (values: FormData) => Promise<void>;
} }
const parseQuantityToNumber = (value?: string | null): number | null => {
if (!value) return null;
const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value));
if (!match) return null;
const num = Number(match[1]);
return Number.isFinite(num) && num > 0 ? num : null;
};
// Converts a K8s-style quantity ("24314504Ki", "2Ti", "10Gi", or a bare
// number assumed to be Gi) to an integer GiB. memory and localStorage
// inputs are displayed in GB, so the max bound and the "Remaining {count}
// GB" label need the converted value.
const GI_FROM_UNIT: Record<string, number> = {
Ki: 1 / (1024 * 1024),
Mi: 1 / 1024,
Gi: 1,
Ti: 1024
};
const parseQuantityToGi = (value?: string | null): number | null => {
if (!value) return null;
const match = /^(-?\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/.exec(String(value));
if (!match) return null;
const num = Number(match[1]);
if (!Number.isFinite(num) || num <= 0) return null;
const factor = match[2] ? GI_FROM_UNIT[match[2]] : 1;
return Math.floor(num * factor);
};
const TABKeysMap = { const TABKeysMap = {
BASIC: 'basic', BASIC: 'basic',
INSTANCE_TYPE: 'instanceType', INSTANCE_TYPE: 'instanceType',
@@ -84,11 +108,7 @@ const requiredFields = {
}, },
[TABKeysMap.STORAGE]: { [TABKeysMap.STORAGE]: {
sort: 4, sort: 4,
fields: [ fields: ['spec.volume.persistent.name', 'spec.volume.ephemeral.capacity']
'spec.volume.persistent.name',
'spec.volume.ephemeral.capacity',
'spec.sshPublicKeys'
]
} }
}; };
@@ -104,6 +124,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
onFinish onFinish
} = props; } = props;
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigate();
const setAddSSHKeyPage = useSetAtom(addSSHKeyPageAtom);
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const [form] = Form.useForm<InstanceFormValues>(); const [form] = Form.useForm<InstanceFormValues>();
const scrollTabsRef = useRef<any>(null); const scrollTabsRef = useRef<any>(null);
@@ -111,7 +133,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
realAction === PageAction.CREATE ? PageAction.CREATE : action; realAction === PageAction.CREATE ? PageAction.CREATE : action;
const sshEnabled = Form.useWatch('enable_ssh', form); const sshEnabled = Form.useWatch('enable_ssh', form);
const { sshkeyOptions, fetchData: fetchSSHData } = useQuerySshkeys(); const { sshkeyOptions, fetchData: fetchSSHData } = useQuerySshkeys();
const [sshOverlayOpen, setSshOverlayOpen] = useState(false);
const { getScrollElementScrollableHeight } = useWrapperContext(); const { getScrollElementScrollableHeight } = useWrapperContext();
const { const {
activeKey, activeKey,
@@ -214,39 +235,18 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
); );
setSelectedInstanceType(instanceType); setSelectedInstanceType(instanceType);
setOnceMaxRequest({ setOnceMaxRequest({
cpu: ceilMilliToCore(candidate?.cpu?.onceMaxRequest), cpu: parseQuantityToNumber(candidate?.cpu?.onceMaxRequest),
memory: parseQuantityToGi(candidate?.ram?.onceMaxRequest), memory: parseQuantityToGi(candidate?.ram?.onceMaxRequest),
localStorage: parseQuantityToGi(candidate?.localStorage?.onceMaxRequest) localStorage: parseQuantityToGi(candidate?.localStorage?.onceMaxRequest)
}); });
const unitResources = instanceType.spec?.unitResources;
const unitCpu = parseQuantityToNumber(unitResources?.cpu);
const unitRam = parseQuantityToNumber(unitResources?.ram);
const acceleratable = instanceType.spec?.acceleratable;
const cpuCores =
unitCpu != null ? ceilMilliToCore(`${count * unitCpu}m`) : null;
const ramGi =
unitRam != null ? parseQuantityToGi(`${count * unitRam}Mi`) : null;
form.setFieldsValue({ form.setFieldsValue({
clusterId: candidate?.cluster ? _.toNumber(candidate.cluster) : null, clusterId: candidate?.cluster ? _.toNumber(candidate.cluster) : null,
spec: { spec: {
type: candidate?.name || '', type: candidate?.name || '',
...(options.writeAccelerator ...(options.writeAccelerator
? { resources: { accelerator: _.toString(count) } } ? { resources: { accelerator: _.toString(count) } }
: {}),
...(acceleratable
? {
resources: {
...(options.writeAccelerator
? { accelerator: _.toString(count) }
: {}),
...(cpuCores != null ? { cpu: cpuCores } : {}),
...(ramGi != null ? { ram: `${ramGi}Gi` } : {})
}
}
: {}) : {})
} as any } as any
}); });
@@ -301,20 +301,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
} }
}, [action, currentData, form, open, realAction, instanceTypeList]); }, [action, currentData, form, open, realAction, instanceTypeList]);
const getUnitResources = () => {
if (selectedInstanceType?.spec?.unitResources) {
return selectedInstanceType.spec.unitResources;
}
try {
return (
JSON.parse(currentData?.description || '{}')?.spec?.unitResources ??
undefined
);
} catch {
return undefined;
}
};
const handleFinish = async (values: InstanceFormValues) => { const handleFinish = async (values: InstanceFormValues) => {
const submittedPorts = [...(values.spec?.ports ?? [])]; const submittedPorts = [...(values.spec?.ports ?? [])];
const submittedHasSSHPort = submittedPorts.some( const submittedHasSSHPort = submittedPorts.some(
@@ -328,27 +314,11 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
name: 'SSH' name: 'SSH'
}); });
} }
const accelerator = _.toNumber(values.spec?.resources?.accelerator);
const submittedResources = { ...(values.spec?.resources ?? {}) };
if (accelerator > 0) {
const unitResources = getUnitResources();
const unitCpu = parseQuantityToNumber(unitResources?.cpu);
const unitRam = parseQuantityToNumber(unitResources?.ram);
if (unitCpu != null) {
submittedResources.cpu = `${accelerator * unitCpu}m`;
}
if (unitRam != null) {
submittedResources.ram = `${accelerator * unitRam}Mi`;
}
}
await onFinish({ await onFinish({
..._.omit(values, ['enable_ssh']), ..._.omit(values, ['enable_ssh']),
spec: { spec: {
...values.spec, ...values.spec,
ports: submittedPorts, ports: submittedPorts
resources: submittedResources
} }
}); });
}; };
@@ -382,23 +352,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
})); }));
const handleAddSSHKey = () => { const handleAddSSHKey = () => {
setSshOverlayOpen(true); setAddSSHKeyPage({ create: true });
}; // to go ssh key page
navigate('/gpu-service/public-keys');
const handleCreateSSHKey = async (values: PublicKeyFormData) => {
try {
await fetchSSHData({ page: -1 });
const current: Array<{ name: string } | string> =
form.getFieldValue(['spec', 'sshPublicKeys']) || [];
form.setFieldValue(
['spec', 'sshPublicKeys'],
[...current, { name: values.name }]
);
setSshOverlayOpen(false);
} catch (error) {
// it's handled in interceptor
}
}; };
const handleOnEnableSSHChange = (e: any) => { const handleOnEnableSSHChange = (e: any) => {
@@ -473,7 +429,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
disabled={disabled} disabled={disabled}
selectedInstanceType={selectedInstanceType} selectedInstanceType={selectedInstanceType}
currentData={currentData as any} currentData={currentData as any}
onceMaxRequest={onceMaxRequest}
onGPUCountChange={handleAcceleratorChange} onGPUCountChange={handleAcceleratorChange}
/> />
) )
@@ -508,79 +463,90 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
]} ]}
/> />
<Flex align="center" justify="space-between"> <Form.Item<FormData>
<Form.Item<FormData> name="enable_ssh"
name="enable_ssh" valuePropName="checked"
valuePropName="checked" style={{ marginBottom: 8 }}
style={{ marginBottom: 8 }} >
> <CheckboxField
<CheckboxField onChange={handleOnEnableSSHChange}
onChange={handleOnEnableSSHChange} disabled={disabled}
disabled={disabled} label={intl.formatMessage({
label={intl.formatMessage({ id: 'gpuservice.instance.ssh.enable'
id: 'gpuservice.instance.ssh.enable'
})}
></CheckboxField>
</Form.Item>
<Button
size="small"
type="link"
icon={<PlusOutlined />}
onClick={handleAddSSHKey}
>
{intl.formatMessage({
id: 'gpuservice.instance.ssh.addKey'
})} })}
</Button> ></CheckboxField>
</Flex> </Form.Item>
{
<div className={instanceStyles.sshkeySelection}> <div className={instanceStyles.sshkeySelection}>
<Form.Item<FormData> <Form.Item<FormData>
name={['spec', 'sshPublicKeys']} name={['spec', 'sshPublicKeys']}
style={{ style={{
marginBottom: 12 marginBottom: 12
}}
hidden={!sshEnabled}
normalize={(value) =>
Array.isArray(value)
? value?.map((item) => ({ name: item }))
: []
}
getValueProps={(value) => ({
value: Array.isArray(value)
? value.map((item) => item?.name ?? item)
: []
})}
rules={[
{
required: sshEnabled,
message: getRuleMessage('select', 'gpuservice.publicKey')
}
]}
>
<MultipleSelect
required
disabled={disabled}
mode="multiple"
maxTagCount={1}
label={intl.formatMessage({
id: 'gpuservice.instance.ssh.assignKey'
})}
styles={{
wrapper: {
width: '100%'
}
}} }}
options={sshkeyOptions} hidden={!sshEnabled}
></MultipleSelect> normalize={(value) =>
</Form.Item> Array.isArray(value)
</div> ? value?.map((item) => ({ name: item }))
: []
}
getValueProps={(value) => ({
value: Array.isArray(value)
? value.map((item) => item?.name ?? item)
: []
})}
rules={[
{
required: sshEnabled,
message: getRuleMessage('select', 'gpuservice.publicKey')
}
]}
>
<MultipleSelect
required
disabled={disabled}
mode="multiple"
maxTagCount={1}
label={intl.formatMessage({
id: 'gpuservice.instance.ssh.assignKey'
})}
styles={{
wrapper: {
width: '100%'
}
}}
options={sshkeyOptions}
notFoundContent={
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={intl.formatMessage({
id: 'noresult.gpuservice.sshkey.title'
})}
styles={{
image: {
height: 32
},
root: {
margin: 0
}
}}
>
<Button
size="small"
type="link"
icon={<PlusOutlined />}
onClick={handleAddSSHKey}
>
{intl.formatMessage({
id: 'gpuservice.instance.ssh.addKey'
})}
</Button>
</Empty>
}
></MultipleSelect>
</Form.Item>
</div>
}
</Form> </Form>
<PublicKeyOverlay
open={sshOverlayOpen}
onCancel={() => setSshOverlayOpen(false)}
onSubmit={handleCreateSSHKey}
/>
</ScrollSpyTabs> </ScrollSpyTabs>
); );
} }
@@ -1,14 +1,11 @@
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import NumberSelection from '@/pages/_components/number-selection'; import NumberSelection from '@/pages/_components/number-selection';
import { InputNumber } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Alert, Flex, Form } from 'antd'; import { Alert, Flex, Form } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { useMemo } from 'react'; import { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { BasicResourceMax } from '../../templates/forms/basic';
import { ceilMilliToCore, parseQuantityToGi } from '../../utils';
import InstanceTypeItem from '../components/instance-type-item'; import InstanceTypeItem from '../components/instance-type-item';
import { getAcceleratorMax } from '../config'; import { getAcceleratorMax } from '../config';
import { import {
@@ -54,7 +51,6 @@ interface InstanceTypeFormItemProps {
disabled?: boolean; disabled?: boolean;
currentData?: ListItem; currentData?: ListItem;
selectedInstanceType?: InstanceTypeItemModel; selectedInstanceType?: InstanceTypeItemModel;
onceMaxRequest?: BasicResourceMax;
onGPUCountChange?: (value: number) => void; onGPUCountChange?: (value: number) => void;
} }
@@ -63,7 +59,6 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
disabled, disabled,
currentData, currentData,
selectedInstanceType, selectedInstanceType,
onceMaxRequest,
onGPUCountChange onGPUCountChange
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@@ -73,45 +68,17 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
? _.toNumber(currentData?.spec?.resources?.accelerator) || 0 ? _.toNumber(currentData?.spec?.resources?.accelerator) || 0
: getAcceleratorMax(selectedInstanceType?.status?.acceleratorTiers); : getAcceleratorMax(selectedInstanceType?.status?.acceleratorTiers);
const isGPU = useMemo(() => { const handleOnGPUCountChange = (value: number) => {
onGPUCountChange?.(value);
};
const showGPUCount = useMemo(() => {
if (action === PageAction.EDIT) { if (action === PageAction.EDIT) {
return _.toNumber(currentData?.spec?.resources?.accelerator) > 0; return _.toNumber(currentData?.spec?.resources?.accelerator) > 0;
} }
return selectedInstanceType?.spec?.acceleratable; return selectedInstanceType?.spec?.acceleratable;
}, [selectedInstanceType, action]); }, [selectedInstanceType, action]);
const handleOnGPUCountChange = (value: number) => {
onGPUCountChange?.(value);
};
const renderMaxLabel = (
label: React.ReactNode,
max?: number | null
): React.ReactNode => {
if (max == null) return label;
return (
<Flex gap={4} align="center">
{label}
{!isGPU && (
<span>
({intl.formatMessage({ id: 'common.max' }, { count: max })})
</span>
)}
</Flex>
);
};
const renderMemoryLabel = (): React.ReactNode => {
if (isGPU) {
return intl.formatMessage({ id: 'gpuservice.template.memory' });
}
return intl.formatMessage(
{ id: 'gpuservice.instance.memory.remaining' },
{ count: onceMaxRequest?.memory }
);
};
const renderInstanceType = () => { const renderInstanceType = () => {
const description = JSON.parse(currentData?.description || '{}').spec || {}; const description = JSON.parse(currentData?.description || '{}').spec || {};
return ( return (
@@ -156,7 +123,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
{action === PageAction.EDIT && renderInstanceType()} {action === PageAction.EDIT && renderInstanceType()}
</Form.Item> </Form.Item>
</FieldBlock> </FieldBlock>
{isGPU && ( {showGPUCount && (
<Form.Item<FormData> <Form.Item<FormData>
name={['spec', 'resources', 'accelerator']} name={['spec', 'resources', 'accelerator']}
hidden={action === PageAction.EDIT} hidden={action === PageAction.EDIT}
@@ -195,7 +162,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
]} ]}
> >
<NumberSelection <NumberSelection
min={1} min={0}
onChange={handleOnGPUCountChange} onChange={handleOnGPUCountChange}
max={maxGpuCount} max={maxGpuCount}
step={1} step={1}
@@ -231,48 +198,6 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
/> />
</Form.Item> </Form.Item>
)} )}
<Flex gap={12}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name={['spec', 'resources', 'ram']}
normalize={(value) => (value ? `${value}Gi` : undefined)}
getValueProps={(value) => {
if (!value) return { value: '' };
const str = String(value);
if (/Gi$/.test(str)) return { value: str.replace(/Gi$/, '') };
if (/(Ki|Mi|Ti)$/.test(str)) {
return { value: parseQuantityToGi(str) ?? '' };
}
return { value: str };
}}
>
<InputNumber
disabled={isGPU || disabled}
label={renderMemoryLabel()}
max={onceMaxRequest?.memory ?? undefined}
/>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name={['spec', 'resources', 'cpu']}
normalize={(value) => (value ? `${value}` : '')}
getValueProps={(value) => {
if (!value) return { value: '' };
const str = String(value);
return {
value: /m$/.test(str) ? (ceilMilliToCore(str) ?? '') : str
};
}}
>
<InputNumber
label={renderMaxLabel('CPU', onceMaxRequest?.cpu)}
max={onceMaxRequest?.cpu ?? undefined}
disabled={disabled || isGPU}
/>
</Form.Item>
</div>
</Flex>
</div> </div>
); );
}; };
@@ -1,77 +0,0 @@
import { PageAction } from '@/config';
import FormOverlayView from '@/pages/_components/form-overlay-view';
import { ModalFooter } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useRef, useState } from 'react';
import { FormData as PublicKeyFormData } from '../../public-keys/config/types';
import GPUServicePublicKeyForm from '../../public-keys/forms';
import useCreateSshkey from '../../public-keys/services/use-create-sshkey';
import useOverlayLayout from '../hooks/use-overlay-layout';
interface PublicKeyOverlayProps {
open: boolean;
onCancel: () => void;
onSubmit: (values: PublicKeyFormData) => Promise<void> | void;
}
const PublicKeyOverlay: React.FC<PublicKeyOverlayProps> = ({
open,
onCancel,
onSubmit
}) => {
const intl = useIntl();
const [loading, setLoading] = useState(false);
const formRef = useRef<any>(null);
const { fetchData: createSshkey } = useCreateSshkey();
const { drawerWidth, getOverlayContainer } = useOverlayLayout(open);
const handleSubmit = () => {
formRef.current?.submit();
};
const handleCancel = () => {
formRef.current?.resetFields();
onCancel();
};
const handleFinish = async (values: PublicKeyFormData) => {
setLoading(true);
try {
await createSshkey({ data: values });
await onSubmit(values);
} finally {
setLoading(false);
}
};
return (
<FormOverlayView
title={intl.formatMessage({ id: 'gpuservice.publicKey.add' })}
open={open}
width={drawerWidth}
onCancel={handleCancel}
getContainer={getOverlayContainer}
footer={
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
loading={loading}
style={{
padding: '16px 24px 24px',
display: 'flex',
justifyContent: 'flex-end'
}}
/>
}
>
<GPUServicePublicKeyForm
ref={formRef}
action={PageAction.CREATE}
open={open}
onFinish={handleFinish}
/>
</FormOverlayView>
);
};
export default PublicKeyOverlay;
@@ -4,10 +4,9 @@ import { FormContext } from '@/pages/gpu-service/storage/config/form-context';
import useQueryStorageClass from '@/pages/gpu-service/storage/services/use-query-storage-class'; import useQueryStorageClass from '@/pages/gpu-service/storage/services/use-query-storage-class';
import { ModalFooter } from '@gpustack/core-ui'; import { ModalFooter } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { FormData as StorageFormData } from '../../storage/config/types'; import { FormData as StorageFormData } from '../../storage/config/types';
import GPUServiceStorageForm from '../../storage/forms'; import GPUServiceStorageForm from '../../storage/forms';
import useOverlayLayout from '../hooks/use-overlay-layout';
interface StorageOverlayProps { interface StorageOverlayProps {
open: boolean; open: boolean;
@@ -25,7 +24,6 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
useQueryStorageClass(); useQueryStorageClass();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const formRef = useRef<any>(null); const formRef = useRef<any>(null);
const { drawerWidth, getOverlayContainer } = useOverlayLayout(open);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -51,11 +49,19 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
} }
}; };
const getOverlayContainer = useCallback(() => {
const containers = document.querySelectorAll<HTMLElement>(
'.ant-layout-content'
);
return containers[containers.length - 1] ?? null;
}, []);
return ( return (
<FormOverlayView <FormOverlayView
title={intl.formatMessage({ id: 'gpuservice.storage.add' })} title={intl.formatMessage({ id: 'gpuservice.storage.add' })}
open={open} open={open}
width={drawerWidth} width={600}
onCancel={handleCancel} onCancel={handleCancel}
getContainer={getOverlayContainer} getContainer={getOverlayContainer}
footer={ footer={
@@ -1,5 +1,4 @@
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PlusOutlined } from '@ant-design/icons';
import { import {
Input as CInput, Input as CInput,
InputNumber as CInputNumber, InputNumber as CInputNumber,
@@ -136,7 +135,6 @@ const StorageVolume = ({
<Button <Button
type="link" type="link"
size="small" size="small"
icon={<PlusOutlined />}
onClick={() => setOverlayOpen(true)} onClick={() => setOverlayOpen(true)}
> >
{intl.formatMessage({ id: 'gpuservice.storage.add' })} {intl.formatMessage({ id: 'gpuservice.storage.add' })}
@@ -5,29 +5,16 @@ import {
DropdownButtons, DropdownButtons,
StatusTag StatusTag
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button, Flex } from 'antd'; import { Button, Flex } from 'antd';
import type { ColumnsType } from 'antd/lib/table'; import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import _ from 'lodash'; import _ from 'lodash';
import { Fragment, useMemo } from 'react'; import { Fragment, useMemo } from 'react';
import { ceilMilliToCore, parseQuantityToGi } from '../../utils';
import { InstanceStatusLabelMap, rowActionList, status } from '../config'; import { InstanceStatusLabelMap, rowActionList, status } from '../config';
import { ListItem } from '../config/types'; import { ListItem } from '../config/types';
import tableSyles from '../styles/table.module.less'; import tableSyles from '../styles/table.module.less';
const formatCpu = (cpu?: string | null): string => {
if (!cpu) return '-';
return /m$/.test(cpu) ? (ceilMilliToCore(cpu) ?? cpu) : cpu;
};
const formatMemoryGB = (value?: string | null): string => {
if (!value) return '-';
if (/Gi$/.test(value)) return value.replace('Gi', 'GB');
const gi = parseQuantityToGi(value);
return gi != null ? `${gi}GB` : value;
};
type ConnectEntry = type ConnectEntry =
| { | {
type: 'ssh'; type: 'ssh';
@@ -46,7 +33,7 @@ type ConnectEntry =
}; };
const getConnectEntries = (record: ListItem): ConnectEntry[] => { const getConnectEntries = (record: ListItem): ConnectEntry[] => {
const ip = record.status?.accessAddresses?.[0]; const ip = record.status?.hostIPs?.[0]?.ip;
const ports = record.status?.ports || []; const ports = record.status?.ports || [];
const configPorts = record.spec?.ports || []; const configPorts = record.spec?.ports || [];
@@ -83,7 +70,7 @@ const getConnectEntries = (record: ListItem): ConnectEntry[] => {
interface ColumnsHookProps { interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void; handleSelect: (val: string, record: ListItem) => void;
clusterList: Global.BaseOption<number>[]; clusterList: Global.BaseOption<string>[];
sortOrder: string[]; sortOrder: string[];
} }
@@ -93,42 +80,30 @@ const useInstancesColumns = ({
sortOrder sortOrder
}: ColumnsHookProps): ColumnsType<ListItem> => { }: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl(); const intl = useIntl();
const access = useAccess();
const renderInstanceType = (record: ListItem) => { const renderInstanceType = (record: ListItem) => {
const description = JSON.parse(record?.description || '{}').spec || {}; const description = JSON.parse(record?.description || '{}').spec || {};
return ( return (
<Flex align="flex-start" orientation="vertical"> <Flex align="flex-start" orientation="vertical">
<AutoTooltip <span className="text-primary">
ghost {description.acceleratable
title={ ? `${description.product} x ${record.spec?.resources?.accelerator}`
<span> : 'CPU'}
{description.acceleratable </span>
? `${description.product} x ${record.spec?.resources?.accelerator}`
: 'CPU'}
</span>
}
>
<span className="text-primary">
{description.acceleratable
? `${description.product} x ${record.spec?.resources?.accelerator}`
: 'CPU'}
</span>
</AutoTooltip>
<Flex <Flex
align="center" align="center"
style={{ fontSize: 13, color: 'var(--ant-color-text-tertiary)' }} style={{ fontSize: 13, color: 'var(--ant-color-text-tertiary)' }}
> >
<span>{formatCpu(record.spec.resources?.cpu)}C</span> <span>{record.spec.resources?.cpu}C</span>
<span className={tableSyles.dot} /> <span className={tableSyles.dot} />
<span> <span>
{intl.formatMessage({ id: 'gpuservice.instance.ram' })}:{' '} {intl.formatMessage({ id: 'gpuservice.instance.ram' })}:{' '}
{formatMemoryGB(record.spec.resources?.ram)} {record.spec.resources?.ram?.replace('Gi', 'GB')}
</span> </span>
<span className={tableSyles.dot} /> <span className={tableSyles.dot} />
<span> <span>
{intl.formatMessage({ id: 'gpuservice.instance.disk' })}:{' '} {intl.formatMessage({ id: 'gpuservice.instance.disk' })}:{' '}
{formatMemoryGB(record.spec.resources?.localStorage)} {record.spec.resources?.localStorage?.replace('Gi', 'GB')}
</span> </span>
</Flex> </Flex>
</Flex> </Flex>
@@ -147,7 +122,7 @@ const useInstancesColumns = ({
}, },
render: (text: string, record: ListItem) => ( render: (text: string, record: ListItem) => (
<AutoTooltip ghost style={{ maxWidth: 360 }}> <AutoTooltip ghost style={{ maxWidth: 360 }}>
<span className="text-primary">{record.displayName || text}</span> <span className="text-primary">{text || record.displayName}</span>
</AutoTooltip> </AutoTooltip>
) )
}, },
@@ -263,16 +238,15 @@ const useInstancesColumns = ({
ellipsis: { ellipsis: {
showTitle: false showTitle: false
}, },
width: 260, render: (_text: string, record: ListItem) => (
render: (_text: string, record: ListItem) => renderInstanceType(record) <div style={{ width: 'max-content' }}>
<AutoTooltip ghost>{renderInstanceType(record)}</AutoTooltip>
</div>
)
}, },
{ {
title: intl.formatMessage({ id: 'clusters.title' }), title: intl.formatMessage({ id: 'clusters.title' }),
dataIndex: 'clusterId', dataIndex: 'clusterId',
hidden: !access.canSeeAdmin,
ellipsis: {
showTitle: false
},
render: (id: number) => ( render: (id: number) => (
<AutoTooltip ghost maxWidth={240}> <AutoTooltip ghost maxWidth={240}>
{_.find(clusterList, { value: id })?.label || id || '-'} {_.find(clusterList, { value: id })?.label || id || '-'}
@@ -287,7 +261,6 @@ const useInstancesColumns = ({
ellipsis: { ellipsis: {
showTitle: false showTitle: false
}, },
width: 180,
render: (text: string) => ( render: (text: string) => (
<AutoTooltip ghost> <AutoTooltip ghost>
{text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '-'} {text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '-'}
@@ -1,31 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import instanceStyles from '../styles/instances.module.less';
const FALLBACK_WIDTH = `calc((100vw - 220px) / 3 + 16px)`;
const useOverlayLayout = (open: boolean) => {
const [drawerWidth, setDrawerWidth] = useState<number | string>(
FALLBACK_WIDTH
);
useEffect(() => {
if (!open) return;
const formWrapper = document.querySelector<HTMLElement>(
`.${instanceStyles.formWrapper}`
);
if (formWrapper) {
setDrawerWidth(formWrapper.clientWidth);
}
}, [open]);
const getOverlayContainer = useCallback(() => {
const containers = document.querySelectorAll<HTMLElement>(
'.ant-layout-content'
);
return containers[containers.length - 1] ?? null;
}, []);
return { drawerWidth, getOverlayContainer };
};
export default useOverlayLayout;
+4 -54
View File
@@ -1,16 +1,13 @@
import { clusterSessionAtom } from '@/atoms/clusters';
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings'; import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import useTableFetch from '@/hooks/use-table-fetch'; import useTableFetch from '@/hooks/use-table-fetch';
import { ProviderValueMap } from '@/pages/cluster-management/config';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list'; import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui'; import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
import { useAccess, useIntl, useNavigate } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, message, Modal, Table } from 'antd'; import { ConfigProvider, message, Modal, Table } from 'antd';
import { useSetAtom } from 'jotai';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect, useMemo } from 'react'; import { useEffect } from 'react';
import PageBox from '../../_components/page-box'; import PageBox from '../../_components/page-box';
import { import {
deleteGPUServiceInstance, deleteGPUServiceInstance,
@@ -30,23 +27,8 @@ import useUpdateInstance from './services/use-update-instance';
const GPUService: React.FC = () => { const GPUService: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigate();
const access = useAccess();
const setClusterSession = useSetAtom(clusterSessionAtom);
const [, modalContextHolder] = Modal.useModal(); const [, modalContextHolder] = Modal.useModal();
// Empty-state CTA: send the user to the cluster-create flow with
// Kubernetes preselected so the GPU Service feature is reachable
// in one step instead of having to pick the provider themselves.
const handleAddK8sCluster = () => {
setClusterSession({
firstAddWorker: false,
firstAddCluster: true,
providerHint: ProviderValueMap.Kubernetes
});
navigate('/cluster-management/clusters/list');
};
const { const {
dataSource, dataSource,
rowSelection, rowSelection,
@@ -89,19 +71,13 @@ const GPUService: React.FC = () => {
const { const {
fetchClusterList, fetchClusterList,
cancelRequest: cancelClusterRequest, cancelRequest: cancelClusterRequest,
clusterList, clusterList
loading: clusterLoading
} = useQueryClusterList(); } = useQueryClusterList();
useEffect(() => { useEffect(() => {
fetchClusterList({ page: -1 }); fetchClusterList({ page: -1 });
}, []); }, []);
const hasK8sCluster = useMemo(
() => clusterList.some((c) => c.provider === ProviderValueMap.Kubernetes),
[clusterList]
);
const handleModalOk = async (data: FormData) => { const handleModalOk = async (data: FormData) => {
try { try {
if (openInstanceModalStatus.realAction === PageAction.CREATE) { if (openInstanceModalStatus.realAction === PageAction.CREATE) {
@@ -153,30 +129,6 @@ const GPUService: React.FC = () => {
const renderEmpty = (type?: string) => { const renderEmpty = (type?: string) => {
if (type !== 'Table') return; if (type !== 'Table') return;
if (!clusterLoading && !hasK8sCluster) {
return (
<NoResult
loading={dataSource.loading || clusterLoading}
loadend={dataSource.loadend}
dataSource={[]}
image={<IconFont type="icon-cloud-outlined" />}
title={intl.formatMessage({
id: 'noresult.gpuservice.instance.title'
})}
subTitle={intl.formatMessage({
id: 'noresult.resources.k8sCluster'
})}
{...(access.canSeeOrgAdmin
? {
buttonText: intl.formatMessage({
id: 'noresult.resources.addk8scluster'
}),
onClick: handleAddK8sCluster
}
: {})}
/>
);
}
return ( return (
<NoResult <NoResult
loading={dataSource.loading} loading={dataSource.loading}
@@ -216,9 +168,7 @@ const GPUService: React.FC = () => {
handleInputChange={handleNameChange} handleInputChange={handleNameChange}
rowSelection={rowSelection} rowSelection={rowSelection}
widths={{ input: 300 }} widths={{ input: 300 }}
handleClickPrimary={ handleClickPrimary={openCreateInstanceModal}
hasK8sCluster ? openCreateInstanceModal : undefined
}
buttonText={intl.formatMessage({ id: 'gpuservice.instance.add' })} buttonText={intl.formatMessage({ id: 'gpuservice.instance.add' })}
handleDeleteByBatch={handleDeleteBatch} handleDeleteByBatch={handleDeleteBatch}
/> />
@@ -25,16 +25,8 @@ export default function useQueryInstanceTypes() {
const [dataList, setDataList] = React.useState<InstanceType[]>([]); const [dataList, setDataList] = React.useState<InstanceType[]>([]);
const isAvailable = (item: InstanceTypeItem) => { const isAvailable = (item: InstanceTypeItem) => {
if (!item.spec?.acceleratable) if (!item.spec?.acceleratable) return true;
return { return getAcceleratorMax(item.status?.acceleratorTiers) > 0;
maxAccelerator: 0,
available: true
};
const max = getAcceleratorMax(item.status?.acceleratorTiers);
return {
maxAccelerator: max || 0,
available: (max || 0) > 0
};
}; };
const queryInstanceTypes = async ( const queryInstanceTypes = async (
@@ -42,14 +34,10 @@ export default function useQueryInstanceTypes() {
) => { ) => {
const res = await fetchData(params); const res = await fetchData(params);
const list = (res?.items || []).map((item) => { const list = (res?.items || []).map((item) => ({
const remainingData = isAvailable(item); ...item,
return { disabled: !isAvailable(item)
...item, }));
maxAccelerator: remainingData.maxAccelerator,
disabled: !remainingData.available
};
});
console.log('queryInstanceTypes', list); console.log('queryInstanceTypes', list);
setDataList(list); setDataList(list);
return list; return list;
@@ -67,7 +67,6 @@ const usePublicKeyColumns = ({
title: intl.formatMessage({ id: 'common.table.operation' }), title: intl.formatMessage({ id: 'common.table.operation' }),
key: 'operation', key: 'operation',
dataIndex: 'operation', dataIndex: 'operation',
width: 200,
render: (_text, record) => ( render: (_text, record) => (
<DropdownButtons <DropdownButtons
items={rowActionList} items={rowActionList}
@@ -42,13 +42,7 @@ const GPUServiceStorageTypes: React.FC = () => {
watch: false, watch: false,
polling: false, polling: false,
API: GPU_SERVICE_STORAGE_TYPE_API, API: GPU_SERVICE_STORAGE_TYPE_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.storageType' }), contentForDelete: intl.formatMessage({ id: 'gpuservice.storageType' })
defaultQueryParams: {
// Management view: drop types reachable only via cross-Org
// cluster_access. The PV-create picker queries without ``mine``
// and still sees those for use.
mine: true
}
}); });
const { fetchData: createStorageType } = useCreateStorageType(); const { fetchData: createStorageType } = useCreateStorageType();
@@ -91,23 +91,7 @@ export const stringifyCommand = (tokens?: string[]): string => {
.join(' '); .join(' ');
}; };
// No ``access`` gates on these menu items: the management page export const templateActions = [
// queries the list API with ``mine=true``, so every row already
// belongs to a scope the caller can manage — no per-item filtering
// needed here.
//
// ``icon`` is narrowed to ``any`` so the inferred declaration type
// for the array doesn't reach into
// ``@ant-design/icons/lib/components/AntdIcon`` (the internal path
// the antd icon component types live at). The other fields keep
// their precise types.
export const templateActions: Array<{
label: string;
key: string;
locale: boolean;
icon: any;
danger?: boolean;
}> = [
{ {
label: 'common.button.edit', label: 'common.button.edit',
key: 'edit', key: 'edit',
@@ -49,6 +49,21 @@ const Basic: React.FC<BasicProps> = ({
[] []
); );
const renderMaxLabel = (
label: React.ReactNode,
max?: number | null
): React.ReactNode => {
if (max == null) return label;
return (
<Flex gap={4} align="center">
{label}
<span>
({intl.formatMessage({ id: 'common.max' }, { count: max })})
</span>
</Flex>
);
};
const renderStorageLabel = (): React.ReactNode => { const renderStorageLabel = (): React.ReactNode => {
if (page === 'instance' && onceMaxRequest?.localStorage != null) { if (page === 'instance' && onceMaxRequest?.localStorage != null) {
return intl.formatMessage( return intl.formatMessage(
@@ -59,6 +74,16 @@ const Basic: React.FC<BasicProps> = ({
return intl.formatMessage({ id: 'gpuservice.template.containerDisk' }); return intl.formatMessage({ id: 'gpuservice.template.containerDisk' });
}; };
const renderMemoryLabel = (): React.ReactNode => {
if (page === 'instance' && onceMaxRequest?.memory != null) {
return intl.formatMessage(
{ id: 'gpuservice.instance.memory.remaining' },
{ count: onceMaxRequest.memory }
);
}
return intl.formatMessage({ id: 'gpuservice.template.memory' });
};
return ( return (
<> <>
<div data-field="template"></div> <div data-field="template"></div>
@@ -202,6 +227,37 @@ const Basic: React.FC<BasicProps> = ({
</Form.Item> </Form.Item>
</div> </div>
</Flex> </Flex>
<Flex gap={12}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name={['spec', 'resources', 'cpu']}
normalize={(value) => (value ? `${value}` : '')}
getValueProps={(value) => ({ value: value ? String(value) : '' })}
>
<InputNumber
label={renderMaxLabel('CPU', onceMaxRequest?.cpu)}
max={onceMaxRequest?.cpu ?? undefined}
disabled={disabled}
/>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name={['spec', 'resources', 'ram']}
normalize={(value) => (value ? `${value}Gi` : undefined)}
getValueProps={(value) => ({
value: value ? String(value).replace(/Gi$/, '') : ''
})}
>
<InputNumber
label={renderMemoryLabel()}
max={onceMaxRequest?.memory ?? undefined}
disabled={disabled}
/>
</Form.Item>
</div>
</Flex>
<Ports disabled={disabled} /> <Ports disabled={disabled} />
<Env disabled={disabled} /> <Env disabled={disabled} />
</> </>
@@ -59,7 +59,6 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
form={form} form={form}
onFinish={handleFinish} onFinish={handleFinish}
preserve={false} preserve={false}
scrollToFirstError
initialValues={{ initialValues={{
spec: { spec: {
imagePullPolicy: DefaultImagePullPolicy, imagePullPolicy: DefaultImagePullPolicy,
+1 -7
View File
@@ -45,13 +45,7 @@ const GPUServiceTemplates: React.FC = () => {
isInfiniteScroll: true, isInfiniteScroll: true,
contentForDelete: intl.formatMessage({ id: 'gpuservice.template' }), contentForDelete: intl.formatMessage({ id: 'gpuservice.template' }),
defaultQueryParams: { defaultQueryParams: {
perPage: 24, perPage: 24
// Management view: drop Global rows for non-admin callers — the
// page is a CRUD surface, and admin-curated Global templates
// they can't edit only add visual noise. The instance-create
// picker (uses ``useQueryTemplates`` separately) doesn't set
// this and so still sees Global presets.
mine: true
} }
}); });
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } = const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
-40
View File
@@ -11,43 +11,3 @@ export const omitPathParams = <T extends Record<string, any>>(
'cluster_id' 'cluster_id'
]) as Omit<T, 'namespace' | 'clusterID'>; ]) as Omit<T, 'namespace' | 'clusterID'>;
}; };
export const parseQuantityToNumber = (value?: string | null): number | null => {
if (!value) return null;
const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value));
if (!match) return null;
const num = Number(match[1]);
return Number.isFinite(num) && num > 0 ? num : null;
};
const GI_FROM_UNIT: Record<string, number> = {
Ki: 1 / (1024 * 1024),
Mi: 1 / 1024,
Gi: 1,
Ti: 1024
};
// Converts a K8s-style quantity ("24314504Ki", "2Ti", "10Gi", or a bare
// number assumed to be bytes) to an integer GiB. Unit suffix is
// case-insensitive.
export const parseQuantityToGi = (value?: string | null): number | null => {
if (!value) return null;
const match = /^(-?\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/i.exec(String(value));
if (!match) return null;
const num = Number(match[1]);
if (!Number.isFinite(num) || num <= 0) return null;
const unit = match[2]
? match[2][0].toUpperCase() + match[2].slice(1).toLowerCase()
: null;
const factor = unit ? GI_FROM_UNIT[unit] : 1 / (1024 * 1024 * 1024);
return Math.floor(num * factor);
};
export const ceilMilliToCore = (value?: string | null): string | null => {
if (!value) return null;
const match = /^(-?\d+(?:\.\d+)?)m?$/.exec(value);
if (!match) return null;
const num = Number(match[1]);
if (!Number.isFinite(num)) return null;
return String(Math.ceil(num / 1000));
};
+5 -12
View File
@@ -445,18 +445,11 @@ export async function queryModelAccessUserList(id: number) {
// The response carries `access_policy` alongside `items` so the // The response carries `access_policy` alongside `items` so the
// Access Settings dialog can refresh both halves from a single // Access Settings dialog can refresh both halves from a single
// GET (the calling list snapshot may be stale after a prior // GET (the calling list snapshot may be stale after a prior
// save). `principals` is the full grant set (any kind) used by the // save).
// principal-based override; `items` stays the USER-only subset. return request<{ items: UserListItem[]; access_policy?: string }>(
return request<{ `${MODEL_ROUTES}/${id}/access`,
items: UserListItem[]; { method: 'GET' }
access_policy?: string; );
principals?: {
principal_type: string;
principal_id: number;
principal_name?: string;
principal_display_name?: string;
}[];
}>(`${MODEL_ROUTES}/${id}/access`, { method: 'GET' });
} }
export async function updateModelAccessUser(params: { export async function updateModelAccessUser(params: {
@@ -34,16 +34,6 @@ import { AccessControlFormData } from '../../config/types';
type TransferKey = string | number | bigint; type TransferKey = string | number | bigint;
// The "specific users" policy is now ALLOWED_PRINCIPALS with a
// user-only grant list — the same value the principal-based override
// (when a plugin provides one) uses, so the two interoperate.
// `allowed_users` is the deprecated value released in v2.1.x; normalize
// it so legacy routes still select the "specific users" radio (they
// converge to ALLOWED_PRINCIPALS on save).
export const ALLOWED_PRINCIPALS_POLICY = 'allowed_principals';
const normalizeAccessPolicy = (p?: string) =>
p === 'allowed_users' ? ALLOWED_PRINCIPALS_POLICY : p;
const buildAccessScopeTips = ( const buildAccessScopeTips = (
override?: AllowedUsersOverride, override?: AllowedUsersOverride,
prepended: PrependedPolicy[] = [] prepended: PrependedPolicy[] = []
@@ -226,7 +216,7 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
const handleOnPolicyChange = async (e: RadioChangeEvent) => { const handleOnPolicyChange = async (e: RadioChangeEvent) => {
console.log('policy changed:', e.target.value); console.log('policy changed:', e.target.value);
const policy = e.target.value; const policy = e.target.value;
if (policy === ALLOWED_PRINCIPALS_POLICY) { if (policy === 'allowed_users') {
form.setFieldsValue({ users: formDataCacheRef.current?.users || [] }); form.setFieldsValue({ users: formDataCacheRef.current?.users || [] });
} else { } else {
formDataCacheRef.current = { formDataCacheRef.current = {
@@ -266,31 +256,16 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
// server's authoritative value, which is what survives a save // server's authoritative value, which is what survives a save
// when the parent list hasn't been refreshed. // when the parent list hasn't been refreshed.
form.setFieldsValue({ form.setFieldsValue({
access_policy: normalizeAccessPolicy(currentData?.access_policy) access_policy: currentData?.access_policy
}); });
queryModelAccessUserList(currentData.id).then((res) => { queryModelAccessUserList(currentData.id).then((res) => {
// Fall back to the legacy `items` (USER-only) field when an const keys = res.items.map((item) => item.id);
// older backend doesn't return `principals` yet. setTargetKeys(keys);
const principals =
res.principals ??
res.items?.map((item) => ({
principal_type: 'user',
principal_id: item.id
})) ??
[];
// Derive the user picker's selection from the unified
// `principals` set (USER-kind subset), not the deprecated
// `items` field. `principals` is also kept whole so a save can
// preserve any non-user grants it doesn't manage.
const userKeys = principals
.filter((p) => p.principal_type === 'user')
.map((p) => p.principal_id);
setTargetKeys(userKeys);
let hasAdmin = false; let hasAdmin = false;
let hasInactive = false; let hasInactive = false;
for (const key of userKeys) { for (const key of keys) {
const user = userMap.get(key); const user = userMap.get(key);
if (!user) continue; if (!user) continue;
if (user.is_admin) hasAdmin = true; if (user.is_admin) hasAdmin = true;
@@ -305,14 +280,8 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
setFilterInUsers(filterSet); setFilterInUsers(filterSet);
form.setFieldsValue({ form.setFieldsValue({
access_policy: normalizeAccessPolicy( access_policy: res.access_policy ?? currentData.access_policy,
res.access_policy ?? currentData.access_policy users: res.items.map((item) => ({ id: item.id }))
),
users: userKeys.map((id) => ({ id })),
// Keep the full grant set: read by the principal-based
// override Field, and used on save to preserve non-user
// grants when the user picker submits `principals`.
principals
}); });
}); });
} else { } else {
@@ -371,11 +340,7 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
return ( return (
<Form <Form
form={form} form={form}
// Submit the full field store (getFieldsValue(true)), not just the onFinish={onFinish}
// registered fields onFinish would pass: the principal-based
// override Field manages `principals` via setFieldsValue without a
// registered Form.Item, so it would otherwise be dropped on save.
onFinish={() => onFinish(form.getFieldsValue(true))}
preserve={true} preserve={true}
clearOnDestroy={true} clearOnDestroy={true}
scrollToFirstError={true} scrollToFirstError={true}
@@ -431,7 +396,7 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.accessSettings.allowedUsers' id: 'models.accessSettings.allowedUsers'
}), }),
value: ALLOWED_PRINCIPALS_POLICY value: 'allowed_users'
}, },
{ {
label: intl.formatMessage({ label: intl.formatMessage({
@@ -459,14 +424,15 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
action={action} action={action}
/> />
)} )}
{allowedUsersOverride && accessPolicy === overridePolicyValue && ( {allowedUsersOverride &&
<allowedUsersOverride.Field accessPolicy === overridePolicyValue && (
form={form} <allowedUsersOverride.Field
routeId={currentData?.id} form={form}
action={action} routeId={currentData?.id}
/> action={action}
)} />
{!allowedUsersOverride && accessPolicy === ALLOWED_PRINCIPALS_POLICY && ( )}
{!allowedUsersOverride && accessPolicy === 'allowed_users' && (
<> <>
<Label> <Label>
{intl.formatMessage({ id: 'models.table.userSelection' })} {intl.formatMessage({ id: 'models.table.userSelection' })}
@@ -1,6 +1,5 @@
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { RouteItem } from '@/pages/model-routes/config/types'; import { RouteItem } from '@/pages/model-routes/config/types';
import { getGPUStackPlugin } from '@/plugins';
import { AlertBlockInfo, FormDrawer, ModalFooter } from '@gpustack/core-ui'; import { AlertBlockInfo, FormDrawer, ModalFooter } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { message } from 'antd'; import { message } from 'antd';
@@ -8,7 +7,7 @@ import _ from 'lodash';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { updateModelAccessUser } from '../../apis'; import { updateModelAccessUser } from '../../apis';
import { AccessControlFormData } from '../../config/types'; import { AccessControlFormData } from '../../config/types';
import AccessControlForm, { ALLOWED_PRINCIPALS_POLICY } from './form'; import AccessControlForm from './form';
const AccessControlModal: React.FC< const AccessControlModal: React.FC<
Global.ScrollerModalProps<RouteItem, AccessControlFormData> Global.ScrollerModalProps<RouteItem, AccessControlFormData>
@@ -24,55 +23,17 @@ const AccessControlModal: React.FC<
const handleOnFinish = async (values: AccessControlFormData) => { const handleOnFinish = async (values: AccessControlFormData) => {
try { try {
// Everything saves through the single `/access` POST as const data: AccessControlFormData = {
// `principals` (one request, one success toast): access_policy: values.access_policy,
// * principal-based override active → its staged full grant set. // `users` is only meaningful for the legacy `allowed_users`
// * user picker → selected users mapped to USER-kind grants, // policy; for the plugin override (typically the principal-
// plus any non-user grants preserved from the GET snapshot // based policy) the plugin's Field manages its own state
// (so a user-only UI never wipes org/group grants it can't // inline via the principal CRUD endpoints, so we send an
// see — though in practice the picker only runs where none // empty list to clear any stale user grants from a prior
// exist). // policy switch.
// * otherwise (authed/public) → send neither, so the server users:
// leaves existing grants untouched. values.access_policy === 'allowed_users' ? values.users || [] : []
const override = getGPUStackPlugin()?.accessControl?.allowedUsersOverride; };
const usesPrincipals =
!!override && values.access_policy === override.policyValue;
const usesUserList =
!override && values.access_policy === ALLOWED_PRINCIPALS_POLICY;
// Guard the load race: on an existing route, `principals` is
// undefined until the GET seeds it. Saving in that window would
// submit an empty grant set and wipe existing grants — bail out
// (the modal stays open; the user can retry once loaded).
if (
currentData?.id &&
(usesPrincipals || usesUserList) &&
!values.principals
) {
return;
}
let data: AccessControlFormData;
if (usesPrincipals) {
data = {
access_policy: values.access_policy,
principals: values.principals || []
};
} else if (usesUserList) {
const preserved = (values.principals || []).filter(
(p) => p.principal_type !== 'user'
);
const userGrants = (values.users || []).map((u) => ({
principal_type: 'user',
principal_id: u.id
}));
data = {
access_policy: values.access_policy,
principals: [...preserved, ...userGrants]
};
} else {
data = { access_policy: values.access_policy };
}
await updateModelAccessUser({ await updateModelAccessUser({
id: currentData?.id as number, id: currentData?.id as number,
data: data data: data
@@ -102,7 +102,7 @@ const ActionsCell: React.FC<ActionsCellProps> = ({
) => { ) => {
api.open({ api.open({
duration: data.duration, duration: data.duration,
title: renderMessage(data.filename), message: renderMessage(data.filename),
key: data.filename, key: data.filename,
closeIcon: ( closeIcon: (
<span>{intl.formatMessage({ id: 'common.button.cancel' })}</span> <span>{intl.formatMessage({ id: 'common.button.cancel' })}</span>
@@ -115,7 +115,7 @@ const NameCell: React.FC<NameCellProps> = ({
styles styles
}) => { }) => {
return ( return (
<span className="instance-name flex-center" style={{ gap: 4 }}> <span className="flex-center instance-name">
<AutoTooltip title={record.name} ghost> <AutoTooltip title={record.name} ghost>
<span className="m-r-5" style={styles?.label}> <span className="m-r-5" style={styles?.label}>
{record.name} {record.name}
+1 -2
View File
@@ -415,8 +415,7 @@ const Models: React.FC<ModelsProps> = ({
handleOpenPlayGround({ handleOpenPlayGround({
categories: row.categories || [], categories: row.categories || [],
name: targetRoute?.route_name || '', name: targetRoute?.route_name || ''
owner_principal_id: row.owner_principal_id
}); });
} }
if (val === 'metrics') { if (val === 'metrics') {
@@ -30,7 +30,6 @@ interface FormContextProps {
flatBackendOptions: BackendOption[]; flatBackendOptions: BackendOption[];
initialValues?: FormData; // for editing model initialValues?: FormData; // for editing model
modelContextData?: Record<string, any>; modelContextData?: Record<string, any>;
submitAttempted?: boolean;
clearCacheFormValues?: () => void; clearCacheFormValues?: () => void;
onValuesChange?: (changedValues: any, allValues: any) => void; onValuesChange?: (changedValues: any, allValues: any) => void;
onBackendChange: (backend: string, option: any) => void; onBackendChange: (backend: string, option: any) => void;
+2 -18
View File
@@ -28,7 +28,6 @@ export interface ListItem {
path: string; path: string;
source: string; source: string;
}>; }>;
owner_principal_id?: number;
name: string; name: string;
description: string; description: string;
id: number; id: number;
@@ -379,24 +378,9 @@ export interface BackendOption {
export interface AccessControlFormData { export interface AccessControlFormData {
// See `RouteItem.access_policy` for why plugin-defined values are // See `RouteItem.access_policy` for why plugin-defined values are
// accepted alongside the built-ins. The OSS "specific users" entry // accepted alongside the built-ins.
// now writes `allowed_principals` (with a user-only grant list);
// `allowed_users` remains accepted as the deprecated released value.
access_policy: 'public' | 'authed' | 'allowed_users' | (string & {}); access_policy: 'public' | 'authed' | 'allowed_users' | (string & {});
// Omitted when the caller isn't managing the user list (the users: { id: number }[];
// principal-based override, or authed/public) so the server leaves
// existing grants untouched; an explicit (possibly empty) list
// replaces the route's USER-kind grants.
users?: { id: number }[];
// Full grant set (any kind) submitted by the principal-based override
// on save — replaces the route's entire grant set. OSS leaves it unset
// (it manages users via `users`).
principals?: {
principal_type: string;
principal_id: number;
principal_name?: string;
principal_display_name?: string;
}[];
} }
export interface BackendItem { export interface BackendItem {
+1 -1
View File
@@ -245,7 +245,7 @@ const BackendFields: React.FC = () => {
}, },
{ {
link: ( link: (
<a onClick={() => navigate('/models/backends')}> <a onClick={() => navigate('/resources/backends')}>
{intl.formatMessage({ id: 'backends.title' })} {intl.formatMessage({ id: 'backends.title' })}
</a> </a>
) )
+26 -23
View File
@@ -14,7 +14,7 @@ import { useIntl } from '@umijs/max';
import { Form } from 'antd'; import { Form } from 'antd';
import { useMemo } from 'react'; import { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { DeployFormKeyMap, sourceOptions } from '../config'; import { sourceOptions } from '../config';
import { useFormContext } from '../config/form-context'; import { useFormContext } from '../config/form-context';
import { FormData } from '../config/types'; import { FormData } from '../config/types';
import BackendForm from './backend'; import BackendForm from './backend';
@@ -68,6 +68,7 @@ const ClusterOption = styled.span`
`; `;
interface BasicFormProps { interface BasicFormProps {
fields?: string[];
sourceDisable?: boolean; sourceDisable?: boolean;
sourceList?: Global.BaseOption<string>[]; sourceList?: Global.BaseOption<string>[];
clusterList: Global.BaseOption< clusterList: Global.BaseOption<
@@ -87,6 +88,7 @@ interface BasicFormProps {
const BasicForm: React.FC<BasicFormProps> = (props) => { const BasicForm: React.FC<BasicFormProps> = (props) => {
const { const {
fields = [],
sourceList, sourceList,
clusterList, clusterList,
sourceDisable, sourceDisable,
@@ -96,7 +98,7 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance(); const form = Form.useFormInstance();
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const { onValuesChange, action, formKey } = useFormContext(); const { onValuesChange, action } = useFormContext();
const handleOnSourceChange = (val: string) => { const handleOnSourceChange = (val: string) => {
onSourceChange?.(val); onSourceChange?.(val);
@@ -181,29 +183,30 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
required required
></CInput.Input> ></CInput.Input>
</Form.Item> </Form.Item>
{fields.includes('source') && (
<Form.Item<FormData> <Form.Item<FormData>
name="source" name="source"
hidden={formKey === DeployFormKeyMap.CATALOG} rules={[
rules={[ {
required: true,
message: getRuleMessage('select', 'models.form.source')
}
]}
>
{ {
required: true, <SealSelect
message: getRuleMessage('select', 'models.form.source') onChange={handleOnSourceChange}
disabled={sourceDisable}
label={intl.formatMessage({
id: 'models.form.source'
})}
options={sourceList ?? sourceOptions}
required
></SealSelect>
} }
]} </Form.Item>
> )}
{
<SealSelect
onChange={handleOnSourceChange}
disabled={sourceDisable}
label={intl.formatMessage({
id: 'models.form.source'
})}
options={sourceList ?? sourceOptions}
required
></SealSelect>
}
</Form.Item>
<OnlineSource></OnlineSource> <OnlineSource></OnlineSource>
<LocalPathSource></LocalPathSource> <LocalPathSource></LocalPathSource>
<Form.Item<FormData> <Form.Item<FormData>
+2 -5
View File
@@ -86,6 +86,7 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
sourceDisable = true, sourceDisable = true,
sourceList, sourceList,
clusterList = [], clusterList = [],
fields = ['source'],
clearCacheFormValues, clearCacheFormValues,
onBackendChange, onBackendChange,
onSourceChange, onSourceChange,
@@ -101,7 +102,6 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const intl = useIntl(); const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]); const [activeKey, setActiveKey] = React.useState<string[]>([]);
const [submitAttempted, setSubmitAttempted] = React.useState(false);
const { modelContextData, fetchContextLength } = useQueryContextLength(); const { modelContextData, fetchContextLength } = useQueryContextLength();
const localPath = Form.useWatch('local_path', form); const localPath = Form.useWatch('local_path', form);
const modelScopeModelId = Form.useWatch('model_scope_model_id', form); const modelScopeModelId = Form.useWatch('model_scope_model_id', form);
@@ -230,7 +230,6 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
..._.omit(data, ['scheduleType']), ..._.omit(data, ['scheduleType']),
...gpuSelector ...gpuSelector
}; };
console.log('submit form data:', allValues);
onOk(allValues); onOk(allValues);
}; };
@@ -284,8 +283,6 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
}; };
const handleOnFinishFailed = (errorInfo: any) => { const handleOnFinishFailed = (errorInfo: any) => {
setSubmitAttempted(true);
console.log('Failed:', errorInfo);
const { errorFields } = errorInfo; const { errorFields } = errorInfo;
if (errorFields && errorFields.length > 0) { if (errorFields && errorFields.length > 0) {
const collapseKeys: string[] = []; const collapseKeys: string[] = [];
@@ -402,7 +399,6 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
workerLabelOptions: workerLabelOptions, workerLabelOptions: workerLabelOptions,
initialValues: initialValues, initialValues: initialValues,
modelContextData: modelContextData, modelContextData: modelContextData,
submitAttempted: submitAttempted,
clearCacheFormValues: clearCacheFormValues, clearCacheFormValues: clearCacheFormValues,
onValuesChange: onValuesChange, onValuesChange: onValuesChange,
onBackendChange: handleBackendChange onBackendChange: handleBackendChange
@@ -460,6 +456,7 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
}} }}
> >
<BasicForm <BasicForm
fields={fields}
sourceList={sourceList} sourceList={sourceList}
clusterList={clusterList} clusterList={clusterList}
sourceDisable={sourceDisable} sourceDisable={sourceDisable}
+2 -2
View File
@@ -85,7 +85,7 @@ const LoraListItem: React.FC<LoraListItemProps> = ({
!selectedRepoNames.has(child.value) || child.value === currentRepo !selectedRepoNames.has(child.value) || child.value === currentRepo
); );
const labelText = isLocal const labelText = isLocal
? intl.formatMessage({ id: 'menu.models.modelfiles' }) ? intl.formatMessage({ id: 'menu.resources.modelfiles' })
: group.label; : group.label;
groups.push({ groups.push({
label: `${sourcePrefix}: ${labelText}`, label: `${sourcePrefix}: ${labelText}`,
@@ -101,7 +101,7 @@ const LoraListItem: React.FC<LoraListItemProps> = ({
if (!hasLocal) { if (!hasLocal) {
groups.push({ groups.push({
label: `${sourcePrefix}: ${intl.formatMessage({ id: 'menu.models.modelfiles' })}`, label: `${sourcePrefix}: ${intl.formatMessage({ id: 'menu.resources.modelfiles' })}`,
groupValue: modelSourceMap.local_path_value, groupValue: modelSourceMap.local_path_value,
options: [] options: []
}); });
+4 -6
View File
@@ -2,7 +2,6 @@ import { MetadataList } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form } from 'antd'; import { Form } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useFormContext } from '../config/form-context';
import { FormData, LoraListItem } from '../config/types'; import { FormData, LoraListItem } from '../config/types';
import useQueryModelLoraList from '../services/use-query-lora-list'; import useQueryModelLoraList from '../services/use-query-lora-list';
import LoraItem from './lora-list-item'; import LoraItem from './lora-list-item';
@@ -18,13 +17,10 @@ const ModelLoraList = () => {
const base = huggingfaceRepoId || modelScopeModelId || localPath || ''; const base = huggingfaceRepoId || modelScopeModelId || localPath || '';
console.log('fetching lora list with base', base, form.getFieldsValue());
const { dataList: defaultDataList, fetchData } = useQueryModelLoraList(); const { dataList: defaultDataList, fetchData } = useQueryModelLoraList();
const { submitAttempted } = useFormContext();
const validated = !!submitAttempted;
const [itemList, setItemList] = useState<ItemValue[]>([]); const [itemList, setItemList] = useState<ItemValue[]>([]);
const [validated, setValidated] = useState(false);
const initializedRef = useRef(false); const initializedRef = useRef(false);
const prevBaseRef = useRef<string>(''); const prevBaseRef = useRef<string>('');
@@ -53,7 +49,6 @@ const ModelLoraList = () => {
prevBaseRef.current = ''; prevBaseRef.current = '';
return; return;
} }
fetchData({ base }); fetchData({ base });
if (prevBaseRef.current && prevBaseRef.current !== base) { if (prevBaseRef.current && prevBaseRef.current !== base) {
form.setFieldValue('lora_list', []); form.setFieldValue('lora_list', []);
@@ -152,6 +147,9 @@ const ModelLoraList = () => {
rules={[ rules={[
{ {
validator: async (_, value: LoraListItem[]) => { validator: async (_, value: LoraListItem[]) => {
if (!validated) {
setValidated(true);
}
if (!value || value.length === 0) return; if (!value || value.length === 0) return;
for (const it of value) { for (const it of value) {
+2 -5
View File
@@ -19,7 +19,8 @@ const HuggingFaceForm: React.FC = () => {
![ ![
modelSourceMap.huggingface_value, modelSourceMap.huggingface_value,
modelSourceMap.modelscope_value modelSourceMap.modelscope_value
].includes(source) ].includes(source) ||
formKey === DeployFormKeyMap.CATALOG
) { ) {
return null; return null;
} }
@@ -35,7 +36,6 @@ const HuggingFaceForm: React.FC = () => {
<Form.Item<FormData> <Form.Item<FormData>
name="huggingface_repo_id" name="huggingface_repo_id"
key="huggingface_repo_id" key="huggingface_repo_id"
hidden={formKey === DeployFormKeyMap.CATALOG}
rules={[ rules={[
{ {
required: true, required: true,
@@ -52,7 +52,6 @@ const HuggingFaceForm: React.FC = () => {
</Form.Item> </Form.Item>
{isGGUF && ( {isGGUF && (
<Form.Item<FormData> <Form.Item<FormData>
hidden={formKey === DeployFormKeyMap.CATALOG}
name="huggingface_filename" name="huggingface_filename"
key="huggingface_filename" key="huggingface_filename"
> >
@@ -68,7 +67,6 @@ const HuggingFaceForm: React.FC = () => {
<Form.Item<FormData> <Form.Item<FormData>
name="model_scope_model_id" name="model_scope_model_id"
key="model_scope_model_id" key="model_scope_model_id"
hidden={formKey === DeployFormKeyMap.CATALOG}
rules={[ rules={[
{ {
required: true, required: true,
@@ -87,7 +85,6 @@ const HuggingFaceForm: React.FC = () => {
<Form.Item<FormData> <Form.Item<FormData>
name="model_scope_file_path" name="model_scope_file_path"
key="model_scope_file_path" key="model_scope_file_path"
hidden={formKey === DeployFormKeyMap.CATALOG}
> >
<CInput.Input <CInput.Input
label={intl.formatMessage({ id: 'models.form.filename' })} label={intl.formatMessage({ id: 'models.form.filename' })}
@@ -12,7 +12,7 @@ import {
} 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 { Flex, Tooltip } from 'antd'; import { Tooltip } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import _ from 'lodash'; import _ from 'lodash';
@@ -158,19 +158,12 @@ const useModelsColumns = ({
sorter: tableSorter(1), sorter: tableSorter(1),
span: 5, span: 5,
render: (text: string, record: ListItem) => ( render: (text: string, record: ListItem) => (
<Flex align="center" gap={4} style={{ maxWidth: '100%' }}> <span className="flex-center" style={{ maxWidth: '100%' }}>
<AutoTooltip <AutoTooltip ghost>
ghost
title={
<span style={{ color: 'var(--ant-color-text-light-solid)' }}>
{text}
</span>
}
>
<span className="text-primary font-400">{text}</span> <span className="text-primary font-400">{text}</span>
</AutoTooltip> </AutoTooltip>
<ModelTag categoryKey={record.categories?.[0] || ''} /> <ModelTag categoryKey={record.categories?.[0] || ''} />
</Flex> </span>
) )
}, },
{ {
+1 -2
View File
@@ -55,8 +55,7 @@ const LLModels: React.FC = () => {
<Segmented <Segmented
shape="round" shape="round"
style={{ style={{
backgroundColor: 'var(--ant-color-fill-secondary)', backgroundColor: 'var(--ant-color-fill-secondary)'
fontSize: 13
}} }}
size="middle" size="middle"
className="m-l-24 font-400" className="m-l-24 font-400"
@@ -4,6 +4,7 @@ import { ListItem as workerListItem } from '@/pages/resources/config/types';
import { convertFileSize } from '@/utils'; import { convertFileSize } from '@/utils';
import { AutoTooltip, IconFont } from '@gpustack/core-ui'; import { AutoTooltip, IconFont } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Flex } from 'antd';
import { ColumnsType } from 'antd/lib/table'; import { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import _ from 'lodash'; import _ from 'lodash';
@@ -11,6 +12,7 @@ import { useMemo } from 'react';
import ActionsCell from '../components/instance-cells/actions-cell'; import ActionsCell from '../components/instance-cells/actions-cell';
import DistributeInfoCell from '../components/instance-cells/distribute-info-cell'; import DistributeInfoCell from '../components/instance-cells/distribute-info-cell';
import DownloadingStatusCell from '../components/instance-cells/downloading-status-cell'; import DownloadingStatusCell from '../components/instance-cells/downloading-status-cell';
import InjectedParamsCell from '../components/instance-cells/injected-params-cell';
import InstanceStatusCell from '../components/instance-cells/instance-status-cell'; import InstanceStatusCell from '../components/instance-cells/instance-status-cell';
import NameCell, { import NameCell, {
NameCellProps NameCellProps
@@ -27,6 +29,10 @@ const WorkerInfoContent: React.FC<NameCellProps> = ({ record, modelData }) => {
return ( return (
<div> <div>
<div>{record.worker_name}</div> <div>{record.worker_name}</div>
{/* <div className="flex-center">
<HddFilled className="m-r-5 text-tertiary" style={{ fontSize: 12 }} />
<span className="text-secondary">{workerIp}</span>
</div> */}
<div className="flex-center"> <div className="flex-center">
<IconFont <IconFont
type="icon-filled-gpu" type="icon-filled-gpu"
@@ -42,6 +48,18 @@ const WorkerInfoContent: React.FC<NameCellProps> = ({ record, modelData }) => {
] ]
</span> </span>
</div> </div>
{/* <div className="flex-center">
<ThunderboltFilled
className="m-r-5 text-tertiary"
style={{ fontSize: 12 }}
/>
<span className="text-secondary">
{record?.backend || modelData?.backend || ''}
{record.backend_version || modelData?.backend_version
? `(${record.backend_version || modelData?.backend_version})`
: ''}
</span>
</div> */}
</div> </div>
); );
}; };
@@ -83,20 +101,38 @@ const useInstancesColumns = (options: {
title: intl.formatMessage({ id: 'common.table.name' }), title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: 'name', dataIndex: 'name',
sorter: tableSorter(1), sorter: tableSorter(1),
minWidth: 160,
render: (value: string, record: ListItem) => ( render: (value: string, record: ListItem) => (
<NameCell <>
showWorkerInfo={true} <Flex gap={8} wrap="wrap">
record={record} <NameCell
modelData={{ showWorkerInfo={true}
backend: record.backend, record={record}
backend_version: record.backend_version modelData={{
}} backend: record.backend,
styles={{ backend_version: record.backend_version
label: { }}
color: 'var(--ant-color-text)' styles={{
} label: {
}} color: 'var(--ant-color-text)'
></NameCell> }
}}
></NameCell>
<InjectedParamsCell record={record}></InjectedParamsCell>
</Flex>
{/* <div className="flex-center">
<ThunderboltFilled
className="m-r-5 text-quaternary"
style={{ fontSize: 12, position: 'relative', top: 2 }}
/>
<span className="text-quaternary">
{record?.backend || record?.backend || ''}
{record.backend_version || record?.backend_version
? `(${record.backend_version || record?.backend_version})`
: ''}
</span>
</div> */}
</>
) )
}, },
{ {
+1 -1
View File
@@ -206,7 +206,7 @@ const Login = () => {
config={{ config={{
apiBaseUrl: GPUSTACK_API_BASE_URL, apiBaseUrl: GPUSTACK_API_BASE_URL,
theme: userSettings.theme, theme: userSettings.theme,
iconUrl: '', iconUrl: '//at.alicdn.com/t/c/font_4613488_vy4szhexci.js',
isDarkTheme: userSettings.isDarkTheme, isDarkTheme: userSettings.isDarkTheme,
defaultColorPrimary: COLOR_PRIMARY defaultColorPrimary: COLOR_PRIMARY
}} }}
@@ -15,8 +15,9 @@ const useRegisterRoute = () => {
routeTargets: [ routeTargets: [
{ {
weight: 100, weight: 100,
overridden_model_name: model.name, provider_model_name: model.name,
provider_id: record.id provider_id: record.id,
parentId: record.id
} }
] ]
}; };
@@ -78,7 +78,7 @@ const RouteItem: React.FC<TargetItemProps> = ({
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{data.model_id {data.model_id
? modelList?.find((m) => m.value === data.model_id)?.label ? modelList?.find((m) => m.value === data.model_id)?.label
: data.overridden_model_name} : data.provider_model_name}
</AutoTooltip> </AutoTooltip>
</span> </span>
); );
@@ -98,7 +98,7 @@ const RouteItem: React.FC<TargetItemProps> = ({
}} }}
> >
<AutoTooltip ghost>{data.name}</AutoTooltip> <AutoTooltip ghost>{data.name}</AutoTooltip>
{!!data.overridden_model_name && !!data.model_id && ( {!!data.lora_module_name && (
<FilesTag color="purple" variant="outlined"> <FilesTag color="purple" variant="outlined">
<span style={{ opacity: 1 }}>LoRA</span> <span style={{ opacity: 1 }}>LoRA</span>
</FilesTag> </FilesTag>
+18 -15
View File
@@ -1,22 +1,24 @@
export interface RouteTargetFormItem {
id?: number;
overridden_model_name?: string;
weight?: number | null;
model_id?: number;
provider_id?: number;
overridden_model_name?: string;
fallback_status_codes?: string[];
parentId?: string | number;
}
export interface FormData { export interface FormData {
name: string; name: string;
description: string; description: string;
categories: any[]; categories: any[];
meta: Record<string, any>; meta: Record<string, any>;
generic_proxy: boolean; generic_proxy: boolean;
fallback_target: RouteTargetFormItem | null; fallback_target: {
targets: RouteTargetFormItem[]; provider_model_name?: string;
model_id?: number;
provider_id?: number;
lora_module_name?: string;
fallback_status_codes?: string[];
};
targets: {
provider_model_name?: string;
weight?: number | null;
model_id?: number;
provider_id?: number;
lora_module_name?: string;
fallback_status_codes?: string[];
}[];
} }
export interface RouteItem { export interface RouteItem {
@@ -34,7 +36,7 @@ export interface RouteItem {
access_policy: string; access_policy: string;
} }
export interface RouteTarget extends RouteTargetFormItem { export interface RouteTarget {
id: number; id: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -44,7 +46,8 @@ export interface RouteTarget extends RouteTargetFormItem {
route_name: string; route_name: string;
route_id: number; route_id: number;
provider_id: number; provider_id: number;
overridden_model_name: string; provider_model_name: string;
fallback_status_codes: string[]; fallback_status_codes: string[];
lora_module_name?: string;
state: string; state: string;
} }
+42 -53
View File
@@ -14,55 +14,23 @@ import { Form } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import FormContext from '../config/form-context'; import FormContext from '../config/form-context';
import { import { FormData, RouteItem as ListItem } from '../config/types';
FormData,
RouteItem as ListItem,
RouteTargetFormItem
} from '../config/types';
import useEditTargets from '../hooks/use-edit-targets'; import useEditTargets from '../hooks/use-edit-targets';
import Basic from './basic'; import Basic from './basic';
import Targets from './targets'; import Targets from './targets';
const isSameTarget = (
left?: RouteTargetFormItem | null,
right?: RouteTargetFormItem | null
) => {
if (!left || !right) {
return false;
}
if (left.model_id && left.model_id === right.model_id) {
return left.overridden_model_name === right.overridden_model_name;
}
if (left.provider_id && left.provider_id === right.provider_id) {
return left.overridden_model_name === right.overridden_model_name;
}
return false;
};
const normalizeTarget = (
target: RouteTargetFormItem | null | undefined
): RouteTargetFormItem => {
const normalizedTarget = {
id: target?.id,
weight: target?.weight ?? 0,
model_id: target?.model_id,
provider_id: target?.provider_id,
overridden_model_name: target?.overridden_model_name,
fallback_status_codes: target?.fallback_status_codes
};
console.log('normalizeTarget', target, normalizedTarget);
return _.omitBy(normalizedTarget, _.isUndefined) as RouteTargetFormItem;
};
interface ProviderFormProps { interface ProviderFormProps {
ref?: any; ref?: any;
open: boolean; open: boolean;
action: PageActionType; action: PageActionType;
realAction?: string; realAction?: string;
currentData?: ListItem & { currentData?: ListItem & {
routeTargets?: RouteTargetFormItem[]; routeTargets?: {
weight?: number;
model_id?: number;
provider_id?: number;
provider_model_name?: string;
}[];
}; // Used when action is EDIT }; // Used when action is EDIT
onFinish: (values: FormData) => Promise<void>; onFinish: (values: FormData) => Promise<void>;
onFallbackChange?: (changed: boolean) => void; onFallbackChange?: (changed: boolean) => void;
@@ -124,12 +92,25 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
const fallbackTarget = values.fallback_target; const fallbackTarget = values.fallback_target;
if (fallbackTarget) { if (fallbackTarget) {
const existingTarget = targetList.find((target) => const exsitinged = targetList.find((ep) => {
isSameTarget(target, fallbackTarget) if (fallbackTarget!.model_id) {
); return (
if (existingTarget) { ep.model_id === fallbackTarget!.model_id &&
ep.lora_module_name === fallbackTarget!.lora_module_name
);
}
return (
ep.provider_id === fallbackTarget!.provider_id &&
ep.provider_model_name === fallbackTarget!.provider_model_name
);
});
if (exsitinged) {
targetList = targetList.map((ep) => { targetList = targetList.map((ep) => {
if (isSameTarget(ep, fallbackTarget)) { if (
(ep.model_id === fallbackTarget.model_id &&
ep.lora_module_name === fallbackTarget.lora_module_name) ||
ep.provider_model_name === fallbackTarget.provider_model_name
) {
return { return {
...ep, ...ep,
fallback_status_codes: ['4xx', '5xx'] fallback_status_codes: ['4xx', '5xx']
@@ -139,7 +120,7 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
}); });
} }
if (!existingTarget) { if (!exsitinged) {
targetList.push({ targetList.push({
...fallbackTarget, ...fallbackTarget,
weight: 0, weight: 0,
@@ -148,7 +129,7 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
} }
} }
return targetList.map((target) => normalizeTarget(target)); return targetList;
}; };
const handleOnFinish = (values: FormData) => { const handleOnFinish = (values: FormData) => {
@@ -167,7 +148,15 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
return; return;
} }
const initDataList = async (targets: RouteTargetFormItem[]) => { const initDataList = async (
targets: {
weight?: number;
model_id?: number;
provider_id?: number;
provider_model_name?: string;
lora_module_name?: string;
}[]
) => {
// init targets form list // init targets form list
targetsRef.current?.initDataList( targetsRef.current?.initDataList(
targets?.map((ep) => ({ targets?.map((ep) => ({
@@ -175,11 +164,11 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
value: ep.model_id value: ep.model_id
? [ ? [
'deployments', 'deployments',
ep.overridden_model_name ep.lora_module_name
? `${ep.model_id}_lora_${ep.overridden_model_name}` ? `${ep.model_id}_lora_${ep.lora_module_name}`
: ep.model_id : ep.model_id
] ]
: [ep.provider_id, ep.overridden_model_name] : [ep.provider_id, ep.provider_model_name]
})) || [] })) || []
); );
}; };
@@ -203,11 +192,11 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
value: fallbackTarget.model_id value: fallbackTarget.model_id
? [ ? [
'deployments', 'deployments',
fallbackTarget.overridden_model_name fallbackTarget.lora_module_name
? `${fallbackTarget.model_id}_lora_${fallbackTarget.overridden_model_name}` ? `${fallbackTarget.model_id}_lora_${fallbackTarget.lora_module_name}`
: fallbackTarget.model_id : fallbackTarget.model_id
] ]
: [fallbackTarget.provider_id, fallbackTarget.overridden_model_name] : [fallbackTarget.provider_id, fallbackTarget.provider_model_name]
}); });
} }
}; };
@@ -1,4 +1,3 @@
import { getAllOrganizations } from '@/atoms/user';
import { useNavigate } from '@umijs/max'; import { useNavigate } from '@umijs/max';
import { modelCategoriesMap } from '../../llmodels/config'; import { modelCategoriesMap } from '../../llmodels/config';
import { categoryToPathMap } from '../../llmodels/config/button-actions'; import { categoryToPathMap } from '../../llmodels/config/button-actions';
@@ -7,13 +6,6 @@ const useOpenPlayground = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleOpenPlayGround = (row: any) => { const handleOpenPlayGround = (row: any) => {
const allOrganizations = getAllOrganizations();
const orgName = allOrganizations.find(
(org) => org.id === row.owner_principal_id
)?.name;
const rawModel = orgName ? `${orgName}/${row.name}` : row.name;
const modelName = encodeURIComponent(rawModel);
for (const [category, path] of Object.entries(categoryToPathMap)) { for (const [category, path] of Object.entries(categoryToPathMap)) {
if ( if (
row.categories?.includes(category) && row.categories?.includes(category) &&
@@ -22,15 +14,15 @@ const useOpenPlayground = () => {
modelCategoriesMap.speech_to_text modelCategoriesMap.speech_to_text
].includes(category) ].includes(category)
) { ) {
navigate(`${path}&model=${modelName}`); navigate(`${path}&model=${row.name}`);
return; return;
} }
if (row.categories?.includes(category)) { if (row.categories?.includes(category)) {
navigate(`${path}?model=${modelName}`); navigate(`${path}?model=${row.name}`);
return; return;
} }
} }
navigate(`/playground/chat?model=${modelName}`); navigate(`/playground/chat?model=${row.name}`);
}; };
return { return {
handleOpenPlayGround handleOpenPlayGround

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