Compare commits
39
Commits
gpu-service
...
v2.2.0rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16a97772d3 | ||
|
|
d33167d070 | ||
|
|
f62e0ba372 | ||
|
|
1b6466ae78 | ||
|
|
29c5987bf1 | ||
|
|
fcd1d01e83 | ||
|
|
a80b889760 | ||
|
|
f71bf1b074 | ||
|
|
7e048ee537 | ||
|
|
34098e8950 | ||
|
|
faf0d4d605 | ||
|
|
85ade88d55 | ||
|
|
7d0b94330d | ||
|
|
5cff1298fc | ||
|
|
d3a3d4e96e | ||
|
|
f99621ec92 | ||
|
|
e47a9304f4 | ||
|
|
9488fc1251 | ||
|
|
b590aa746b | ||
|
|
ef61134b8b | ||
|
|
a9daa0b586 | ||
|
|
ae0ad25ed6 | ||
|
|
a4c48c1981 | ||
|
|
3497590d2e | ||
|
|
11cf1329c8 | ||
|
|
ba5b41cf3a | ||
|
|
1d70446cc4 | ||
|
|
f15d0ab243 | ||
|
|
34be4800d7 | ||
|
|
e40b0c2e07 | ||
|
|
45e0ad9e93 | ||
|
|
96e6ddfe8a | ||
|
|
6b47bacfac | ||
|
|
099b419e34 | ||
|
|
e343ec0a1e | ||
|
|
e3d6d08916 | ||
|
|
d0bc206c83 | ||
|
|
7287f8c81b | ||
|
|
1878753cde |
+1
-2
@@ -3,7 +3,6 @@ node_modules
|
|||||||
.umi-production
|
.umi-production
|
||||||
public/static/*.js
|
public/static/*.js
|
||||||
public/static/*.css
|
public/static/*.css
|
||||||
src/components/icon-font/iconfont/iconfont.js
|
src/components/iconfont/
|
||||||
src/components/icon-font/iconfont/*.css
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,5 +3,5 @@ module.exports = {
|
|||||||
rules: {
|
rules: {
|
||||||
'selector-class-pattern': null
|
'selector-class-pattern': null
|
||||||
},
|
},
|
||||||
ignoreFiles: ['public/static/*.css']
|
ignoreFiles: ['public/static/*.css', 'src/components/iconfont/iconfont.css']
|
||||||
};
|
};
|
||||||
|
|||||||
+26
-20
@@ -179,6 +179,25 @@ 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'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -186,6 +205,7 @@ 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',
|
||||||
@@ -223,7 +243,12 @@ const baseRoutes = [
|
|||||||
path: '/gpu-service/storage-types',
|
path: '/gpu-service/storage-types',
|
||||||
key: 'gpuServiceStorageTypes',
|
key: 'gpuServiceStorageTypes',
|
||||||
icon: 'icon-storage-outlined',
|
icon: 'icon-storage-outlined',
|
||||||
access: 'canSeeAdmin',
|
// Storage types are tenant-scoped on the backend (Org owners
|
||||||
|
// 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'
|
||||||
@@ -266,25 +291,6 @@ 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'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,8 @@ 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
@@ -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.16",
|
"@gpustack/core-ui": "^1.0.19",
|
||||||
"@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",
|
||||||
|
|||||||
Generated
+7
-7
@@ -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.16
|
specifier: ^1.0.19
|
||||||
version: 1.0.16(czdvzceysqw7iv6pct2ucnb23e)
|
version: 1.0.19(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.16':
|
'@gpustack/core-ui@1.0.19':
|
||||||
resolution: {integrity: sha512-wFKDv7X0FXRmAmZPt4WKYV0+aGHcx82/3EZSJkrut/49XQd5XdrqKaimGtlFWFCNEWqsouwzs4uqaC7JFqfOkQ==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.16.tgz}
|
resolution: {integrity: sha512-EI2inYoTDdWYdxXXm0BM2DQ7chysnbIXICivBMeUxxhc8IkEcwaQfNqo/KUtA/bkqjDieRxFC1JlsDIIhbcL3A==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.19.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.16(czdvzceysqw7iv6pct2ucnb23e)':
|
'@gpustack/core-ui@1.0.19(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: 4.10.1
|
history: 5.3.0
|
||||||
|
|
||||||
'@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: 3.7.2
|
redux: 4.2.1
|
||||||
|
|
||||||
'@types/react-router@5.1.20':
|
'@types/react-router@5.1.20':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
+16
-1
@@ -1,6 +1,9 @@
|
|||||||
import { applyAccessExtensions } from './access.extensions';
|
import { applyAccessExtensions } from './access.extensions';
|
||||||
|
|
||||||
export default (initialState: { currentUser?: Global.UserInfo }) => {
|
export default (initialState: {
|
||||||
|
currentUser?: Global.UserInfo;
|
||||||
|
hasKubernetesCluster?: boolean;
|
||||||
|
}) => {
|
||||||
const isPlatformAdmin = !!(
|
const isPlatformAdmin = !!(
|
||||||
initialState &&
|
initialState &&
|
||||||
initialState.currentUser &&
|
initialState.currentUser &&
|
||||||
@@ -11,6 +14,12 @@ export default (initialState: { currentUser?: Global.UserInfo }) => {
|
|||||||
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`).
|
||||||
@@ -18,6 +27,11 @@ export default (initialState: { currentUser?: Global.UserInfo }) => {
|
|||||||
// * `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
|
||||||
@@ -27,6 +41,7 @@ export default (initialState: { currentUser?: Global.UserInfo }) => {
|
|||||||
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,
|
||||||
|
|||||||
+42
-2
@@ -3,6 +3,8 @@ 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';
|
||||||
@@ -34,11 +36,45 @@ 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;
|
||||||
|
|
||||||
@@ -122,12 +158,16 @@ 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 = await fetchUserInfo();
|
const [userInfo, hasKubernetesCluster] = await Promise.all([
|
||||||
|
fetchUserInfo(),
|
||||||
|
probeHasKubernetesCluster()
|
||||||
|
]);
|
||||||
checkDefaultPage(userInfo);
|
checkDefaultPage(userInfo);
|
||||||
return {
|
return {
|
||||||
fetchUserInfo,
|
fetchUserInfo,
|
||||||
currentUser: userInfo,
|
currentUser: userInfo,
|
||||||
pluginData
|
pluginData,
|
||||||
|
hasKubernetesCluster
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ 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);
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
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
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,15 @@ 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;
|
||||||
@@ -101,3 +110,18 @@ 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.
Vendored
+6
@@ -92,6 +92,12 @@ 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 };
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ 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
|
||||||
@@ -108,6 +109,7 @@ export default {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
motion: true,
|
motion: true,
|
||||||
colorFill: '#0A0A0A',
|
colorFill: '#0A0A0A',
|
||||||
colorBgBase: '#0A0A0A'
|
colorBgBase: '#0A0A0A',
|
||||||
|
menuItemSelectedBg: '#292929'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ 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,6 +1,7 @@
|
|||||||
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';
|
||||||
@@ -356,7 +357,7 @@ export default (props: any) => {
|
|||||||
config={{
|
config={{
|
||||||
apiBaseUrl: GPUSTACK_API_BASE_URL,
|
apiBaseUrl: GPUSTACK_API_BASE_URL,
|
||||||
theme: userSettings.theme,
|
theme: userSettings.theme,
|
||||||
iconUrl: '//at.alicdn.com/t/c/font_4613488_jmdepcs90im.js',
|
iconUrl: '',
|
||||||
isDarkTheme: userSettings.isDarkTheme,
|
isDarkTheme: userSettings.isDarkTheme,
|
||||||
defaultColorPrimary: COLOR_PRIMARY
|
defaultColorPrimary: COLOR_PRIMARY
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 } from 'antd-style';
|
import { createStyles, type FullToken } from 'antd-style';
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
@@ -20,7 +20,8 @@ interface SiderMenuProps {
|
|||||||
initialState: Global.InitialStateType;
|
initialState: Global.InitialStateType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = createStyles(({ css, token }) => {
|
const useStyles = createStyles(
|
||||||
|
({ css, token }: { css: any; token: FullToken }) => {
|
||||||
console.log('useStyles', token);
|
console.log('useStyles', token);
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -59,7 +60,7 @@ const useStyles = createStyles(({ css, token }) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ant-color-text-tertiary);
|
color: var(--ant-color-text);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +96,9 @@ const useStyles = createStyles(({ css, token }) => {
|
|||||||
color: ${Menu.itemHoverColor};
|
color: ${Menu.itemHoverColor};
|
||||||
}
|
}
|
||||||
&.menu-item-selected {
|
&.menu-item-selected {
|
||||||
background-color: ${Menu.itemSelectedBg};
|
background-color: ${Menu.menuItemSelectedBg};
|
||||||
color: ${Menu.itemSelectedColor};
|
color: ${Menu.itemSelectedColor};
|
||||||
|
|
||||||
.anticon {
|
.anticon {
|
||||||
color: ${Menu.itemSelectedColor};
|
color: ${Menu.itemSelectedColor};
|
||||||
}
|
}
|
||||||
@@ -130,7 +132,8 @@ const useStyles = createStyles(({ css, token }) => {
|
|||||||
right: -2px;
|
right: -2px;
|
||||||
`
|
`
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const SiderMenu: React.FC<SiderMenuProps> = (props) => {
|
const SiderMenu: React.FC<SiderMenuProps> = (props) => {
|
||||||
const { menuData, collapsed } = props;
|
const { menuData, collapsed } = props;
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ 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 cluster’s 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',
|
||||||
@@ -164,6 +162,10 @@ 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',
|
||||||
@@ -172,16 +174,15 @@ 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.gpuVendorOverrides.title': 'GPU Vendor Overrides',
|
'clusters.operatorImage.title': 'Operator Image',
|
||||||
'clusters.gpuVendorOverrides.validate.emptySelector':
|
'clusters.operatorImage.tip':
|
||||||
'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.',
|
'Override for the GPUStack Operator container image. Leave empty to use the server default.',
|
||||||
'clusters.gpuVendorOverrides.validate.duplicate':
|
'clusters.namespace.title': 'Namespace',
|
||||||
'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.',
|
'clusters.namespace.tip':
|
||||||
'clusters.gpuVendorOverrides.validate.keyConflict':
|
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||||
'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.title': 'GPU Instances',
|
||||||
'clusters.gpuVendorOverrides.tip':
|
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||||
'Required when running multiple GPU vendors in one cluster. Each entry pins that vendor’s 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': 'Static Access Address',
|
||||||
'clusters.gpuVendorOverrides.add': 'Add Override',
|
'clusters.gpuInstances.staticAddress.tip':
|
||||||
'clusters.gpuVendorOverrides.vendor': 'GPU Vendor',
|
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
|
||||||
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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': 'Memory',
|
'gpuservice.instance.memory': 'VRAM',
|
||||||
'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': 'Temporary',
|
'gpuservice.storage.temporary': 'Ephemeral',
|
||||||
'gpuservice.storage.persistentVolume': 'Persistent Volume',
|
'gpuservice.storage.persistentVolume': 'Persistent',
|
||||||
'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':
|
||||||
|
|||||||
@@ -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': 'Models',
|
'menu.models': 'Model Service',
|
||||||
'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.resources.modelfiles': 'Model Files',
|
'menu.models.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.resources.backendsList': 'Inference Backends',
|
'menu.models.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',
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ 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.',
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ 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 cluster’s 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',
|
||||||
@@ -164,6 +162,10 @@ 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',
|
||||||
@@ -172,18 +174,17 @@ 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.gpuVendorOverrides.title': 'GPU Vendor Overrides',
|
'clusters.operatorImage.title': 'Operator Image',
|
||||||
'clusters.gpuVendorOverrides.validate.emptySelector':
|
'clusters.operatorImage.tip':
|
||||||
'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.',
|
'Override for the GPUStack Operator container image. Leave empty to use the server default.',
|
||||||
'clusters.gpuVendorOverrides.validate.duplicate':
|
'clusters.namespace.title': 'Namespace',
|
||||||
'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.',
|
'clusters.namespace.tip':
|
||||||
'clusters.gpuVendorOverrides.validate.keyConflict':
|
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||||
'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.title': 'GPU Instances',
|
||||||
'clusters.gpuVendorOverrides.tip':
|
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||||
'Required when running multiple GPU vendors in one cluster. Each entry pins that vendor’s 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': 'Static Access Address',
|
||||||
'clusters.gpuVendorOverrides.add': 'Add Override',
|
'clusters.gpuInstances.staticAddress.tip':
|
||||||
'clusters.gpuVendorOverrides.vendor': 'GPU Vendor',
|
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
|
||||||
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
||||||
|
|||||||
@@ -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': 'Memory',
|
'gpuservice.instance.memory': 'VRAM',
|
||||||
'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)',
|
||||||
|
|||||||
@@ -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.resources.modelfiles': 'Model Files',
|
'menu.models.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.resources.backendsList': 'Inference Backends',
|
'menu.models.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.resources.modelfiles': 'Model Files',
|
// 4. 'menu.models.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.resources.backendsList': 'Inference Backends',
|
// 14. 'menu.models.backendsList': 'Inference Backends',
|
||||||
// 15. 'menu.models.benchmark': 'Benchmarks',
|
// 15. 'menu.models.benchmark': 'Benchmarks',
|
||||||
// 15. 'menu.models.provider': 'Provider',
|
// 15. 'menu.models.provider': 'Provider',
|
||||||
// 15. 'menu.models.providers': 'Provider',
|
// 15. 'menu.models.providers': 'Provider',
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ 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.',
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ 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 cluster’s 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': 'Выполнить команду',
|
||||||
@@ -165,6 +163,10 @@ 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',
|
||||||
@@ -173,18 +175,17 @@ 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.gpuVendorOverrides.title': 'GPU Vendor Overrides',
|
'clusters.operatorImage.title': 'Operator Image',
|
||||||
'clusters.gpuVendorOverrides.validate.emptySelector':
|
'clusters.operatorImage.tip':
|
||||||
'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.',
|
'Override for the GPUStack Operator container image. Leave empty to use the server default.',
|
||||||
'clusters.gpuVendorOverrides.validate.duplicate':
|
'clusters.namespace.title': 'Namespace',
|
||||||
'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.',
|
'clusters.namespace.tip':
|
||||||
'clusters.gpuVendorOverrides.validate.keyConflict':
|
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||||
'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.title': 'GPU Instances',
|
||||||
'clusters.gpuVendorOverrides.tip':
|
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||||
'Required when running multiple GPU vendors in one cluster. Each entry pins that vendor’s 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': 'Static Access Address',
|
||||||
'clusters.gpuVendorOverrides.add': 'Add Override',
|
'clusters.gpuInstances.staticAddress.tip':
|
||||||
'clusters.gpuVendorOverrides.vendor': 'GPU Vendor',
|
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
|
||||||
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
||||||
|
|||||||
@@ -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': 'Память',
|
'gpuservice.instance.memory': 'VRAM',
|
||||||
'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': 'Введите ёмкость',
|
||||||
|
|||||||
@@ -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.resources.modelfiles': 'Файлы модлей',
|
'menu.models.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.resources.backendsList': 'Бэкенды запуска',
|
'menu.models.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',
|
||||||
|
|||||||
@@ -37,9 +37,12 @@ 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.',
|
||||||
@@ -67,8 +70,7 @@ 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',
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ 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 cluster’s 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',
|
||||||
@@ -165,6 +163,10 @@ 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',
|
||||||
@@ -173,16 +175,15 @@ 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.gpuVendorOverrides.title': 'GPU Vendor Overrides',
|
'clusters.operatorImage.title': 'Operator Image',
|
||||||
'clusters.gpuVendorOverrides.validate.emptySelector':
|
'clusters.operatorImage.tip':
|
||||||
'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.',
|
'Override for the GPUStack Operator container image. Leave empty to use the server default.',
|
||||||
'clusters.gpuVendorOverrides.validate.duplicate':
|
'clusters.namespace.title': 'Namespace',
|
||||||
'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.',
|
'clusters.namespace.tip':
|
||||||
'clusters.gpuVendorOverrides.validate.keyConflict':
|
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||||
'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.title': 'GPU Instances',
|
||||||
'clusters.gpuVendorOverrides.tip':
|
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||||
'Required when running multiple GPU vendors in one cluster. Each entry pins that vendor’s 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': 'Static Access Address',
|
||||||
'clusters.gpuVendorOverrides.add': 'Add Override',
|
'clusters.gpuInstances.staticAddress.tip':
|
||||||
'clusters.gpuVendorOverrides.vendor': 'GPU Vendor',
|
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
|
||||||
'clusters.gpuVendorOverrides.nodeSelector': 'Node Selector'
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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': 'Bellek',
|
'gpuservice.instance.memory': 'VRAM',
|
||||||
'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ı Hacim',
|
'gpuservice.storage.persistentVolume': 'Kalıcı',
|
||||||
'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)',
|
||||||
|
|||||||
@@ -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': 'Modeller',
|
'menu.models': 'Model Hizmetleri',
|
||||||
'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.resources.modelfiles': 'Model Dosyaları',
|
'menu.models.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.resources.backendsList': 'Çıkarım Altyapıları',
|
'menu.models.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',
|
||||||
|
|||||||
@@ -38,9 +38,12 @@ 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.',
|
||||||
|
|||||||
@@ -68,8 +68,6 @@ 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': '运行指令',
|
||||||
@@ -157,6 +155,10 @@ 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': '镜像仓库地址',
|
||||||
@@ -165,16 +167,15 @@ 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.gpuVendorOverrides.title': 'GPU 厂商覆盖配置',
|
'clusters.operatorImage.title': 'Operator 镜像',
|
||||||
'clusters.gpuVendorOverrides.validate.emptySelector':
|
'clusters.operatorImage.tip':
|
||||||
'{vendor} 的覆盖配置至少需要一项 nodeSelector —— 空覆盖会让多 vendor manifest 无法定位该 runtime 的节点。',
|
'GPUStack Operator 容器镜像的覆盖值。留空则使用服务端默认值。',
|
||||||
'clusters.gpuVendorOverrides.validate.duplicate':
|
'clusters.namespace.title': '命名空间',
|
||||||
'{v1} 与 {v2} 的覆盖配置使用了完全相同的 nodeSelector。每个 vendor 必须对应不同的节点集,否则它们的 worker DaemonSet 会相互争抢同一批节点。',
|
'clusters.namespace.tip':
|
||||||
'clusters.gpuVendorOverrides.validate.keyConflict':
|
'集群清单渲染所使用的 Kubernetes 命名空间。留空则使用 gpustack-system。',
|
||||||
'{vendor} 的覆盖配置重用了基础节点选择器中已有的 key({keys})—— 这会让 CPU worker 同时"要求"和"禁止"这些 key,永远无法被调度。',
|
'clusters.gpuInstances.title': 'GPU 实例',
|
||||||
'clusters.gpuVendorOverrides.tip':
|
'clusters.gpuInstances.tip': '为该集群启用 GPU 实例支持。',
|
||||||
'集群中存在多个 GPU 厂商时必填。每项的 nodeSelector 将该厂商的 worker DaemonSet 固定到匹配的节点,同时 CPU worker 通过 DoesNotExist 节点亲和性避开这些节点。',
|
'clusters.gpuInstances.staticAddress': '静态访问地址',
|
||||||
'clusters.gpuVendorOverrides.add': '添加覆盖配置',
|
'clusters.gpuInstances.staticAddress.tip':
|
||||||
'clusters.gpuVendorOverrides.vendor': 'GPU 厂商',
|
'Operator 访问该集群 GPU 实例所使用的静态地址(例如 LoadBalancer VIP)。可选。'
|
||||||
'clusters.gpuVendorOverrides.nodeSelector': '节点选择器'
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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':
|
||||||
'数据在实例重启后仍会保留,仅在实例被终止时删除。无法被其它实例共享。',
|
'数据在实例重启后仍会保留,仅在实例被终止时删除。无法被其它实例共享。',
|
||||||
|
|||||||
@@ -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.resources.modelfiles': '模型文件',
|
'menu.models.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.resources.backendsList': '推理后端',
|
'menu.models.backendsList': '推理后端',
|
||||||
'menu.gpuService': 'GPU 服务',
|
'menu.gpuService': 'GPU 服务',
|
||||||
'menu.gpuService.instances': 'GPU 实例',
|
'menu.gpuService.instances': 'GPU 实例',
|
||||||
'menu.gpuService.templates': '实例模板',
|
'menu.gpuService.templates': '实例模板',
|
||||||
|
|||||||
@@ -35,8 +35,11 @@ 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 { currentClusterAtom } from '@/atoms/gpuservice';
|
import { currentOrganizationIdAtom } from '@/atoms/user';
|
||||||
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,18 +9,22 @@ 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 currentCluster = useAtomValue(currentClusterAtom);
|
const currentOrgId = useAtomValue(currentOrganizationIdAtom);
|
||||||
const ownerPrincipalId = currentCluster?.owner_principal_id;
|
|
||||||
const form = Form.useFormInstance();
|
const form = Form.useFormInstance();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ownerPrincipalId != null) {
|
if (currentOrgId != null) {
|
||||||
form.setFieldValue(name, ownerPrincipalId);
|
form.setFieldValue(name, currentOrgId);
|
||||||
}
|
}
|
||||||
}, [ownerPrincipalId, name, form]);
|
}, [currentOrgId, name, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Item name={name} hidden>
|
<Form.Item name={name} hidden>
|
||||||
|
|||||||
@@ -55,17 +55,22 @@ 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, setCurrentTitle }) => {
|
}> = ({ onClose, action, providerHint, 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 }>[]
|
||||||
>([]);
|
>([]);
|
||||||
const [currentStep, setCurrentStep] = useState<number>(startStep);
|
// When the caller already picked a provider for us, start one step
|
||||||
|
// 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;
|
||||||
@@ -79,7 +84,7 @@ const ClusterCreate: React.FC<{
|
|||||||
cluster_id: 0
|
cluster_id: 0
|
||||||
});
|
});
|
||||||
const [extraData, setExtraData] = useState<ClusterFormData>({
|
const [extraData, setExtraData] = useState<ClusterFormData>({
|
||||||
provider: ProviderValueMap.Docker
|
provider: (providerHint as ProviderType) ?? 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);
|
||||||
@@ -326,7 +331,11 @@ const ClusterCreate: React.FC<{
|
|||||||
<MainWrapper>
|
<MainWrapper>
|
||||||
{!isAddWorkerStep && (
|
{!isAddWorkerStep && (
|
||||||
<StepWrapper>
|
<StepWrapper>
|
||||||
<ClusterSteps steps={steps} currentStep={currentStep}></ClusterSteps>
|
<ClusterSteps
|
||||||
|
steps={steps}
|
||||||
|
currentStep={currentStep}
|
||||||
|
selectedProvider={extraData.provider}
|
||||||
|
></ClusterSteps>
|
||||||
</StepWrapper>
|
</StepWrapper>
|
||||||
)}
|
)}
|
||||||
<ColumnWrapper
|
<ColumnWrapper
|
||||||
@@ -351,7 +360,12 @@ const ClusterCreate: React.FC<{
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
{currentStep === startStep && (
|
{currentStep === 0 && (
|
||||||
|
// 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,13 +6,18 @@ 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 = () => {
|
||||||
@@ -41,6 +46,7 @@ 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>
|
||||||
|
|||||||
@@ -73,7 +73,14 @@ 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({
|
||||||
@@ -310,14 +317,28 @@ 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 }
|
||||||
@@ -443,7 +464,8 @@ const Clusters: React.FC = () => {
|
|||||||
id: 'menu.clusterManagement.clusterCreate'
|
id: 'menu.clusterManagement.clusterCreate'
|
||||||
})}
|
})}
|
||||||
open={clusterModalStatus.open}
|
open={clusterModalStatus.open}
|
||||||
onClose={closeClusterModal}
|
providerHint={pendingProviderHint}
|
||||||
|
onClose={handleClusterModalClose}
|
||||||
></ClusterModal>
|
></ClusterModal>
|
||||||
{AddWorkerModal}
|
{AddWorkerModal}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ 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 { Alert, Tag } from 'antd';
|
import { Tag } from 'antd';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, 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';
|
||||||
@@ -26,75 +23,25 @@ const buildWorkerCommand = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||||
const { stepList, registerField, updateField, provider, registrationInfo } =
|
const { stepList, registerField, updateField, provider } =
|
||||||
useAddWorkerContext();
|
useAddWorkerContext();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const stepIndex = stepList.indexOf(StepNamesMap.SelectGPU) + 1;
|
const stepIndex = stepList.indexOf(StepNamesMap.SelectGPU) + 1;
|
||||||
|
|
||||||
// Pull the cluster so we know whether gpuVendorOverrides was configured.
|
// K8s clusters render one worker DaemonSet per requested GPU runtime and
|
||||||
// The K8s register flow gates multi-select on that being present, and
|
// derive each DaemonSet's nodeSelector from the vendor's PCI-presence label
|
||||||
// restricts available runtimes to its keys.
|
// at manifest time, so multiple vendors can be registered without any
|
||||||
const [overrideRuntimes, setOverrideRuntimes] = useState<string[]>([]);
|
// per-cluster override config. Multi-select is therefore always available
|
||||||
useEffect(() => {
|
// for the Kubernetes provider; other providers stay single-select.
|
||||||
const id = registrationInfo?.cluster_id;
|
const multiCapable = provider === ProviderValueMap.Kubernetes;
|
||||||
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
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isMultiActive = useMemo(
|
// No vendor is gated anymore — every card stays selectable.
|
||||||
() => multiCapable && selectedKeys.some((k) => overrideKeys.has(k)),
|
const availableKeys = undefined;
|
||||||
[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.
|
||||||
@@ -126,10 +73,6 @@ 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,
|
||||||
@@ -142,21 +85,13 @@ 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);
|
||||||
}
|
}
|
||||||
// Multi-add only when the current state already includes an override
|
// K8s clusters support multiple GPU runtimes, so accumulate picks.
|
||||||
// pick. Otherwise (still in single-select land), replace.
|
// Other providers stay single-select and replace the current pick.
|
||||||
if (isMultiActive) return [...prev, key];
|
if (multiCapable) 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}
|
||||||
@@ -182,17 +117,6 @@ 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,9 +9,8 @@ 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, message } from 'antd';
|
import { Form } 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 {
|
||||||
@@ -20,6 +19,7 @@ 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,6 +34,11 @@ 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);
|
||||||
|
|
||||||
@@ -54,94 +59,58 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
|||||||
}
|
}
|
||||||
}, [activeKey, action]);
|
}, [activeKey, action]);
|
||||||
|
|
||||||
// Mirror the backend's `_validate_multi_vendor_overrides` check so the
|
// The backend models the optional k8s_options string knobs as
|
||||||
// user sees the problem at save time instead of as a 400 when they
|
// Optional[str] and treats null/absent as "use the server default" or
|
||||||
// run the register command. Returns the i18n'd message of the first
|
// "no auth". Coerce empty form values to null before sending so a blank
|
||||||
// problem found, or null when clean.
|
// input is unambiguous rather than an empty string that defeats fallbacks.
|
||||||
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 creds = values?.k8s_options?.imageCredentials;
|
const base: any = { ...values };
|
||||||
if (!Array.isArray(creds)) return values;
|
// Top-level cluster field shared by Docker and K8s. Trim then coerce a
|
||||||
const fixed = creds.map((c: any) => ({
|
// blank input to null so clearing it on edit (or a whitespace-only
|
||||||
|
// value) falls back to the server default rather than persisting an
|
||||||
|
// empty string.
|
||||||
|
if (base.system_default_container_registry !== undefined) {
|
||||||
|
base.system_default_container_registry =
|
||||||
|
base.system_default_container_registry?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
...c,
|
||||||
username: c?.username ? c.username : null,
|
username: c?.username || null,
|
||||||
password: c?.password ? c.password : null
|
password: c?.password || null
|
||||||
}));
|
}));
|
||||||
return {
|
}
|
||||||
...values,
|
|
||||||
k8s_options: { ...values.k8s_options, imageCredentials: fixed }
|
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 `gpuVendorOverrides`, which is
|
// objects (e.g. `k8s_options` would lose values set via setFieldValue),
|
||||||
// only set via setFieldValue), so we go straight to the full store.
|
// 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,
|
||||||
@@ -236,21 +205,11 @@ 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 (e.g. gpuVendorOverrides)
|
// setFieldValue on non-registered paths are still included in what we
|
||||||
// are still included in what we hand to the API.
|
// 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()
|
||||||
);
|
);
|
||||||
@@ -314,6 +273,37 @@ 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,13 +1,18 @@
|
|||||||
import { Steps } from 'antd';
|
import { Steps, Typography } 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';
|
||||||
|
|
||||||
// `description` is intentionally omitted: the upstream step list ships
|
const { Text } = Typography;
|
||||||
// hardcoded English copy that isn't translated. Keeping it would cause the
|
|
||||||
// step to render both the localized title and the English description side
|
const ANTD_STEP_KEYS = [
|
||||||
// by side. Same reason we don't surface `subTitle`.
|
'title',
|
||||||
const ANTD_STEP_KEYS = ['title', 'icon', 'status', 'disabled'] as const;
|
'icon',
|
||||||
|
'status',
|
||||||
|
'disabled',
|
||||||
|
'subTitle'
|
||||||
|
] as const;
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
const Wrapper = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -31,6 +36,11 @@ 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);
|
||||||
@@ -43,17 +53,23 @@ 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) => _.pick(step, ANTD_STEP_KEYS));
|
.map((step, index) => {
|
||||||
|
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,21 +1,16 @@
|
|||||||
|
import { PageActionType } from '@/config/types';
|
||||||
import {
|
import {
|
||||||
MinusOutlined,
|
MinusOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
QuestionCircleOutlined
|
QuestionCircleOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import { Input as CInput, LabelSelector, useAppUtils } from '@gpustack/core-ui';
|
||||||
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, Tooltip } from 'antd';
|
import { Button, Form, Switch, Tooltip } from 'antd';
|
||||||
import _ from 'lodash';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { GPUsConfigs } from '../../resources/config/gpu-driver';
|
import { GpuInstanceOptions } from '../config/types';
|
||||||
|
import K8SVolumeMount from './k8s-volume-mount';
|
||||||
|
|
||||||
const Title = styled.div`
|
const Title = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -28,13 +23,6 @@ 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;
|
||||||
`;
|
`;
|
||||||
@@ -182,106 +170,107 @@ const NodeSelectorForm: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const vendorOptions = Object.values(GPUsConfigs)
|
// Render namespace. Kept as the first field of the section so the most
|
||||||
.filter((c) => !!c.gpuVendor)
|
// fundamental K8s deployment knob is set before the rest.
|
||||||
.map((c) => ({ label: c.label, value: c.gpuVendor as string }));
|
const NamespaceForm: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const GpuVendorOverridesForm: React.FC<{
|
return (
|
||||||
initialValue?: Record<string, { nodeSelector?: Record<string, string> }>;
|
<SectionWrap>
|
||||||
|
<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);
|
||||||
|
|
||||||
// Single source of truth for the cards while the user is editing.
|
const writeForm = (en: boolean, addr: string) => {
|
||||||
// We mirror it into the form via setFieldValue so submit picks it up.
|
form.setFieldValue(
|
||||||
// Seed local state directly from the parent's currentData — Form.useWatch
|
['k8s_options', 'gpuInstanceOptions'],
|
||||||
// on a non-registered nested path proved unreliable for picking up
|
en ? { gpuInstancesAccessStaticAddress: addr } : undefined
|
||||||
// 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);
|
|
||||||
|
|
||||||
// On mount: if we seeded with an initialValue, mirror it into the form so
|
// Mirror a seeded initial value into the form on mount so submit collects it.
|
||||||
// submit collects it. (When seeded, initializedRef is already true.)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialValue && Object.keys(initialValue).length > 0) {
|
if (initialValue) {
|
||||||
form.setFieldValue(['k8s_options', 'gpuVendorOverrides'], initialValue);
|
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// If the parent currentData arrives after mount (e.g. async fetch), adopt
|
// Adopt currentData arriving after mount (async edit load), once. After the
|
||||||
// it once. After the user has interacted (`initializedRef`), local state
|
// user has interacted (`initializedRef`), local state owns the section.
|
||||||
// owns the visible list.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initializedRef.current) return;
|
if (initializedRef.current) return;
|
||||||
if (initialValue && Object.keys(initialValue).length > 0) {
|
if (initialValue) {
|
||||||
setOverrides(initialValue);
|
setEnabled(true);
|
||||||
form.setFieldValue(['k8s_options', 'gpuVendorOverrides'], initialValue);
|
setAddress(initialValue.gpuInstancesAccessStaticAddress || '');
|
||||||
|
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
|
||||||
initializedRef.current = true;
|
initializedRef.current = true;
|
||||||
}
|
}
|
||||||
}, [initialValue, form]);
|
}, [initialValue]);
|
||||||
|
|
||||||
// Once the user has touched this section, keep the form mirror in sync
|
const handleToggle = (checked: boolean) => {
|
||||||
// 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 handleAdd = () => {
|
const handleAddressChange = (e: any) => {
|
||||||
if (availableVendors.length === 0) return;
|
const next = typeof e === 'string' ? e : (e?.target?.value ?? '');
|
||||||
const next = availableVendors[0].value;
|
setAddress(next);
|
||||||
writeOverrides({ ...overrides, [next]: { nodeSelector: {} } });
|
writeForm(true, next);
|
||||||
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 (
|
||||||
@@ -290,122 +279,48 @@ const GpuVendorOverridesForm: 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.gpuVendorOverrides.title' })}
|
{intl.formatMessage({ id: 'clusters.gpuInstances.title' })}
|
||||||
</span>
|
</span>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({ id: 'clusters.gpuInstances.tip' })}
|
||||||
id: 'clusters.gpuVendorOverrides.tip'
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<QuestionCircleOutlined
|
<QuestionCircleOutlined
|
||||||
style={{ color: 'var(--ant-color-text-secondary)' }}
|
style={{ color: 'var(--ant-color-text-secondary)' }}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Switch checked={enabled} onChange={handleToggle} />
|
||||||
type="link"
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={availableVendors.length === 0}
|
|
||||||
>
|
|
||||||
<PlusOutlined />{' '}
|
|
||||||
{intl.formatMessage({ id: 'clusters.gpuVendorOverrides.add' })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Title>
|
</Title>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
{enabled && (
|
||||||
{vendorKeys.map((vendor) => {
|
<CInput.Input
|
||||||
const optionsForThisRow = [
|
|
||||||
...vendorOptions.filter(
|
|
||||||
(opt) => opt.value === vendor || !vendorKeys.includes(opt.value)
|
|
||||||
)
|
|
||||||
];
|
|
||||||
const vendorLabel =
|
|
||||||
vendorOptions.find((o) => o.value === vendor)?.label || vendor;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
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}
|
isInFormItems={false}
|
||||||
required
|
value={address}
|
||||||
style={{ width: '100%' }}
|
onChange={handleAddressChange}
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: 'clusters.gpuVendorOverrides.vendor'
|
id: 'clusters.gpuInstances.staticAddress'
|
||||||
})}
|
})}
|
||||||
value={vendor}
|
description={intl.formatMessage({
|
||||||
options={optionsForThisRow}
|
id: 'clusters.gpuInstances.staticAddress.tip'
|
||||||
onChange={(value: string) =>
|
|
||||||
handleVendorChange(vendor, value)
|
|
||||||
}
|
|
||||||
></SealSelect>
|
|
||||||
</div>
|
|
||||||
<LabelSelector
|
|
||||||
label={intl.formatMessage({
|
|
||||||
id: 'clusters.gpuVendorOverrides.nodeSelector'
|
|
||||||
})}
|
})}
|
||||||
value={overrides[vendor]?.nodeSelector || {}}
|
></CInput.Input>
|
||||||
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<{
|
||||||
initialOverrides?: GpuVendorOverridesValue;
|
action: PageActionType;
|
||||||
}> = ({ initialOverrides }) => {
|
initialGpuInstanceOptions?: GpuInstanceOptions;
|
||||||
|
}> = ({ action, initialGpuInstanceOptions }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<NamespaceForm />
|
||||||
|
<K8SVolumeMount action={action}></K8SVolumeMount>
|
||||||
<ImageCredentialsForm />
|
<ImageCredentialsForm />
|
||||||
<NodeSelectorForm />
|
<NodeSelectorForm />
|
||||||
<GpuVendorOverridesForm initialValue={initialOverrides} />
|
<OperatorImageForm />
|
||||||
|
<GpuInstanceOptionsForm initialValue={initialGpuInstanceOptions} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,17 +74,31 @@ 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>;
|
||||||
gpuVendorOverrides?: Record<
|
// Override for the gpustack-operator container image. Falls back to the
|
||||||
string,
|
// server's default when unset.
|
||||||
{ nodeSelector?: Record<string, string> }
|
operatorImage?: string | null;
|
||||||
>;
|
// 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 {
|
||||||
@@ -93,6 +107,10 @@ 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;
|
||||||
@@ -122,6 +140,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"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,7 +9,6 @@ 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"
|
||||||
|
|
||||||
@@ -53,14 +52,12 @@ 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 = !!getGPUStackPlugin()?.clusterDetail
|
const nameLinkable: boolean =
|
||||||
?.linkableName;
|
!!getGPUStackPlugin()?.clusterDetail?.linkableName;
|
||||||
|
|
||||||
const setActionsItems = (row: ClusterListItem) => {
|
const setActionsItems = (row: ClusterListItem) => {
|
||||||
return clusterActionList.filter((item) => {
|
return clusterActionList.filter((item) => {
|
||||||
@@ -112,9 +112,7 @@ const useClusterColumns = (
|
|||||||
<>
|
<>
|
||||||
<AutoTooltip ghost title={text}>
|
<AutoTooltip ghost title={text}>
|
||||||
{nameLinkable ? (
|
{nameLinkable ? (
|
||||||
<Typography.Link
|
<Typography.Link onClick={() => onCellClick?.(record, 'name')}>
|
||||||
onClick={() => onCellClick?.(record, 'name')}
|
|
||||||
>
|
|
||||||
{record.name}
|
{record.name}
|
||||||
</Typography.Link>
|
</Typography.Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ 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 schema from '../config/worker-config.json';
|
import dockerSchema from '../config/worker-config.docker.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`
|
||||||
@@ -40,6 +39,8 @@ 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: () => {
|
||||||
@@ -62,8 +63,16 @@ 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]);
|
}, [provider, action, currentData?.worker_config]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -79,14 +88,24 @@ const ClusterAdvanceConfig: React.FC<{
|
|||||||
>
|
>
|
||||||
<CInput.TextArea required={false} trim={false}></CInput.TextArea>
|
<CInput.TextArea required={false} trim={false}></CInput.TextArea>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{provider === ProviderValueMap.Kubernetes && (
|
{/* Default container registry used to resolve images for this cluster.
|
||||||
<>
|
A top-level cluster field shared by both Docker and Kubernetes
|
||||||
<K8SVolumeMount action={action}></K8SVolumeMount>
|
providers (the backend hoists any legacy worker_config value onto
|
||||||
<K8sPodSpec
|
this column). */}
|
||||||
initialOverrides={currentData?.k8s_options?.gpuVendorOverrides}
|
<Form.Item<FormData>
|
||||||
></K8sPodSpec>
|
name="system_default_container_registry"
|
||||||
</>
|
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,16 +115,12 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveInstanceDataInDescription = (instanceType: InstanceTypeItem) => {
|
const saveInstanceDataInDescription = (instanceType: InstanceTypeItem) => {
|
||||||
return JSON.stringify(
|
return JSON.stringify({
|
||||||
{
|
|
||||||
name: instanceType.name,
|
name: instanceType.name,
|
||||||
spec: {
|
spec: {
|
||||||
...instanceType.spec
|
...instanceType.spec
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
null,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// apply the selection of instance type and template
|
// apply the selection of instance type and template
|
||||||
@@ -159,7 +155,6 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
description: saveInstanceDataInDescription(instanceType)
|
description: saveInstanceDataInDescription(instanceType)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// update form
|
// update form
|
||||||
form.current?.applyInstanceType?.(instanceType);
|
form.current?.applyInstanceType?.(instanceType);
|
||||||
};
|
};
|
||||||
@@ -295,8 +290,8 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
...item.spec,
|
...item.spec,
|
||||||
sshPublicKeys: formValues?.spec?.sshPublicKeys,
|
sshPublicKeys: formValues?.spec?.sshPublicKeys,
|
||||||
resources: {
|
resources: {
|
||||||
...item?.spec?.resources,
|
...formValues?.spec?.resources,
|
||||||
accelerator: formValues?.spec?.resources?.accelerator
|
localStorage: item?.spec?.resources?.localStorage
|
||||||
},
|
},
|
||||||
volume: {
|
volume: {
|
||||||
...formValues?.spec?.volume
|
...formValues?.spec?.volume
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -21,28 +22,30 @@ const Title = styled.div`
|
|||||||
|
|
||||||
const Meta = styled.div`
|
const Meta = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
grid-template-columns: repeat(7, auto);
|
||||||
|
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: flex;
|
display: grid;
|
||||||
|
grid-template-columns: subgrid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
min-height: 15px;
|
||||||
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);
|
||||||
flex: none;
|
margin: 0 4px;
|
||||||
}
|
justify-self: center;
|
||||||
|
|
||||||
.meta-item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-icon {
|
.meta-icon {
|
||||||
@@ -58,7 +61,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;
|
||||||
@@ -66,14 +69,10 @@ const MetaItem: React.FC<{
|
|||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showDot && <span className="dot"></span>}
|
{showDot && <span className="dot" />}
|
||||||
<span className="meta-item">
|
|
||||||
<IconFont type={icon} className="meta-icon" />
|
<IconFont type={icon} className="meta-icon" />
|
||||||
<Flex align="center" gap={4}>
|
<span className="meta-label">{label}</span>
|
||||||
<span>{label}</span>
|
<span className="meta-value">{value || '-'}</span>
|
||||||
<span>{value || '-'}</span>
|
|
||||||
</Flex>
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -81,15 +80,27 @@ 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 manufacturerKey = specData.manufacturer || (acceleratable ? '' : 'cpu');
|
const manufacturer = acceleratable ? specData.manufacturer || '' : 'cpu';
|
||||||
const manufacturer = manufacturerKey?.toUpperCase();
|
const manufacturerColor = manufactureColorMap[manufacturer] ?? 'purple';
|
||||||
const manufacturerColor =
|
|
||||||
(manufacturerKey && manufactureColorMap[manufacturerKey]) ?? 'purple';
|
|
||||||
|
|
||||||
// resource remaining status
|
// resource once-max-request status
|
||||||
const remainingData = item.status?.remaining || {};
|
const onceMaxRequestData = item.status?.onceMaxRequest || {};
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -113,16 +124,16 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ThemeTag color={manufacturerColor} disabled={false}>
|
<ThemeTag color={manufacturerColor} disabled={false}>
|
||||||
{manufacturer}
|
{manufacturer?.toUpperCase()}
|
||||||
</ThemeTag>
|
</ThemeTag>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Title>
|
</Title>
|
||||||
<Meta>
|
<Meta>
|
||||||
<span style={{ display: 'flex', height: 15 }}>
|
|
||||||
{acceleratable && (
|
|
||||||
<span className="meta-row">
|
<span className="meta-row">
|
||||||
|
{acceleratable && (
|
||||||
|
<>
|
||||||
<MetaItem
|
<MetaItem
|
||||||
showDot={false}
|
showDot={false}
|
||||||
icon="icon-gpu1"
|
icon="icon-gpu1"
|
||||||
@@ -131,7 +142,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"
|
||||||
@@ -139,16 +150,18 @@ 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}
|
},
|
||||||
></MetaItem>
|
{ count: '' }
|
||||||
</span>
|
)}
|
||||||
|
value={`${item.maxAccelerator || '-'}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="meta-row">
|
<span className="meta-row">
|
||||||
@@ -156,15 +169,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(remainingData.ram)) ?? '-'}
|
value={toDisplayUnit(convertKiToGi(ramRaw)) ?? '-'}
|
||||||
></MetaItem>
|
/>
|
||||||
<MetaItem
|
<MetaItem
|
||||||
show={!!remainingData.cpu}
|
show={!!cpuRaw}
|
||||||
showDot={true}
|
showDot={true}
|
||||||
icon="icon-cpu"
|
icon="icon-cpu"
|
||||||
label="CPU"
|
label="CPU"
|
||||||
value={remainingData.cpu ?? '-'}
|
value={ceilMilliToCore(cpuRaw) ?? '-'}
|
||||||
></MetaItem>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</Meta>
|
</Meta>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const InstanceStatusValueMap = {
|
|||||||
Preparing: 'Preparing',
|
Preparing: 'Preparing',
|
||||||
NotReady: 'NotReady',
|
NotReady: 'NotReady',
|
||||||
Ready: 'Ready',
|
Ready: 'Ready',
|
||||||
Staring: 'Staring'
|
Starting: 'Starting'
|
||||||
};
|
};
|
||||||
|
|
||||||
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.Staring]: 'Staring'
|
[InstanceStatusValueMap.Starting]: 'Starting'
|
||||||
};
|
};
|
||||||
|
|
||||||
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.Staring]: StatusMaps.transitioning
|
[InstanceStatusValueMap.Starting]: 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 `${_.round(Number(num), 2)}Ti`;
|
if (unit === 'Ti') return `${_.floor(Number(num), 0)}Ti`;
|
||||||
return `${_.round(Number(num) / GI_DIVISOR[unit], 2)}Gi`;
|
return `${_.floor(Number(num) / GI_DIVISOR[unit], 0)}Gi`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseQuantity = (value?: string | null): number => {
|
const parseQuantity = (value?: string | null): number => {
|
||||||
@@ -183,37 +183,58 @@ 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 across all acceleratorTiers (not from candidates).
|
// tier.onceMaxRequest.accelerator across all acceleratorTiers (not from candidates).
|
||||||
export const getAcceleratorMax = (
|
export const getAcceleratorMax = (
|
||||||
tiers?: { onceMaxRequest: string }[] | null
|
tiers?: { onceMaxRequest: { accelerator?: 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);
|
const n = parseQuantity(tier.onceMaxRequest?.accelerator);
|
||||||
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 is >= the requested count.
|
// onceMaxRequest.accelerator is >= the requested count and whose cpu/ram/localStorage
|
||||||
|
// remaining are all > 0.
|
||||||
export const pickCandidateForAccelerator = <
|
export const pickCandidateForAccelerator = <
|
||||||
C extends { cluster: string; name: string }
|
C extends {
|
||||||
|
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) => parseQuantity(a.onceMaxRequest) - parseQuantity(b.onceMaxRequest)
|
(a, b) =>
|
||||||
|
parseQuantity(a.onceMaxRequest?.accelerator) -
|
||||||
|
parseQuantity(b.onceMaxRequest?.accelerator)
|
||||||
);
|
);
|
||||||
const tier = sorted.find((t) =>
|
|
||||||
count === 0
|
// count === 0 ? parseQuantity(tier.onceMaxRequest.accelerator) > count; this is CPU-only case.
|
||||||
? parseQuantity(t.onceMaxRequest) > count
|
for (const tier of sorted) {
|
||||||
: parseQuantity(t.onceMaxRequest) >= count
|
const fits = parseQuantity(tier.onceMaxRequest?.accelerator) >= count;
|
||||||
);
|
if (!fits) continue;
|
||||||
return tier?.candidates?.[0] ?? null;
|
const candidate = tier.candidates?.find(hasResources);
|
||||||
|
if (candidate) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
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;
|
||||||
@@ -28,9 +44,6 @@ 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: {
|
||||||
@@ -66,13 +79,29 @@ 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
|
||||||
@@ -101,16 +130,23 @@ 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: string;
|
onceMaxRequest: InstanceTypeTierOnceMaxRequestResource;
|
||||||
candidates?: InstanceTypeCandidate[] | null;
|
candidates?: InstanceTypeCandidate[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceTypeRemainingResource {
|
export interface InstanceTypeOnceMaxRequestResource {
|
||||||
accelerator?: string | null;
|
accelerator?: `${number}` | null;
|
||||||
cpu: string;
|
cpu: QuanityCPU;
|
||||||
ram: string;
|
ram: QuanityMemory;
|
||||||
localStorage: string;
|
localStorage: QuanityLocalStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceTypeSpec {
|
export interface InstanceTypeSpec {
|
||||||
@@ -122,10 +158,14 @@ 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 {
|
||||||
remaining: InstanceTypeRemainingResource;
|
onceMaxRequest: InstanceTypeOnceMaxRequestResource;
|
||||||
acceleratorTiers?: InstanceTypeTier[] | null;
|
acceleratorTiers?: InstanceTypeTier[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,14 +173,10 @@ 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;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
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,
|
||||||
@@ -14,9 +18,8 @@ import {
|
|||||||
useScrollActiveChange,
|
useScrollActiveChange,
|
||||||
useWrapperContext
|
useWrapperContext
|
||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
import { useIntl, useNavigate } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Button, Empty, Form } from 'antd';
|
import { Button, Flex, Form } from 'antd';
|
||||||
import { useSetAtom } from 'jotai';
|
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
@@ -26,6 +29,7 @@ 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, {
|
||||||
@@ -36,6 +40,7 @@ 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;
|
||||||
@@ -57,35 +62,6 @@ 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',
|
||||||
@@ -108,7 +84,11 @@ const requiredFields = {
|
|||||||
},
|
},
|
||||||
[TABKeysMap.STORAGE]: {
|
[TABKeysMap.STORAGE]: {
|
||||||
sort: 4,
|
sort: 4,
|
||||||
fields: ['spec.volume.persistent.name', 'spec.volume.ephemeral.capacity']
|
fields: [
|
||||||
|
'spec.volume.persistent.name',
|
||||||
|
'spec.volume.ephemeral.capacity',
|
||||||
|
'spec.sshPublicKeys'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,8 +104,6 @@ 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);
|
||||||
@@ -133,6 +111,7 @@ 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,
|
||||||
@@ -235,18 +214,39 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
);
|
);
|
||||||
|
|
||||||
setSelectedInstanceType(instanceType);
|
setSelectedInstanceType(instanceType);
|
||||||
|
|
||||||
setOnceMaxRequest({
|
setOnceMaxRequest({
|
||||||
cpu: parseQuantityToNumber(candidate?.cpu?.onceMaxRequest),
|
cpu: ceilMilliToCore(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,6 +301,20 @@ 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(
|
||||||
@@ -314,11 +328,27 @@ 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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -352,9 +382,23 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const handleAddSSHKey = () => {
|
const handleAddSSHKey = () => {
|
||||||
setAddSSHKeyPage({ create: true });
|
setSshOverlayOpen(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) => {
|
||||||
@@ -429,6 +473,7 @@ 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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -463,6 +508,7 @@ 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"
|
||||||
@@ -476,7 +522,18 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
})}
|
})}
|
||||||
></CheckboxField>
|
></CheckboxField>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddSSHKey}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'gpuservice.instance.ssh.addKey'
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
<div className={instanceStyles.sshkeySelection}>
|
<div className={instanceStyles.sshkeySelection}>
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name={['spec', 'sshPublicKeys']}
|
name={['spec', 'sshPublicKeys']}
|
||||||
@@ -515,38 +572,15 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
options={sshkeyOptions}
|
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>
|
></MultipleSelect>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</Form>
|
</Form>
|
||||||
|
<PublicKeyOverlay
|
||||||
|
open={sshOverlayOpen}
|
||||||
|
onCancel={() => setSshOverlayOpen(false)}
|
||||||
|
onSubmit={handleCreateSSHKey}
|
||||||
|
/>
|
||||||
</ScrollSpyTabs>
|
</ScrollSpyTabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
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 {
|
||||||
@@ -51,6 +54,7 @@ interface InstanceTypeFormItemProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
currentData?: ListItem;
|
currentData?: ListItem;
|
||||||
selectedInstanceType?: InstanceTypeItemModel;
|
selectedInstanceType?: InstanceTypeItemModel;
|
||||||
|
onceMaxRequest?: BasicResourceMax;
|
||||||
onGPUCountChange?: (value: number) => void;
|
onGPUCountChange?: (value: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +63,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
disabled,
|
disabled,
|
||||||
currentData,
|
currentData,
|
||||||
selectedInstanceType,
|
selectedInstanceType,
|
||||||
|
onceMaxRequest,
|
||||||
onGPUCountChange
|
onGPUCountChange
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -68,17 +73,45 @@ 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 handleOnGPUCountChange = (value: number) => {
|
const isGPU = useMemo(() => {
|
||||||
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 (
|
||||||
@@ -123,7 +156,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
{action === PageAction.EDIT && renderInstanceType()}
|
{action === PageAction.EDIT && renderInstanceType()}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
{showGPUCount && (
|
{isGPU && (
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name={['spec', 'resources', 'accelerator']}
|
name={['spec', 'resources', 'accelerator']}
|
||||||
hidden={action === PageAction.EDIT}
|
hidden={action === PageAction.EDIT}
|
||||||
@@ -162,7 +195,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<NumberSelection
|
<NumberSelection
|
||||||
min={0}
|
min={1}
|
||||||
onChange={handleOnGPUCountChange}
|
onChange={handleOnGPUCountChange}
|
||||||
max={maxGpuCount}
|
max={maxGpuCount}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -198,6 +231,48 @@ 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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,9 +4,10 @@ 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 { useCallback, useEffect, useRef, useState } from 'react';
|
import { 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;
|
||||||
@@ -24,6 +25,7 @@ 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) {
|
||||||
@@ -49,19 +51,11 @@ 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={600}
|
width={drawerWidth}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
getContainer={getOverlayContainer}
|
getContainer={getOverlayContainer}
|
||||||
footer={
|
footer={
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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,
|
||||||
@@ -135,6 +136,7 @@ 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,16 +5,29 @@ import {
|
|||||||
DropdownButtons,
|
DropdownButtons,
|
||||||
StatusTag
|
StatusTag
|
||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useAccess, 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';
|
||||||
@@ -33,7 +46,7 @@ type ConnectEntry =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getConnectEntries = (record: ListItem): ConnectEntry[] => {
|
const getConnectEntries = (record: ListItem): ConnectEntry[] => {
|
||||||
const ip = record.status?.hostIPs?.[0]?.ip;
|
const ip = record.status?.accessAddresses?.[0];
|
||||||
const ports = record.status?.ports || [];
|
const ports = record.status?.ports || [];
|
||||||
const configPorts = record.spec?.ports || [];
|
const configPorts = record.spec?.ports || [];
|
||||||
|
|
||||||
@@ -70,38 +83,52 @@ 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>[];
|
||||||
sortOrder: string[];
|
sortOrder: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const useInstancesColumns = ({
|
const useInstancesColumns = ({
|
||||||
handleSelect,
|
handleSelect,
|
||||||
|
clusterList,
|
||||||
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
|
||||||
|
ghost
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
{description.acceleratable
|
||||||
|
? `${description.product} x ${record.spec?.resources?.accelerator}`
|
||||||
|
: 'CPU'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
<span className="text-primary">
|
<span className="text-primary">
|
||||||
{description.acceleratable
|
{description.acceleratable
|
||||||
? `${description.product} x ${record.spec?.resources?.accelerator}`
|
? `${description.product} x ${record.spec?.resources?.accelerator}`
|
||||||
: 'CPU'}
|
: 'CPU'}
|
||||||
</span>
|
</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>{record.spec.resources?.cpu}C</span>
|
<span>{formatCpu(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' })}:{' '}
|
||||||
{record.spec.resources?.ram?.replace('Gi', 'GB')}
|
{formatMemoryGB(record.spec.resources?.ram)}
|
||||||
</span>
|
</span>
|
||||||
<span className={tableSyles.dot} />
|
<span className={tableSyles.dot} />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage({ id: 'gpuservice.instance.disk' })}:{' '}
|
{intl.formatMessage({ id: 'gpuservice.instance.disk' })}:{' '}
|
||||||
{record.spec.resources?.localStorage?.replace('Gi', 'GB')}
|
{formatMemoryGB(record.spec.resources?.localStorage)}
|
||||||
</span>
|
</span>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -120,7 +147,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">{text || record.displayName}</span>
|
<span className="text-primary">{record.displayName || text}</span>
|
||||||
</AutoTooltip>
|
</AutoTooltip>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -236,10 +263,20 @@ const useInstancesColumns = ({
|
|||||||
ellipsis: {
|
ellipsis: {
|
||||||
showTitle: false
|
showTitle: false
|
||||||
},
|
},
|
||||||
render: (_text: string, record: ListItem) => (
|
width: 260,
|
||||||
<div style={{ width: 'max-content' }}>
|
render: (_text: string, record: ListItem) => renderInstanceType(record)
|
||||||
<AutoTooltip ghost>{renderInstanceType(record)}</AutoTooltip>
|
},
|
||||||
</div>
|
{
|
||||||
|
title: intl.formatMessage({ id: 'clusters.title' }),
|
||||||
|
dataIndex: 'clusterId',
|
||||||
|
hidden: !access.canSeeAdmin,
|
||||||
|
ellipsis: {
|
||||||
|
showTitle: false
|
||||||
|
},
|
||||||
|
render: (id: number) => (
|
||||||
|
<AutoTooltip ghost maxWidth={240}>
|
||||||
|
{_.find(clusterList, { value: id })?.label || id || '-'}
|
||||||
|
</AutoTooltip>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -250,6 +287,7 @@ 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') : '-'}
|
||||||
@@ -271,7 +309,7 @@ const useInstancesColumns = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [handleSelect, sortOrder, intl]);
|
}, [handleSelect, sortOrder, clusterList, intl]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useInstancesColumns;
|
export default useInstancesColumns;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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;
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
|
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 { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
|
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useAccess, useIntl, useNavigate } 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 PageBox from '../../_components/page-box';
|
import PageBox from '../../_components/page-box';
|
||||||
import {
|
import {
|
||||||
deleteGPUServiceInstance,
|
deleteGPUServiceInstance,
|
||||||
@@ -25,8 +30,23 @@ 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,
|
||||||
@@ -66,6 +86,21 @@ const GPUService: React.FC = () => {
|
|||||||
closeViewEventsModal,
|
closeViewEventsModal,
|
||||||
openViewEventsModalStatus
|
openViewEventsModalStatus
|
||||||
} = useViewEvents();
|
} = useViewEvents();
|
||||||
|
const {
|
||||||
|
fetchClusterList,
|
||||||
|
cancelRequest: cancelClusterRequest,
|
||||||
|
clusterList,
|
||||||
|
loading: clusterLoading
|
||||||
|
} = useQueryClusterList();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 {
|
||||||
@@ -118,6 +153,30 @@ 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}
|
||||||
@@ -142,6 +201,7 @@ const GPUService: React.FC = () => {
|
|||||||
|
|
||||||
const columns = useInstancesColumns({
|
const columns = useInstancesColumns({
|
||||||
handleSelect,
|
handleSelect,
|
||||||
|
clusterList,
|
||||||
sortOrder
|
sortOrder
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +216,9 @@ const GPUService: React.FC = () => {
|
|||||||
handleInputChange={handleNameChange}
|
handleInputChange={handleNameChange}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
widths={{ input: 300 }}
|
widths={{ input: 300 }}
|
||||||
handleClickPrimary={openCreateInstanceModal}
|
handleClickPrimary={
|
||||||
|
hasK8sCluster ? openCreateInstanceModal : undefined
|
||||||
|
}
|
||||||
buttonText={intl.formatMessage({ id: 'gpuservice.instance.add' })}
|
buttonText={intl.formatMessage({ id: 'gpuservice.instance.add' })}
|
||||||
handleDeleteByBatch={handleDeleteBatch}
|
handleDeleteByBatch={handleDeleteBatch}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,8 +25,16 @@ 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) return true;
|
if (!item.spec?.acceleratable)
|
||||||
return getAcceleratorMax(item.status?.acceleratorTiers) > 0;
|
return {
|
||||||
|
maxAccelerator: 0,
|
||||||
|
available: true
|
||||||
|
};
|
||||||
|
const max = getAcceleratorMax(item.status?.acceleratorTiers);
|
||||||
|
return {
|
||||||
|
maxAccelerator: max || 0,
|
||||||
|
available: (max || 0) > 0
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryInstanceTypes = async (
|
const queryInstanceTypes = async (
|
||||||
@@ -34,10 +42,14 @@ 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);
|
||||||
|
return {
|
||||||
...item,
|
...item,
|
||||||
disabled: !isAvailable(item)
|
maxAccelerator: remainingData.maxAccelerator,
|
||||||
}));
|
disabled: !remainingData.available
|
||||||
|
};
|
||||||
|
});
|
||||||
console.log('queryInstanceTypes', list);
|
console.log('queryInstanceTypes', list);
|
||||||
setDataList(list);
|
setDataList(list);
|
||||||
return list;
|
return list;
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ 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,7 +42,13 @@ 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,7 +91,23 @@ export const stringifyCommand = (tokens?: string[]): string => {
|
|||||||
.join(' ');
|
.join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const templateActions = [
|
// No ``access`` gates on these menu items: the management page
|
||||||
|
// 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,21 +49,6 @@ 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(
|
||||||
@@ -74,16 +59,6 @@ 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>
|
||||||
@@ -227,37 +202,6 @@ 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,6 +59,7 @@ 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,
|
||||||
|
|||||||
@@ -45,7 +45,13 @@ 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 } =
|
||||||
|
|||||||
@@ -11,3 +11,43 @@ 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));
|
||||||
|
};
|
||||||
|
|||||||
@@ -445,11 +445,18 @@ 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).
|
// save). `principals` is the full grant set (any kind) used by the
|
||||||
return request<{ items: UserListItem[]; access_policy?: string }>(
|
// principal-based override; `items` stays the USER-only subset.
|
||||||
`${MODEL_ROUTES}/${id}/access`,
|
return request<{
|
||||||
{ method: 'GET' }
|
items: UserListItem[];
|
||||||
);
|
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,6 +34,16 @@ 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[] = []
|
||||||
@@ -216,7 +226,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_users') {
|
if (policy === ALLOWED_PRINCIPALS_POLICY) {
|
||||||
form.setFieldsValue({ users: formDataCacheRef.current?.users || [] });
|
form.setFieldsValue({ users: formDataCacheRef.current?.users || [] });
|
||||||
} else {
|
} else {
|
||||||
formDataCacheRef.current = {
|
formDataCacheRef.current = {
|
||||||
@@ -256,16 +266,31 @@ 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: currentData?.access_policy
|
access_policy: normalizeAccessPolicy(currentData?.access_policy)
|
||||||
});
|
});
|
||||||
queryModelAccessUserList(currentData.id).then((res) => {
|
queryModelAccessUserList(currentData.id).then((res) => {
|
||||||
const keys = res.items.map((item) => item.id);
|
// Fall back to the legacy `items` (USER-only) field when an
|
||||||
setTargetKeys(keys);
|
// older backend doesn't return `principals` yet.
|
||||||
|
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 keys) {
|
for (const key of userKeys) {
|
||||||
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;
|
||||||
@@ -280,8 +305,14 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
|
|||||||
setFilterInUsers(filterSet);
|
setFilterInUsers(filterSet);
|
||||||
|
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
access_policy: res.access_policy ?? currentData.access_policy,
|
access_policy: normalizeAccessPolicy(
|
||||||
users: res.items.map((item) => ({ id: item.id }))
|
res.access_policy ?? currentData.access_policy
|
||||||
|
),
|
||||||
|
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 {
|
||||||
@@ -340,7 +371,11 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
|
|||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
onFinish={onFinish}
|
// Submit the full field store (getFieldsValue(true)), not just the
|
||||||
|
// 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}
|
||||||
@@ -396,7 +431,7 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
|
|||||||
label: intl.formatMessage({
|
label: intl.formatMessage({
|
||||||
id: 'models.accessSettings.allowedUsers'
|
id: 'models.accessSettings.allowedUsers'
|
||||||
}),
|
}),
|
||||||
value: 'allowed_users'
|
value: ALLOWED_PRINCIPALS_POLICY
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: intl.formatMessage({
|
label: intl.formatMessage({
|
||||||
@@ -424,15 +459,14 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
|
|||||||
action={action}
|
action={action}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{allowedUsersOverride &&
|
{allowedUsersOverride && accessPolicy === overridePolicyValue && (
|
||||||
accessPolicy === overridePolicyValue && (
|
|
||||||
<allowedUsersOverride.Field
|
<allowedUsersOverride.Field
|
||||||
form={form}
|
form={form}
|
||||||
routeId={currentData?.id}
|
routeId={currentData?.id}
|
||||||
action={action}
|
action={action}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!allowedUsersOverride && accessPolicy === 'allowed_users' && (
|
{!allowedUsersOverride && accessPolicy === ALLOWED_PRINCIPALS_POLICY && (
|
||||||
<>
|
<>
|
||||||
<Label>
|
<Label>
|
||||||
{intl.formatMessage({ id: 'models.table.userSelection' })}
|
{intl.formatMessage({ id: 'models.table.userSelection' })}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
@@ -7,7 +8,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 from './form';
|
import AccessControlForm, { ALLOWED_PRINCIPALS_POLICY } from './form';
|
||||||
|
|
||||||
const AccessControlModal: React.FC<
|
const AccessControlModal: React.FC<
|
||||||
Global.ScrollerModalProps<RouteItem, AccessControlFormData>
|
Global.ScrollerModalProps<RouteItem, AccessControlFormData>
|
||||||
@@ -23,17 +24,55 @@ const AccessControlModal: React.FC<
|
|||||||
|
|
||||||
const handleOnFinish = async (values: AccessControlFormData) => {
|
const handleOnFinish = async (values: AccessControlFormData) => {
|
||||||
try {
|
try {
|
||||||
const data: AccessControlFormData = {
|
// Everything saves through the single `/access` POST as
|
||||||
|
// `principals` (one request, one success toast):
|
||||||
|
// * principal-based override active → its staged full grant set.
|
||||||
|
// * user picker → selected users mapped to USER-kind grants,
|
||||||
|
// plus any non-user grants preserved from the GET snapshot
|
||||||
|
// (so a user-only UI never wipes org/group grants it can't
|
||||||
|
// see — though in practice the picker only runs where none
|
||||||
|
// exist).
|
||||||
|
// * otherwise (authed/public) → send neither, so the server
|
||||||
|
// leaves existing grants untouched.
|
||||||
|
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,
|
access_policy: values.access_policy,
|
||||||
// `users` is only meaningful for the legacy `allowed_users`
|
principals: values.principals || []
|
||||||
// policy; for the plugin override (typically the principal-
|
|
||||||
// based policy) the plugin's Field manages its own state
|
|
||||||
// inline via the principal CRUD endpoints, so we send an
|
|
||||||
// empty list to clear any stale user grants from a prior
|
|
||||||
// policy switch.
|
|
||||||
users:
|
|
||||||
values.access_policy === 'allowed_users' ? values.users || [] : []
|
|
||||||
};
|
};
|
||||||
|
} 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,
|
||||||
message: renderMessage(data.filename),
|
title: 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="flex-center instance-name">
|
<span className="instance-name flex-center" style={{ gap: 4 }}>
|
||||||
<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}
|
||||||
|
|||||||
@@ -415,7 +415,8 @@ 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,6 +30,7 @@ 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;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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;
|
||||||
@@ -378,9 +379,24 @@ 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.
|
// accepted alongside the built-ins. The OSS "specific users" entry
|
||||||
|
// 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 & {});
|
||||||
users: { id: number }[];
|
// Omitted when the caller isn't managing the user list (the
|
||||||
|
// 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 {
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ const BackendFields: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
link: (
|
link: (
|
||||||
<a onClick={() => navigate('/resources/backends')}>
|
<a onClick={() => navigate('/models/backends')}>
|
||||||
{intl.formatMessage({ id: 'backends.title' })}
|
{intl.formatMessage({ id: 'backends.title' })}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 { sourceOptions } from '../config';
|
import { DeployFormKeyMap, 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,7 +68,6 @@ 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<
|
||||||
@@ -88,7 +87,6 @@ interface BasicFormProps {
|
|||||||
|
|
||||||
const BasicForm: React.FC<BasicFormProps> = (props) => {
|
const BasicForm: React.FC<BasicFormProps> = (props) => {
|
||||||
const {
|
const {
|
||||||
fields = [],
|
|
||||||
sourceList,
|
sourceList,
|
||||||
clusterList,
|
clusterList,
|
||||||
sourceDisable,
|
sourceDisable,
|
||||||
@@ -98,7 +96,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 } = useFormContext();
|
const { onValuesChange, action, formKey } = useFormContext();
|
||||||
|
|
||||||
const handleOnSourceChange = (val: string) => {
|
const handleOnSourceChange = (val: string) => {
|
||||||
onSourceChange?.(val);
|
onSourceChange?.(val);
|
||||||
@@ -183,9 +181,10 @@ 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,
|
required: true,
|
||||||
@@ -205,8 +204,6 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
|
|||||||
></SealSelect>
|
></SealSelect>
|
||||||
}
|
}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
|
||||||
|
|
||||||
<OnlineSource></OnlineSource>
|
<OnlineSource></OnlineSource>
|
||||||
<LocalPathSource></LocalPathSource>
|
<LocalPathSource></LocalPathSource>
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
|
|||||||
sourceDisable = true,
|
sourceDisable = true,
|
||||||
sourceList,
|
sourceList,
|
||||||
clusterList = [],
|
clusterList = [],
|
||||||
fields = ['source'],
|
|
||||||
clearCacheFormValues,
|
clearCacheFormValues,
|
||||||
onBackendChange,
|
onBackendChange,
|
||||||
onSourceChange,
|
onSourceChange,
|
||||||
@@ -102,6 +101,7 @@ 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,6 +230,7 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,6 +284,8 @@ 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[] = [];
|
||||||
@@ -399,6 +402,7 @@ 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
|
||||||
@@ -456,7 +460,6 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BasicForm
|
<BasicForm
|
||||||
fields={fields}
|
|
||||||
sourceList={sourceList}
|
sourceList={sourceList}
|
||||||
clusterList={clusterList}
|
clusterList={clusterList}
|
||||||
sourceDisable={sourceDisable}
|
sourceDisable={sourceDisable}
|
||||||
|
|||||||
@@ -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.resources.modelfiles' })
|
? intl.formatMessage({ id: 'menu.models.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.resources.modelfiles' })}`,
|
label: `${sourcePrefix}: ${intl.formatMessage({ id: 'menu.models.modelfiles' })}`,
|
||||||
groupValue: modelSourceMap.local_path_value,
|
groupValue: modelSourceMap.local_path_value,
|
||||||
options: []
|
options: []
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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';
|
||||||
@@ -17,10 +18,13 @@ 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>('');
|
||||||
|
|
||||||
@@ -49,6 +53,7 @@ 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', []);
|
||||||
@@ -147,9 +152,6 @@ 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) {
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -36,6 +35,7 @@ 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,6 +52,7 @@ 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"
|
||||||
>
|
>
|
||||||
@@ -67,6 +68,7 @@ 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,
|
||||||
@@ -85,6 +87,7 @@ 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 { Tooltip } from 'antd';
|
import { Flex, 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,12 +158,19 @@ const useModelsColumns = ({
|
|||||||
sorter: tableSorter(1),
|
sorter: tableSorter(1),
|
||||||
span: 5,
|
span: 5,
|
||||||
render: (text: string, record: ListItem) => (
|
render: (text: string, record: ListItem) => (
|
||||||
<span className="flex-center" style={{ maxWidth: '100%' }}>
|
<Flex align="center" gap={4} style={{ maxWidth: '100%' }}>
|
||||||
<AutoTooltip ghost>
|
<AutoTooltip
|
||||||
|
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] || ''} />
|
||||||
</span>
|
</Flex>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ 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,7 +4,6 @@ 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';
|
||||||
@@ -12,7 +11,6 @@ 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
|
||||||
@@ -29,10 +27,6 @@ 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"
|
||||||
@@ -48,18 +42,6 @@ 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -101,10 +83,7 @@ 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) => (
|
||||||
<>
|
|
||||||
<Flex gap={8} wrap="wrap">
|
|
||||||
<NameCell
|
<NameCell
|
||||||
showWorkerInfo={true}
|
showWorkerInfo={true}
|
||||||
record={record}
|
record={record}
|
||||||
@@ -118,21 +97,6 @@ const useInstancesColumns = (options: {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
></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> */}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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: '//at.alicdn.com/t/c/font_4613488_vy4szhexci.js',
|
iconUrl: '',
|
||||||
isDarkTheme: userSettings.isDarkTheme,
|
isDarkTheme: userSettings.isDarkTheme,
|
||||||
defaultColorPrimary: COLOR_PRIMARY
|
defaultColorPrimary: COLOR_PRIMARY
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ const useRegisterRoute = () => {
|
|||||||
routeTargets: [
|
routeTargets: [
|
||||||
{
|
{
|
||||||
weight: 100,
|
weight: 100,
|
||||||
provider_model_name: model.name,
|
overridden_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.provider_model_name}
|
: data.overridden_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.lora_module_name && (
|
{!!data.overridden_model_name && !!data.model_id && (
|
||||||
<FilesTag color="purple" variant="outlined">
|
<FilesTag color="purple" variant="outlined">
|
||||||
<span style={{ opacity: 1 }}>LoRA</span>
|
<span style={{ opacity: 1 }}>LoRA</span>
|
||||||
</FilesTag>
|
</FilesTag>
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
|
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: {
|
fallback_target: RouteTargetFormItem | null;
|
||||||
provider_model_name?: string;
|
targets: RouteTargetFormItem[];
|
||||||
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 {
|
||||||
@@ -36,7 +34,7 @@ export interface RouteItem {
|
|||||||
access_policy: string;
|
access_policy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteTarget {
|
export interface RouteTarget extends RouteTargetFormItem {
|
||||||
id: number;
|
id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -46,8 +44,7 @@ export interface RouteTarget {
|
|||||||
route_name: string;
|
route_name: string;
|
||||||
route_id: number;
|
route_id: number;
|
||||||
provider_id: number;
|
provider_id: number;
|
||||||
provider_model_name: string;
|
overridden_model_name: string;
|
||||||
fallback_status_codes: string[];
|
fallback_status_codes: string[];
|
||||||
lora_module_name?: string;
|
|
||||||
state: string;
|
state: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,23 +14,55 @@ 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 { FormData, RouteItem as ListItem } from '../config/types';
|
import {
|
||||||
|
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?: {
|
routeTargets?: RouteTargetFormItem[];
|
||||||
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;
|
||||||
@@ -92,25 +124,12 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
|
|||||||
const fallbackTarget = values.fallback_target;
|
const fallbackTarget = values.fallback_target;
|
||||||
|
|
||||||
if (fallbackTarget) {
|
if (fallbackTarget) {
|
||||||
const exsitinged = targetList.find((ep) => {
|
const existingTarget = targetList.find((target) =>
|
||||||
if (fallbackTarget!.model_id) {
|
isSameTarget(target, fallbackTarget)
|
||||||
return (
|
|
||||||
ep.model_id === fallbackTarget!.model_id &&
|
|
||||||
ep.lora_module_name === fallbackTarget!.lora_module_name
|
|
||||||
);
|
);
|
||||||
}
|
if (existingTarget) {
|
||||||
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 (
|
if (isSameTarget(ep, fallbackTarget)) {
|
||||||
(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']
|
||||||
@@ -120,7 +139,7 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!exsitinged) {
|
if (!existingTarget) {
|
||||||
targetList.push({
|
targetList.push({
|
||||||
...fallbackTarget,
|
...fallbackTarget,
|
||||||
weight: 0,
|
weight: 0,
|
||||||
@@ -129,7 +148,7 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return targetList;
|
return targetList.map((target) => normalizeTarget(target));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOnFinish = (values: FormData) => {
|
const handleOnFinish = (values: FormData) => {
|
||||||
@@ -148,15 +167,7 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initDataList = async (
|
const initDataList = async (targets: RouteTargetFormItem[]) => {
|
||||||
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) => ({
|
||||||
@@ -164,11 +175,11 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
|
|||||||
value: ep.model_id
|
value: ep.model_id
|
||||||
? [
|
? [
|
||||||
'deployments',
|
'deployments',
|
||||||
ep.lora_module_name
|
ep.overridden_model_name
|
||||||
? `${ep.model_id}_lora_${ep.lora_module_name}`
|
? `${ep.model_id}_lora_${ep.overridden_model_name}`
|
||||||
: ep.model_id
|
: ep.model_id
|
||||||
]
|
]
|
||||||
: [ep.provider_id, ep.provider_model_name]
|
: [ep.provider_id, ep.overridden_model_name]
|
||||||
})) || []
|
})) || []
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -192,11 +203,11 @@ const AccessForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
|
|||||||
value: fallbackTarget.model_id
|
value: fallbackTarget.model_id
|
||||||
? [
|
? [
|
||||||
'deployments',
|
'deployments',
|
||||||
fallbackTarget.lora_module_name
|
fallbackTarget.overridden_model_name
|
||||||
? `${fallbackTarget.model_id}_lora_${fallbackTarget.lora_module_name}`
|
? `${fallbackTarget.model_id}_lora_${fallbackTarget.overridden_model_name}`
|
||||||
: fallbackTarget.model_id
|
: fallbackTarget.model_id
|
||||||
]
|
]
|
||||||
: [fallbackTarget.provider_id, fallbackTarget.provider_model_name]
|
: [fallbackTarget.provider_id, fallbackTarget.overridden_model_name]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -6,6 +7,13 @@ 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) &&
|
||||||
@@ -14,15 +22,15 @@ const useOpenPlayground = () => {
|
|||||||
modelCategoriesMap.speech_to_text
|
modelCategoriesMap.speech_to_text
|
||||||
].includes(category)
|
].includes(category)
|
||||||
) {
|
) {
|
||||||
navigate(`${path}&model=${row.name}`);
|
navigate(`${path}&model=${modelName}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.categories?.includes(category)) {
|
if (row.categories?.includes(category)) {
|
||||||
navigate(`${path}?model=${row.name}`);
|
navigate(`${path}?model=${modelName}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
navigate(`/playground/chat?model=${row.name}`);
|
navigate(`/playground/chat?model=${modelName}`);
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
handleOpenPlayGround
|
handleOpenPlayGround
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user