Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bda8264788 | ||
|
|
ab8e792276 | ||
|
|
0e4be63b6b | ||
|
|
d3ebea9090 | ||
|
|
15a9e23243 | ||
|
|
eab7d9c77f | ||
|
|
4a86b71018 | ||
|
|
6350f7f11a | ||
|
|
ce6388253c | ||
|
|
d8f6064d5e | ||
|
|
9f5d95f565 | ||
|
|
0cabbeb81c | ||
|
|
76618aa3a1 | ||
|
|
b19db0f67d | ||
|
|
76f228bfe7 | ||
|
|
be40692773 | ||
|
|
42ac377c17 | ||
|
|
46f859cc3c | ||
|
|
f2bff0d1fb | ||
|
|
0bd98b9662 | ||
|
|
1ac2c02197 | ||
|
|
907187d53a | ||
|
|
faf6246fe6 | ||
|
|
0c16e2be7a | ||
|
|
3212882895 | ||
|
|
62f34ffcaf | ||
|
|
50601b0aff | ||
|
|
4c5d42cb13 | ||
|
|
85268b8917 | ||
|
|
973d2c4529 | ||
|
|
ab766b8c54 | ||
|
|
4945242c87 | ||
|
|
c4a72eb375 | ||
|
|
b12d11728d | ||
|
|
8f0e3a7576 | ||
|
|
4810ff2dbe | ||
|
|
a221b22be9 | ||
|
|
24b458530d | ||
|
|
caea43590a | ||
|
|
c3d7a0b85e | ||
|
|
5c6f28027d | ||
|
|
dc04529f5b | ||
|
|
2bb04a419d | ||
|
|
8e31b71668 | ||
|
|
f055453889 | ||
|
|
820a6cecdd | ||
|
|
c3a779e4c8 | ||
|
|
00008abf03 | ||
|
|
d9c6f2f780 | ||
|
|
9a4d77e869 | ||
|
|
2407416e33 | ||
|
|
234e42ccfa | ||
|
|
4426b8dc30 | ||
|
|
9541f49f39 | ||
|
|
9f7a99f370 | ||
|
|
12f0bb2f98 | ||
|
|
ac45a72e0e | ||
|
|
9705818b15 | ||
|
|
39374eafda | ||
|
|
110bc3205e | ||
|
|
57fec89f2d | ||
|
|
72e794c281 | ||
|
|
fadbcc3e44 | ||
|
|
9e30e964e6 | ||
|
|
fbb707d014 | ||
|
|
d2d82ee5ee | ||
|
|
9061fa08ba | ||
|
|
f29236518e | ||
|
|
6537de5224 | ||
|
|
0413d470a8 | ||
|
|
c01f4d3ce8 | ||
|
|
dd9d359bb0 | ||
|
|
b444c35643 | ||
|
|
abd0d52fad | ||
|
|
c8771dd3b9 | ||
|
|
6d16f56183 | ||
|
|
95ffe0350e | ||
|
|
e314f25a77 | ||
|
|
65ea65e9ae | ||
|
|
4ce975562e | ||
|
|
07e625f2bc | ||
|
|
d95fd8dc90 | ||
|
|
55028cf3a3 |
+59
-42
@@ -140,15 +140,6 @@ const baseRoutes = [
|
||||
access: 'canSeeOrgAdmin',
|
||||
component: './model-routes/index'
|
||||
},
|
||||
{
|
||||
name: 'usage',
|
||||
path: '/models/usage',
|
||||
key: 'usage',
|
||||
icon: 'icon-usage-outlined',
|
||||
selectedIcon: 'icon-usage-filled',
|
||||
defaultIcon: 'icon-usage-outlined',
|
||||
component: './usage/index'
|
||||
},
|
||||
{
|
||||
name: 'providers',
|
||||
path: '/models/providers',
|
||||
@@ -197,6 +188,7 @@ const baseRoutes = [
|
||||
icon: 'icon-files',
|
||||
selectedIcon: 'icon-files-filled',
|
||||
defaultIcon: 'icon-files',
|
||||
access: 'canSeeOrgAdmin',
|
||||
component: './resources/components/model-files'
|
||||
}
|
||||
]
|
||||
@@ -274,6 +266,16 @@ const baseRoutes = [
|
||||
path: '/resources',
|
||||
redirect: '/resources/workers'
|
||||
},
|
||||
{
|
||||
name: 'clusters',
|
||||
path: '/resources/clusters/list',
|
||||
key: 'clusters',
|
||||
icon: 'icon-cluster2-outline',
|
||||
selectedIcon: 'icon-cluster2-filled',
|
||||
defaultIcon: 'icon-cluster2-outline',
|
||||
component: './cluster-management/clusters',
|
||||
subMenu: ['/resources/clusters/detail', '/resources/clusters/create']
|
||||
},
|
||||
{
|
||||
name: 'workers',
|
||||
path: '/resources/workers',
|
||||
@@ -291,50 +293,51 @@ const baseRoutes = [
|
||||
selectedIcon: 'icon-gpu-filled',
|
||||
defaultIcon: 'icon-gpu1',
|
||||
component: './resources/components/gpus'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'clusterManagement',
|
||||
path: '/cluster-management',
|
||||
key: 'clusterManagement',
|
||||
access: 'canSeeOrgAdmin',
|
||||
routes: [
|
||||
{
|
||||
path: '/cluster-management',
|
||||
redirect: '/cluster-management/clusters/list'
|
||||
},
|
||||
{
|
||||
name: 'clusters',
|
||||
path: '/cluster-management/clusters/list',
|
||||
key: 'clusters',
|
||||
icon: 'icon-cluster2-outline',
|
||||
selectedIcon: 'icon-cluster2-filled',
|
||||
defaultIcon: 'icon-cluster2-outline',
|
||||
component: './cluster-management/clusters',
|
||||
subMenu: [
|
||||
'/cluster-management/clusters/detail',
|
||||
'/cluster-management/clusters/create'
|
||||
]
|
||||
name: 'credentials',
|
||||
path: '/resources/credentials',
|
||||
key: 'credentials',
|
||||
icon: 'icon-credential-outline',
|
||||
selectedIcon: 'icon-credential-filled',
|
||||
defaultIcon: 'icon-credential-outline',
|
||||
component: './cluster-management/credentials'
|
||||
},
|
||||
{
|
||||
name: 'clusterDetail',
|
||||
path: '/cluster-management/clusters/detail',
|
||||
path: '/resources/clusters/detail',
|
||||
key: 'clusterDetail',
|
||||
icon: 'icon-cluster2-outline',
|
||||
selectedIcon: 'icon-cluster2-filled',
|
||||
defaultIcon: 'icon-cluster2-outline',
|
||||
hideInMenu: true,
|
||||
component: './cluster-management/cluster-detail'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// Cross-resource consumption (tokens + GPU/CPU instances + storage).
|
||||
// A folder so it matches the other top-level groups; more usage views can
|
||||
// graduate in here later.
|
||||
name: 'billingAndUsage',
|
||||
path: '/usage',
|
||||
key: 'usageGroup',
|
||||
icon: 'icon-usage-outlined',
|
||||
selectedIcon: 'icon-usage-filled',
|
||||
defaultIcon: 'icon-usage-outlined',
|
||||
routes: [
|
||||
{
|
||||
path: '/usage',
|
||||
redirect: '/usage/overview'
|
||||
},
|
||||
{
|
||||
name: 'credentials',
|
||||
path: '/cluster-management/credentials',
|
||||
key: 'credentials',
|
||||
icon: 'icon-credential-outline',
|
||||
selectedIcon: 'icon-credential-filled',
|
||||
defaultIcon: 'icon-credential-outline',
|
||||
component: './cluster-management/credentials'
|
||||
name: 'usage',
|
||||
path: '/usage/overview',
|
||||
key: 'usage',
|
||||
icon: 'icon-usage-outlined',
|
||||
selectedIcon: 'icon-usage-filled',
|
||||
defaultIcon: 'icon-usage-outlined',
|
||||
component: './usage/index'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -347,6 +350,20 @@ const baseRoutes = [
|
||||
path: '/access-control',
|
||||
redirect: '/access-control/users'
|
||||
},
|
||||
{
|
||||
name: 'organizations',
|
||||
path: '/access-control/organizations',
|
||||
key: 'organizations',
|
||||
icon: 'icon-org-outlined',
|
||||
selectedIcon: 'icon-org-filled',
|
||||
defaultIcon: 'icon-org-outlined',
|
||||
// OSS exposes the menu to platform admins as a teaser for the
|
||||
// enterprise multi-tenancy module. The page itself just renders
|
||||
// an upsell notice — the real CRUD UI lives in the enterprise
|
||||
// plugin and shadows this route via `routes.extensions.ts`.
|
||||
access: 'canSeeAdmin',
|
||||
component: './organizations'
|
||||
},
|
||||
{
|
||||
name: 'users',
|
||||
path: '/access-control/users',
|
||||
@@ -380,8 +397,8 @@ const baseRoutes = [
|
||||
},
|
||||
{
|
||||
name: 'profile',
|
||||
path: '/profile',
|
||||
key: 'profile',
|
||||
path: '/preferences',
|
||||
key: 'preferences',
|
||||
hideInMenu: true,
|
||||
component: './profile',
|
||||
icon: 'User'
|
||||
|
||||
+24
-10
@@ -1,13 +1,27 @@
|
||||
const child_process = require('child_process');
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export const getBranchInfo = () => {
|
||||
const latestCommit = child_process
|
||||
.execSync('git rev-parse HEAD')
|
||||
.toString()
|
||||
.trim();
|
||||
const versionTag = child_process
|
||||
.execSync(`git tag --contains ${latestCommit}`)
|
||||
.toString()
|
||||
.trim();
|
||||
return { version: versionTag || '', commitId: latestCommit.slice(0, 7) };
|
||||
// git may be absent (source archive, bare container) or this tree may
|
||||
// not be a git checkout. Swallow the failure and fall back to the env
|
||||
// overrides below — losing build info shouldn't fail the build.
|
||||
let latestCommit = '';
|
||||
let versionTag = '';
|
||||
try {
|
||||
latestCommit = execSync('git rev-parse HEAD').toString().trim();
|
||||
versionTag = execSync(`git tag --contains ${latestCommit}`)
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
// Not a git checkout / git unavailable; rely on env overrides.
|
||||
}
|
||||
// Respect explicit GPUSTACK_UI_* overrides so a wrapping build that
|
||||
// checks this source tree out as a sub-package can stamp its own
|
||||
// release tag and commit id onto the UI (otherwise the panel reports
|
||||
// the host tree's git HEAD, which the wrapper doesn't control).
|
||||
const overrideVersion = process.env.GPUSTACK_UI_VERSION?.trim();
|
||||
const overrideCommitId = process.env.GPUSTACK_UI_COMMIT_ID?.trim();
|
||||
return {
|
||||
version: overrideVersion || versionTag || '',
|
||||
commitId: overrideCommitId || latestCommit.slice(0, 7)
|
||||
};
|
||||
};
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"@ant-design/pro-components": "3.1.0-0",
|
||||
"@antv/g6": "^5.0.51",
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@gpustack/core-ui": "^1.0.19",
|
||||
"@gpustack/core-ui": "^1.0.22",
|
||||
"@huggingface/gguf": "^0.1.7",
|
||||
"@huggingface/hub": "^0.15.1",
|
||||
"@huggingface/tasks": "^0.11.6",
|
||||
|
||||
Generated
+5
-5
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.2
|
||||
'@gpustack/core-ui':
|
||||
specifier: ^1.0.19
|
||||
version: 1.0.19(czdvzceysqw7iv6pct2ucnb23e)
|
||||
specifier: ^1.0.22
|
||||
version: 1.0.22(czdvzceysqw7iv6pct2ucnb23e)
|
||||
'@huggingface/gguf':
|
||||
specifier: ^0.1.7
|
||||
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}
|
||||
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
|
||||
|
||||
'@gpustack/core-ui@1.0.19':
|
||||
resolution: {integrity: sha512-EI2inYoTDdWYdxXXm0BM2DQ7chysnbIXICivBMeUxxhc8IkEcwaQfNqo/KUtA/bkqjDieRxFC1JlsDIIhbcL3A==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.19.tgz}
|
||||
'@gpustack/core-ui@1.0.22':
|
||||
resolution: {integrity: sha512-uFLGalziwuojjMIeXMlCZn3tLRGA6PCa0JxLxa/+kKP+xAsy7Fcxgh4PerbTyk+P+hKnYv8cD+DPpkObDGqJgg==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.22.tgz}
|
||||
peerDependencies:
|
||||
'@ant-design/icons': '>=6.0.0'
|
||||
'@ant-design/pro-components': 3.1.0-0
|
||||
@@ -10808,7 +10808,7 @@ snapshots:
|
||||
|
||||
'@formatjs/intl-utils@2.3.0': {}
|
||||
|
||||
'@gpustack/core-ui@1.0.19(czdvzceysqw7iv6pct2ucnb23e)':
|
||||
'@gpustack/core-ui@1.0.22(czdvzceysqw7iv6pct2ucnb23e)':
|
||||
dependencies:
|
||||
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
+7
-1
@@ -3,6 +3,7 @@ import { applyAccessExtensions } from './access.extensions';
|
||||
export default (initialState: {
|
||||
currentUser?: Global.UserInfo;
|
||||
hasKubernetesCluster?: boolean;
|
||||
hasResourceEvents?: boolean;
|
||||
}) => {
|
||||
const isPlatformAdmin = !!(
|
||||
initialState &&
|
||||
@@ -20,6 +21,10 @@ export default (initialState: {
|
||||
// role-based default so a transient network blip can't lock anyone
|
||||
// out of the menu.
|
||||
const hasKubernetesCluster = initialState?.hasKubernetesCluster;
|
||||
// Having run GPU/CPU instances or storage (any resource_events) also unlocks
|
||||
// GPU Service / the full Usage page — a user who used it keeps seeing it even
|
||||
// without a current cluster. MaaS-only users (no cluster, no events) don't.
|
||||
const hasResourceEvents = !!initialState?.hasResourceEvents;
|
||||
|
||||
// Predicate roles, top-down by strictness:
|
||||
// * `canSeeAdmin` — strictly platform admin (`users.is_admin`).
|
||||
@@ -41,7 +46,8 @@ export default (initialState: {
|
||||
return applyAccessExtensions({
|
||||
canSeeAdmin: isPlatformAdmin,
|
||||
canSeeOrgAdmin: isPlatformAdmin,
|
||||
canSeeGpuService: isPlatformAdmin || hasKubernetesCluster !== false,
|
||||
canSeeGpuService:
|
||||
isPlatformAdmin || hasKubernetesCluster !== false || hasResourceEvents,
|
||||
canManageCurrentOrg: false,
|
||||
canSeeUser,
|
||||
canDelete: true,
|
||||
|
||||
+87
-7
@@ -1,10 +1,12 @@
|
||||
import { userSettingsHelperAtom } from '@/atoms/settings';
|
||||
import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user';
|
||||
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
|
||||
import { setAtomStorage } from '@/atoms/utils';
|
||||
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
|
||||
import { COLOR_PRIMARY } from '@/config/theme/constants';
|
||||
import { queryClusterList } from '@/pages/cluster-management/apis';
|
||||
import { ProviderValueMap } from '@/pages/cluster-management/config';
|
||||
import { queryResourceEvents } from '@/pages/usage/apis/resource';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { enterprisePluginReady } from '@/plugins/enterprise-ready';
|
||||
import { GPUStackPluginManager } from '@/plugins/manager';
|
||||
import { requestConfig } from '@/request-config';
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
} from '@/services/profile/apis';
|
||||
import { fetchSystemConfig } from '@/services/system/query-system-config';
|
||||
import { isOnline } from '@/utils';
|
||||
import { installTenantFetch } from '@/utils/install-fetch';
|
||||
import {
|
||||
IS_FIRST_LOGIN,
|
||||
readState,
|
||||
@@ -24,6 +27,8 @@ import '@gpustack/core-ui/style.css';
|
||||
import { RequestConfig, history, request as umiRequest } from '@umijs/max';
|
||||
import { message } from 'antd';
|
||||
|
||||
installTenantFetch();
|
||||
|
||||
// only for the first login and access from http://localhost
|
||||
|
||||
const checkDefaultPage = async (userInfo: any) => {
|
||||
@@ -47,7 +52,12 @@ const checkDefaultPage = async (userInfo: any) => {
|
||||
const HAS_K8S_CLUSTER_KEY = 'hasKubernetesCluster';
|
||||
const probeHasKubernetesCluster = async (): Promise<boolean | undefined> => {
|
||||
try {
|
||||
const res = await queryClusterList({ page: -1 });
|
||||
const res = await queryClusterList(
|
||||
{ page: -1 },
|
||||
{
|
||||
skipErrorHandler: true
|
||||
}
|
||||
);
|
||||
const value = (res?.items ?? []).some(
|
||||
(c) => c?.provider === ProviderValueMap.Kubernetes
|
||||
);
|
||||
@@ -69,12 +79,49 @@ const probeHasKubernetesCluster = async (): Promise<boolean | undefined> => {
|
||||
}
|
||||
};
|
||||
|
||||
// Probes whether the caller has ANY resource-usage events (GPU/CPU instance or
|
||||
// storage lifecycle). Used alongside the cluster probe so a user who has run
|
||||
// GPU instances still sees GPU Service / the full Usage page even if they
|
||||
// currently have no Kubernetes cluster. Mirrored into sessionStorage for the
|
||||
// access extensions; any failure → undefined ("unknown — don't restrict").
|
||||
const HAS_RESOURCE_EVENTS_KEY = 'hasResourceEvents';
|
||||
const probeHasResourceEvents = async (): Promise<boolean | undefined> => {
|
||||
try {
|
||||
// No date range = "ever"; scope is clamped to the caller server-side.
|
||||
const res = await queryResourceEvents(
|
||||
{ perPage: 1 },
|
||||
{
|
||||
skipErrorHandler: true
|
||||
}
|
||||
);
|
||||
const value = (res?.pagination?.total ?? 0) > 0;
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
HAS_RESOURCE_EVENTS_KEY,
|
||||
JSON.stringify(value)
|
||||
);
|
||||
} catch {
|
||||
// sessionStorage may be unavailable; predicate treats missing as unknown.
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error('probeHasResourceEvents error', error);
|
||||
try {
|
||||
window.sessionStorage.removeItem(HAS_RESOURCE_EVENTS_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// runtime configuration
|
||||
export async function getInitialState(): Promise<{
|
||||
fetchUserInfo: () => Promise<Global.UserInfo>;
|
||||
currentUser?: Global.UserInfo;
|
||||
pluginData?: Record<string, any>;
|
||||
hasKubernetesCluster?: boolean;
|
||||
hasResourceEvents?: boolean;
|
||||
}> {
|
||||
const { location } = history;
|
||||
|
||||
@@ -119,6 +166,36 @@ export async function getInitialState(): Promise<{
|
||||
getUpdateCheck();
|
||||
fetchSystemConfig();
|
||||
}
|
||||
// Only commit a substantive user object. A truthy-but-empty
|
||||
// `data` (e.g. server responded 200 with an empty body) would
|
||||
// otherwise look like "logged in" to every `currentUser`
|
||||
// reader and the access seam — break out instead and let the
|
||||
// caller treat the request as failed.
|
||||
if (data && typeof data === 'object' && Object.keys(data).length > 0) {
|
||||
// Commit the identity to atom storage (and so to localStorage)
|
||||
// before returning. The access function — memoized on
|
||||
// `initialState` and run once per commit — reads identity from
|
||||
// localStorage; without this preemptive write the predicate
|
||||
// sees the prior session's identity on its first evaluation
|
||||
// after login, and stays stale until the next identity change
|
||||
// (which usually doesn't come without a manual refresh).
|
||||
try {
|
||||
setAtomStorage(userAtom, data);
|
||||
} catch (err) {
|
||||
console.error('userAtom commit error:', err);
|
||||
}
|
||||
// Fire `onUserFetched` so plugins maintaining identity-scoped
|
||||
// caches can seed them under the new identity before any
|
||||
// caller commits this user to `initialState`. Errors here are
|
||||
// swallowed and logged — fetchUserInfo must still return.
|
||||
try {
|
||||
await getGPUStackPlugin()?.login?.onUserFetched?.(data, {
|
||||
request: umiRequest
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('onUserFetched plugin hook error:', err);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
const data = error?.response?.data;
|
||||
@@ -158,16 +235,19 @@ export async function getInitialState(): Promise<{
|
||||
getAppVersionInfo();
|
||||
|
||||
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
|
||||
const [userInfo, hasKubernetesCluster] = await Promise.all([
|
||||
fetchUserInfo(),
|
||||
probeHasKubernetesCluster()
|
||||
]);
|
||||
const [userInfo, hasKubernetesCluster, hasResourceEvents] =
|
||||
await Promise.all([
|
||||
fetchUserInfo(),
|
||||
probeHasKubernetesCluster(),
|
||||
probeHasResourceEvents()
|
||||
]);
|
||||
checkDefaultPage(userInfo);
|
||||
return {
|
||||
fetchUserInfo,
|
||||
currentUser: userInfo,
|
||||
pluginData,
|
||||
hasKubernetesCluster
|
||||
hasKubernetesCluster,
|
||||
hasResourceEvents
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
.ant-layout-sider-children {
|
||||
border-inline: none;
|
||||
border-radius: 0;
|
||||
padding-inline-end: 0;
|
||||
padding-block-end: 8px;
|
||||
}
|
||||
|
||||
|
||||
+38
-19
@@ -90,20 +90,33 @@ const getStoredCurrentOrgId = (): number | null => {
|
||||
// cluster-owner fallback.
|
||||
const ORG_CACHE_KEYS = ['organizationList', 'allOrganizations'] as const;
|
||||
|
||||
const lookupOrgNamespace = (id: number | null): string | null => {
|
||||
export interface CachedOrg {
|
||||
id: number;
|
||||
name?: string;
|
||||
// The platform Org (single global tenant). Its models are NOT
|
||||
// namespaced in ``/v1/models`` — they appear under their bare name.
|
||||
is_platform?: boolean;
|
||||
}
|
||||
|
||||
// Resolve the cached Org record for an owner/principal id by scanning both
|
||||
// org caches. ``organizationList`` (the caller's member orgs) is checked
|
||||
// alongside the admin-only ``allOrganizations`` so member sessions resolve
|
||||
// too. Id types vary between localStorage payloads (some writers stringify,
|
||||
// others persist as a JSON number), so compare as strings — strict equality
|
||||
// would silently miss those cases.
|
||||
export const getOrgById = (
|
||||
id: number | string | null | undefined
|
||||
): CachedOrg | null => {
|
||||
if (id == null) return null;
|
||||
// Normalise both sides to strings — the stored id type varies between
|
||||
// localStorage payloads (some writers stringify, others persist as a
|
||||
// JSON number); strict equality would silently miss those cases.
|
||||
const target = String(id);
|
||||
for (const key of ORG_CACHE_KEYS) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) continue;
|
||||
const list = JSON.parse(raw) as Array<{ id: number; name?: string }>;
|
||||
const list = JSON.parse(raw) as CachedOrg[];
|
||||
if (!Array.isArray(list)) continue;
|
||||
const match = list.find((item) => String(item?.id) === target);
|
||||
if (match?.name) return `gpustack-${match.name}`;
|
||||
if (match) return match;
|
||||
} catch {
|
||||
// ignore malformed cache; continue checking other keys
|
||||
}
|
||||
@@ -111,17 +124,23 @@ const lookupOrgNamespace = (id: number | null): string | 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 [];
|
||||
}
|
||||
// Bare Org *name* (e.g. ``org1``) for an owner/principal id, or null.
|
||||
export const getOrgNameById = (
|
||||
id: number | string | null | undefined
|
||||
): string | null => {
|
||||
return getOrgById(id)?.name ?? null;
|
||||
};
|
||||
|
||||
const lookupOrgNamespace = (id: number | null): string | null => {
|
||||
const name = getOrgNameById(id);
|
||||
return name ? `gpustack-${name}` : null;
|
||||
};
|
||||
|
||||
// The Org the caller is currently acting under, or null in the admin-"All"
|
||||
// context. The org-switcher reloads the page on switch, so the list pages
|
||||
// always show this org's resources — which is how ``/v1/models`` namespaces
|
||||
// their model ids (``{org}/{name}``). Callers reconstructing that id use this
|
||||
// as the fallback owner when a row carries no explicit ``owner_principal_id``.
|
||||
export const getCurrentOrg = (): CachedOrg | null => {
|
||||
return getOrgById(getStoredCurrentOrgId());
|
||||
};
|
||||
|
||||
+17
-37
@@ -171,8 +171,6 @@ body {
|
||||
}
|
||||
|
||||
.ant-table .ant-table-container table {
|
||||
// border-spacing: 0 20px;
|
||||
|
||||
.ant-table-thead th.ant-table-column-sort {
|
||||
background-color: transparent;
|
||||
|
||||
@@ -241,8 +239,8 @@ body {
|
||||
height: 100%;
|
||||
|
||||
.ant-pro-sider-footer {
|
||||
padding-block: 2px 0;
|
||||
padding-left: 6px;
|
||||
padding-block: 4px 0;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.ant-pro-sider-actions-list-item {
|
||||
@@ -259,46 +257,27 @@ body {
|
||||
border-block-end: none;
|
||||
}
|
||||
|
||||
.ant-pro-sider-logo-collapsed {
|
||||
padding-left: 12px;
|
||||
cursor: e-resize;
|
||||
|
||||
.collapse-wrap {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 48px;
|
||||
width: 64px;
|
||||
top: -16px;
|
||||
left: -16px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.collapse-wrap {
|
||||
display: block;
|
||||
.ant-pro-sider-logo {
|
||||
.collapse-btn {
|
||||
color: var(--ant-color-text-tertiary);
|
||||
:hover {
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pro-sider .ant-layout-sider-children {
|
||||
// border-right: 1px solid var(--ant-color-split);
|
||||
.ant-pro-sider-logo-collapsed {
|
||||
padding-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-content table {
|
||||
.ant-table-tbody {
|
||||
.ant-table-row {
|
||||
border-radius: var(--table-td-radius);
|
||||
.ant-table .ant-table-tbody {
|
||||
.ant-table-row {
|
||||
border-radius: var(--table-td-radius);
|
||||
|
||||
> td {
|
||||
background-color: unset;
|
||||
border-bottom: 1px solid var(--ant-color-split);
|
||||
}
|
||||
> td {
|
||||
background-color: unset !important;
|
||||
border-bottom: 1px solid var(--ant-color-split);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,7 +326,6 @@ body {
|
||||
.ant-pro-layout-container {
|
||||
overflow-x: auto;
|
||||
min-height: 100vh;
|
||||
// background-color: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.ant-pro-sider {
|
||||
@@ -812,6 +790,8 @@ body {
|
||||
}
|
||||
|
||||
.ant-pro-sider-logo + div {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function useTableFetch<T>(
|
||||
key?: (typeof PaginationKey)[keyof typeof PaginationKey];
|
||||
fetchAPI: (params: any, options?: any) => Promise<Global.PageResponse<T>>;
|
||||
deleteAPI?: (id: number, params?: any) => Promise<any>;
|
||||
afterDelete?: (id?: number | number[]) => void;
|
||||
contentForDelete?: string;
|
||||
defaultData?: any[];
|
||||
events?: EventsType[];
|
||||
@@ -48,6 +49,7 @@ export default function useTableFetch<T>(
|
||||
const {
|
||||
fetchAPI,
|
||||
deleteAPI,
|
||||
afterDelete,
|
||||
contentForDelete,
|
||||
API,
|
||||
polling = false,
|
||||
@@ -363,7 +365,7 @@ export default function useTableFetch<T>(
|
||||
|
||||
// remove the deleted id from selected ids in row selection
|
||||
rowSelection.removeSelectedKeys([row.id]);
|
||||
|
||||
afterDelete?.(row.id);
|
||||
// ======== to avoid fetch data twice, because of debounceFetchData has been run =======
|
||||
if (!updateManually) {
|
||||
fetchData();
|
||||
@@ -390,6 +392,7 @@ export default function useTableFetch<T>(
|
||||
successIds.push(id);
|
||||
}
|
||||
);
|
||||
afterDelete?.(successIds);
|
||||
rowSelection.removeSelectedKeys(successIds);
|
||||
fetchData();
|
||||
return res;
|
||||
|
||||
@@ -14,7 +14,6 @@ type EventsType = 'CREATE' | 'UPDATE' | 'DELETE' | 'INSERT';
|
||||
export function useUpdateChunkedList(options: {
|
||||
events?: EventsType[];
|
||||
dataList?: any[];
|
||||
triggerAt?: React.MutableRefObject<number>;
|
||||
limit?: number;
|
||||
onCreate?: (args: any) => void;
|
||||
onUpdate?: (args: any) => void;
|
||||
@@ -24,9 +23,9 @@ export function useUpdateChunkedList(options: {
|
||||
filterFun?: (args: any) => boolean;
|
||||
mapFun?: (args: any) => any;
|
||||
computedID?: (d: object) => string;
|
||||
isNewItem?: (item: any) => boolean;
|
||||
}) {
|
||||
const { events = ['CREATE', 'DELETE', 'UPDATE', 'INSERT'], triggerAt } =
|
||||
options;
|
||||
const { events = ['CREATE', 'DELETE', 'UPDATE', 'INSERT'] } = options;
|
||||
const deletedIdsRef = useRef<Set<number | string>>(new Set());
|
||||
const cacheDataListRef = useRef<any[]>(options.dataList || []);
|
||||
const timerRef = useRef<any>(null);
|
||||
@@ -71,17 +70,14 @@ export function useUpdateChunkedList(options: {
|
||||
(sItem: any) => sItem.id === item.id
|
||||
);
|
||||
const updateItem = { ...item };
|
||||
if (updateIndex === -1 && !triggerAt?.current) {
|
||||
if (updateIndex === -1) {
|
||||
acc.push(updateItem);
|
||||
} else if (!triggerAt?.current) {
|
||||
} else {
|
||||
cacheDataListRef.current[updateIndex] = updateItem;
|
||||
}
|
||||
// only push items created after the watch started
|
||||
|
||||
// TODO: only push items created after triggerAt
|
||||
if (
|
||||
triggerAt?.current &&
|
||||
Date.parse(item.created_at) >= triggerAt.current
|
||||
) {
|
||||
if (options.isNewItem?.(item)) {
|
||||
latestCreateList.push(updateItem);
|
||||
}
|
||||
|
||||
@@ -103,10 +99,8 @@ export function useUpdateChunkedList(options: {
|
||||
cacheDataListRef.current = cacheDataListRef.current?.filter(
|
||||
(item: any) => {
|
||||
// collect deleted items
|
||||
if (triggerAt?.current) {
|
||||
if (ids?.includes(item.id)) {
|
||||
deletedList.push(item);
|
||||
}
|
||||
if (ids?.includes(item.id) && !options.isNewItem?.(item)) {
|
||||
deletedList.push(item);
|
||||
}
|
||||
return !ids?.includes(item.id);
|
||||
}
|
||||
@@ -133,10 +127,8 @@ export function useUpdateChunkedList(options: {
|
||||
updateItem,
|
||||
...cacheDataListRef.current.slice(0, limit - 1)
|
||||
];
|
||||
if (options.onUpdate && triggerAt?.current) {
|
||||
if (Date.parse(item.created_at) >= triggerAt.current) {
|
||||
options.onUpdate?.([updateItem]);
|
||||
}
|
||||
if (options.onUpdate && options.isNewItem?.(item)) {
|
||||
options.onUpdate?.([updateItem]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -210,14 +210,14 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
|
||||
key: 'settings',
|
||||
label: (
|
||||
<span className="flex flex-center">
|
||||
<IconFont type="icon-settings-02" />
|
||||
<IconFont type="icon-preferences" />
|
||||
<span className="m-l-8" style={{ marginLeft: 8 }}>
|
||||
{intl?.formatMessage?.({ id: 'common.button.settings' })}
|
||||
{intl?.formatMessage?.({ id: 'common.preferences' })}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
onClick: () => {
|
||||
history.push('/profile');
|
||||
history.push('/preferences');
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+21
-37
@@ -21,11 +21,7 @@ import {
|
||||
import { useAccessMarkedRoutes } from '@@/plugin-access';
|
||||
import { useModel } from '@@/plugin-model';
|
||||
import { ProLayout } from '@ant-design/pro-components';
|
||||
import {
|
||||
CoreUIProvider,
|
||||
IconFont,
|
||||
useOverlayScroller
|
||||
} from '@gpustack/core-ui';
|
||||
import { CoreUIProvider, IconFont } from '@gpustack/core-ui';
|
||||
import {
|
||||
Access,
|
||||
Outlet,
|
||||
@@ -72,9 +68,9 @@ const NO_CONTAINER_PAGES = [
|
||||
|
||||
const CHECK_RESOURCE_PATH = [
|
||||
'/resources/workers',
|
||||
'/cluster-management/clusters/list',
|
||||
'/cluster-management/credentials',
|
||||
'/cluster-management/clusters/create'
|
||||
'/resources/clusters/list',
|
||||
'/resources/credentials',
|
||||
'/resources/clusters/create'
|
||||
];
|
||||
|
||||
type NewRoute = IRoute & {
|
||||
@@ -135,9 +131,6 @@ const mapRoutes = (routes: IRoute[], role: string) => {
|
||||
};
|
||||
|
||||
export default (props: any) => {
|
||||
const { initialize: initialize } = useOverlayScroller({
|
||||
defer: false
|
||||
});
|
||||
const [, contextHolder] = Modal.useModal();
|
||||
const { themeData, setUserSettings, userSettings } = useUserSettings();
|
||||
const [userInfo] = useAtom(userAtom);
|
||||
@@ -241,30 +234,7 @@ export default (props: any) => {
|
||||
}, [userSettings.collapsed]);
|
||||
|
||||
const renderMenuHeader = (logo: React.ReactNode, title: React.ReactNode) => {
|
||||
return (
|
||||
<>
|
||||
{logo}
|
||||
<div className="collapse-wrap" onClick={handleToggleCollapse}>
|
||||
<Button
|
||||
style={{
|
||||
marginRight: collapsed ? 0 : -14,
|
||||
border: 'none',
|
||||
cursor: 'w-resize'
|
||||
}}
|
||||
size="small"
|
||||
type={collapsed ? 'default' : 'text'}
|
||||
>
|
||||
<IconFont
|
||||
type={collapsed ? 'icon-expand-left' : 'icon-expand-right'}
|
||||
className="font-size-18 text-secondary"
|
||||
style={{
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return <>{logo}</>;
|
||||
};
|
||||
|
||||
const menuContentRender = (menuProps: any, defaultDom: React.ReactNode) => {
|
||||
@@ -357,7 +327,7 @@ export default (props: any) => {
|
||||
config={{
|
||||
apiBaseUrl: GPUSTACK_API_BASE_URL,
|
||||
theme: userSettings.theme,
|
||||
iconUrl: '',
|
||||
iconUrl: '//at.alicdn.com/t/c/font_4613488_rwchdketjg8.js',
|
||||
isDarkTheme: userSettings.isDarkTheme,
|
||||
defaultColorPrimary: COLOR_PRIMARY
|
||||
}}
|
||||
@@ -408,9 +378,23 @@ export default (props: any) => {
|
||||
openKeys={false}
|
||||
disableMobile={true}
|
||||
siderWidth={220}
|
||||
menuFooterRender={() => (
|
||||
<Button
|
||||
style={{
|
||||
border: 'none'
|
||||
}}
|
||||
size="small"
|
||||
type={'text'}
|
||||
onClick={handleToggleCollapse}
|
||||
>
|
||||
<IconFont
|
||||
type={collapsed ? 'icon-expand-left' : 'icon-expand-right'}
|
||||
className="font-size-18"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
onCollapse={onCollapse}
|
||||
onMenuHeaderClick={onMenuHeaderClick}
|
||||
menuHeaderRender={renderMenuHeader}
|
||||
collapsed={userSettings.collapsed}
|
||||
onPageChange={onPageChange}
|
||||
formatMessage={formatMessage}
|
||||
|
||||
@@ -256,7 +256,7 @@ export const getRightRenderContent = (opts: {
|
||||
</span>
|
||||
),
|
||||
onClick: () => {
|
||||
history.push('/profile');
|
||||
history.push('/preferences');
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+54
-38
@@ -1,5 +1,5 @@
|
||||
import { CaretDownOutlined } from '@ant-design/icons';
|
||||
import { IconFont } from '@gpustack/core-ui';
|
||||
import { IconFont, OverlayScroller } from '@gpustack/core-ui';
|
||||
import { Link, useLocation } from '@umijs/max';
|
||||
import { Tooltip } from 'antd';
|
||||
import { createStyles, type FullToken } from 'antd-style';
|
||||
@@ -29,12 +29,17 @@ const useStyles = createStyles(
|
||||
|
||||
return {
|
||||
siderMenu: css`
|
||||
width: 100%;
|
||||
&.sider-menu-collapsed {
|
||||
.menu-item {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.os-scrollbar-vertical .os-scrollbar-handle {
|
||||
min-width: 4px;
|
||||
max-width: 4px;
|
||||
}
|
||||
`,
|
||||
groupTitle: css`
|
||||
display: flex;
|
||||
@@ -222,44 +227,55 @@ const SiderMenu: React.FC<SiderMenuProps> = (props) => {
|
||||
'sider-menu-collapsed': collapsed
|
||||
})}
|
||||
>
|
||||
{menuData.map((item: MenuItem, index: number) => (
|
||||
<div key={item.key}>
|
||||
{item.children && item.children.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
className={cx(styles.groupTitle, {
|
||||
'menu-item-group-title-collapsed': collapsed
|
||||
})}
|
||||
onClick={(e) => handleToggleGroup(e, item)}
|
||||
>
|
||||
{!collapsed ? (
|
||||
<span className="group-title-text">
|
||||
<span>{item.name}</span>
|
||||
<CaretDownOutlined
|
||||
rotate={collapseKeys.has(item.key) ? -90 : 0}
|
||||
></CaretDownOutlined>
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.line}></span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cx(styles.menuItemGroup, {
|
||||
'menu-item-group-collapsed': collapsed,
|
||||
'menu-item-group-hidden':
|
||||
!collapsed && collapseKeys.has(item.key)
|
||||
})}
|
||||
>
|
||||
{item.children?.map((child: MenuItem) =>
|
||||
menuItemRender(child, child.key)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
menuItemRender(item, item.key)
|
||||
)}
|
||||
<OverlayScroller
|
||||
styles={{
|
||||
wrapper: {
|
||||
paddingInline: 0,
|
||||
maxHeight: '100%'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ paddingRight: 8 }}>
|
||||
{menuData.map((item: MenuItem, index: number) => (
|
||||
<div key={item.key}>
|
||||
{item.children && item.children.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
className={cx(styles.groupTitle, {
|
||||
'menu-item-group-title-collapsed': collapsed
|
||||
})}
|
||||
onClick={(e) => handleToggleGroup(e, item)}
|
||||
>
|
||||
{!collapsed ? (
|
||||
<span className="group-title-text">
|
||||
<span>{item.name}</span>
|
||||
<CaretDownOutlined
|
||||
rotate={collapseKeys.has(item.key) ? -90 : 0}
|
||||
></CaretDownOutlined>
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.line}></span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cx(styles.menuItemGroup, {
|
||||
'menu-item-group-collapsed': collapsed,
|
||||
'menu-item-group-hidden':
|
||||
!collapsed && collapseKeys.has(item.key)
|
||||
})}
|
||||
>
|
||||
{item.children?.map((child: MenuItem) =>
|
||||
menuItemRender(child, child.key)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
menuItemRender(item, item.key)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</OverlayScroller>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,5 +23,7 @@ export default {
|
||||
'apikeys.accessScope.inference': 'Inference APIs',
|
||||
'apikeys.access.permissions': 'Access Permissions',
|
||||
'apikeys.type.auto': 'Auto-generated',
|
||||
'apikeys.type.custom': 'Custom'
|
||||
'apikeys.type.custom': 'Custom',
|
||||
'apikeys.button.ipConfig': 'IP Access Control',
|
||||
'quotaLimits.button.title': 'Quota Limit'
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
'clusters.workerpool.batchSize.desc':
|
||||
'Number of workers created simultaneously in the Worker pool',
|
||||
'clusters.create.addworker.tips':
|
||||
'Please make sure the <a href={link} target="_blank">prerequisites</a> for {label} are met before executing the following command.',
|
||||
'Please make sure the <a href={link} target="_blank">prerequisites</a> are met before executing the following command.',
|
||||
'clusters.create.addCommand.tips':
|
||||
'On the Worker that needs to be added, run the following command to join it to the cluster.',
|
||||
'clusters.create.addCommand.k8s.tips':
|
||||
@@ -110,6 +110,10 @@ export default {
|
||||
'{count} new workers have been added to the cluster.',
|
||||
'clusters.create.serverUrl': 'GPUStack Server URL',
|
||||
'clusters.create.workerConfig': 'Worker Configuration',
|
||||
'clusters.edit.k8sOptions.changed.tip':
|
||||
'You have changed the Kubernetes options. Re-run the registration command on the target cluster for the changes to take effect.',
|
||||
'clusters.edit.workerConfig.tip':
|
||||
'Changes to the worker configuration take effect only after restarting the affected workers.',
|
||||
'clusters.addworker.containerName': 'Worker Container Name',
|
||||
'clusters.addworker.containerName.tips':
|
||||
'Specify a name for the worker container.',
|
||||
@@ -165,7 +169,7 @@ export default {
|
||||
'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.k8sOptions.title': 'Kubernetes Deployment Options',
|
||||
'clusters.imageCredentials.title': 'Image Credentials',
|
||||
'clusters.imageCredentials.add': 'Add Credential',
|
||||
'clusters.imageCredentials.registry': 'Registry',
|
||||
@@ -180,9 +184,14 @@ export default {
|
||||
'clusters.namespace.title': 'Namespace',
|
||||
'clusters.namespace.tip':
|
||||
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||
'clusters.gpuInstances.title': 'GPU Instances',
|
||||
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||
'clusters.gpuInstances.staticAddress': 'Static Access Address',
|
||||
'clusters.clusterType.title': 'Cluster Type',
|
||||
'clusters.modelService.title': 'Model Service',
|
||||
'clusters.modelService.tip':
|
||||
'For LLM inference and API serving — e.g. exposing model APIs and token-based services.',
|
||||
'clusters.gpuInstances.title': 'GPU Service',
|
||||
'clusters.gpuInstances.tip':
|
||||
'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.',
|
||||
'clusters.gpuInstances.staticAddress': 'GPU Service Static Access Address',
|
||||
'clusters.gpuInstances.staticAddress.tip':
|
||||
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ export default {
|
||||
'common.button.enabled': 'Enabled',
|
||||
'common.button.disabled': 'Disabled',
|
||||
'common.button.upgrade': 'Upgrade',
|
||||
'common.enterprise.feature': 'Available in GPUStack Enterprise',
|
||||
'common.input.holder': 'Please enter',
|
||||
'common.validate.value': '{name} value is required',
|
||||
'common.button.edit': 'Edit',
|
||||
@@ -55,6 +56,7 @@ export default {
|
||||
'common.button.viewevent': 'View Events',
|
||||
'common.button.recreate': 'Recreate',
|
||||
'common.table.operation': 'Operations',
|
||||
'common.table.creator': 'Creator',
|
||||
'common.table.createTime': 'Created',
|
||||
'common.table.updateTime': 'Updated',
|
||||
'common.table.description': 'Description',
|
||||
@@ -65,6 +67,7 @@ export default {
|
||||
'common.search.name.placeholder': 'filter by name',
|
||||
'common.search.id.placeholder': 'filter by ID',
|
||||
'common.filter.byId': 'filter by ID',
|
||||
'common.filter.byCreator': 'Filter by creator',
|
||||
'common.table.type': 'Type',
|
||||
'common.table.default': 'Default Value',
|
||||
'common.copy.success': 'Copied success!',
|
||||
@@ -285,5 +288,7 @@ export default {
|
||||
'common.image.limit.height': 'Image height must be {height}.',
|
||||
'common.remaining': 'Remaining {count}',
|
||||
'common.max': 'Max {count}',
|
||||
'common.validate.group': 'Please complete the {group} configuration'
|
||||
'common.max.count': '{label} Count',
|
||||
'common.validate.group': 'Please complete the {group} configuration',
|
||||
'common.preferences': 'Preferences'
|
||||
};
|
||||
|
||||
@@ -13,7 +13,11 @@ export default {
|
||||
'gpuservice.template.command.placeholder':
|
||||
'Separate arguments with spaces; wrap arguments containing spaces in quotes, e.g.: /bin/bash -c "echo hello world"',
|
||||
'gpuservice.template.mountPath': 'Mount Path',
|
||||
'gpuservice.template.mountPath.tips':
|
||||
'The default mount path for the storage volume when creating an instance from this template. Useful for persisting data that needs to be retained while the instance is running.',
|
||||
'gpuservice.template.containerDisk': 'Container Disk (GB)',
|
||||
'gpuservice.template.containerDisk.tips':
|
||||
'The size of the container system disk.',
|
||||
'gpuservice.template.memory': 'Memory (GB)',
|
||||
'gpuservice.instance.containerDisk.remaining':
|
||||
'Container Disk (Max {count} GB)',
|
||||
@@ -97,12 +101,17 @@ export default {
|
||||
'gpuservice.instance.templates': 'Instance Templates',
|
||||
'gpuservice.instance.section.storage': 'Storage',
|
||||
'gpuservice.instance.type.required': 'Please select an instance type',
|
||||
'gpuservice.instance.type.noAvailable': 'No instance type available',
|
||||
'gpuservice.instance.gpuCount': 'GPU Count',
|
||||
'gpuservice.instance.gpuCount.required': 'Please enter the GPU count',
|
||||
'gpuservice.instance.gpuCount.max':
|
||||
'Please select at most {count} GPU card(s)',
|
||||
'gpuservice.instance.gpuCount.min':
|
||||
'Please select at least {count} GPU card(s)',
|
||||
'gpuservice.instance.cpuCount.max':
|
||||
'Please select at most {count} CPU core(s)',
|
||||
'gpuservice.instance.cpuCount.min':
|
||||
'Please select at least {count} CPU core(s)',
|
||||
'gpuservice.instance.gpuCount.noAvailable':
|
||||
'No available GPU resources, please choose another instance type.',
|
||||
'gpuservice.instance.gpuCount.zero':
|
||||
@@ -112,6 +121,10 @@ export default {
|
||||
'gpuservice.instance.memory': 'VRAM',
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.disk': 'Disk',
|
||||
'gpuservice.table.count': 'Count',
|
||||
'gpuservice.instance.disk.system': 'System Disk',
|
||||
'gpuservice.instance.disk.ephemeral': 'Ephemeral Storage',
|
||||
'gpuservice.instance.disk.persistent': 'Persistent Storage',
|
||||
'gpuservice.instance.search.type.placeholder': 'Search by name',
|
||||
'gpuservice.instance.search.template.placeholder':
|
||||
'Search by template name, image or mount path',
|
||||
@@ -147,9 +160,8 @@ export default {
|
||||
'gpuservice.storage.temporary.tips':
|
||||
'Data is cleared when the instance stops.',
|
||||
'gpuservice.storage.persistentVolume.tips':
|
||||
'Data persists across restarts and is deleted only when the instance is terminated. Cannot be shared with other instances.',
|
||||
'gpuservice.storage.persistentVolume.required':
|
||||
'Please select a persistent volume',
|
||||
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
||||
'gpuservice.storage.persistentVolume.required': 'Please select a storage',
|
||||
'gpuservice.storage.persistentVolume.capacity': 'Capacity (GB)',
|
||||
'gpuservice.storage.persistentVolume.capacity.required':
|
||||
'Please enter capacity',
|
||||
@@ -159,5 +171,6 @@ export default {
|
||||
'gpuservice.storage.tempCapacity.required':
|
||||
'Please enter the temporary storage capacity',
|
||||
'gpuservice.form.rule.name':
|
||||
"Lowercase letters, numbers, and '-'. Start and end with a letter or number, no consecutive '-', max 63 characters."
|
||||
"Lowercase letters, numbers, and '-'. Start and end with a letter or number, no consecutive '-', max 63 characters.",
|
||||
'gpuservice.form.storage.select': 'Select Storage'
|
||||
};
|
||||
|
||||
@@ -31,15 +31,18 @@ export default {
|
||||
'menu.accessControl': 'Access Control',
|
||||
'menu.accessControl.apikeys': 'API Keys',
|
||||
'menu.accessControl.users': 'Users',
|
||||
'menu.profile': 'Profile',
|
||||
'menu.accessControl.organizations': 'Organizations',
|
||||
'menu.profile': 'Preferences',
|
||||
'menu.login': 'Login',
|
||||
'menu.usage': 'Usage',
|
||||
'menu.usage.usage': 'Usage',
|
||||
'menu.billingAndUsage': 'Usage & Billing',
|
||||
'menu.billingAndUsage.usage': 'Usage',
|
||||
'menu.404': '404',
|
||||
'menu.clusterManagement': 'Cluster Management',
|
||||
'menu.clusterManagement.clusters': 'Clusters',
|
||||
'menu.clusterManagement.credentials': 'Cloud Credentials',
|
||||
'menu.clusterManagement.clusterDetail': 'Cluster Detail',
|
||||
'menu.clusterManagement.clusterCreate': 'Create Cluster',
|
||||
'menu.resources.clusters': 'Clusters',
|
||||
'menu.resources.credentials': 'Cloud Credentials',
|
||||
'menu.resources.clusterDetail': 'Cluster Detail',
|
||||
'menu.resources.clusterCreate': 'Create Cluster',
|
||||
'menu.models.backendsList': 'Inference Backends',
|
||||
'menu.gpuService': 'GPU Service',
|
||||
'menu.gpuService.instances': 'GPU Instances',
|
||||
|
||||
@@ -291,7 +291,7 @@ export default {
|
||||
'models.instance.startHistory': 'Run History',
|
||||
'models.instance.startHistory.tips':
|
||||
'Shows logs from the run before the last error-triggered restart.',
|
||||
'models.form.lora.label': 'LoRA Adapter',
|
||||
'models.form.lora.label': 'LoRA Adapters',
|
||||
'models.form.lora.add': 'Add LoRA Adapter',
|
||||
'models.form.lora.select': 'Select LoRA',
|
||||
'models.form.lora.name': 'LoRA name',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
'organizations.upsell.title': 'Organizations are an Enterprise feature',
|
||||
'organizations.upsell.subtitle':
|
||||
'Multi-tenancy lets you isolate users, resources, and quotas across teams. Upgrade to GPUStack Enterprise to manage organizations.',
|
||||
'organizations.upsell.featuresTitle': 'What you get in Enterprise',
|
||||
'organizations.upsell.feature.orgs':
|
||||
'Create organizations to group users and isolate workloads',
|
||||
'organizations.upsell.feature.members':
|
||||
'Manage members and roles per organization',
|
||||
'organizations.upsell.feature.quotas':
|
||||
'Set resource and token quotas per organization',
|
||||
'organizations.upsell.feature.isolation':
|
||||
'Scope API keys, model deployments, and resources per organization',
|
||||
'organizations.upsell.cta': 'Learn about Enterprise'
|
||||
};
|
||||
@@ -17,9 +17,15 @@ export default {
|
||||
'usage.table.user.apiKeysUsed': 'API Keys Used',
|
||||
'usage.table.lastActive': 'Last Active',
|
||||
'usage.filter.granularity': 'Granularity',
|
||||
'usage.filter.granularity.hour': 'Hour',
|
||||
'usage.filter.granularity.day': 'Day',
|
||||
'usage.filter.granularity.week': 'Week',
|
||||
'usage.filter.granularity.month': 'Month',
|
||||
'usage.tabs.summary': 'Summary',
|
||||
'usage.tabs.tokens': 'Tokens',
|
||||
'usage.tabs.gpuInstances': 'GPU Instances',
|
||||
'usage.tabs.storage': 'Storage',
|
||||
'usage.tabs.resourceEvents': 'Resource Events',
|
||||
'usage.tabs.models': 'Models',
|
||||
'usage.tabs.apikeys': 'API Keys',
|
||||
'usage.tabs.users': 'User',
|
||||
@@ -32,5 +38,69 @@ export default {
|
||||
'usage.chart.cached': 'Cached',
|
||||
'usage.chart.uncached': 'Uncached',
|
||||
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
|
||||
'usage.table.inputTokensCached': 'Input Tokens Cached'
|
||||
'usage.table.inputTokensCached': 'Input Tokens Cached',
|
||||
|
||||
// --- Resource usage: shared metrics & units ---
|
||||
'usage.metric.tokens': 'Tokens',
|
||||
'usage.metric.input': 'Input',
|
||||
'usage.metric.output': 'Output',
|
||||
'usage.metric.gpuHours': 'GPU Hours',
|
||||
'usage.metric.instanceHours': 'Instance Hours',
|
||||
'usage.metric.gbDays': 'GB-Days',
|
||||
'usage.metric.gbHours': 'GB-Hours',
|
||||
'usage.metric.activeUsers': 'Active Users',
|
||||
'usage.metric.activeInstances': 'Active Instances',
|
||||
'usage.metric.activeStorage': 'Active Storage',
|
||||
'usage.metric.activeVolumes': 'Active Volumes',
|
||||
'usage.metric.storageTypes': 'Storage Types',
|
||||
'usage.metric.gpuHours.tip':
|
||||
'Instance running time weighted by GPU count: an instance with N GPUs running for H hours counts as N × H GPU-hours. Equal to Instance Hours when every instance uses a single GPU.',
|
||||
'usage.metric.instanceHours.tip':
|
||||
'Total running time summed across all instances, regardless of how many GPUs each uses. One instance running for 2 hours = 2 instance-hours.',
|
||||
'usage.metric.gbDays.tip':
|
||||
'Storage capacity integrated over time, in GB × days: 10 GB kept for 5 days = 50 GB-days. (= GB-Hours ÷ 24)',
|
||||
'usage.metric.gbHours.tip':
|
||||
'Storage capacity integrated over time, in GB × hours: 10 GB kept for 5 hours = 50 GB-hours.',
|
||||
|
||||
// --- Resource usage: common table / labels ---
|
||||
'usage.common.noData': 'No data',
|
||||
'usage.common.unknown': 'unknown',
|
||||
'usage.table.date': 'Date',
|
||||
'usage.table.name': 'Name',
|
||||
'usage.table.user': 'User',
|
||||
'usage.table.users': 'Users',
|
||||
'usage.table.type': 'Type',
|
||||
'usage.table.instance': 'Instance',
|
||||
'usage.table.instanceType': 'Instance Type',
|
||||
'usage.table.instanceTypes': 'Instance Types',
|
||||
'usage.table.instances': 'Instances',
|
||||
'usage.table.capacity': 'Capacity',
|
||||
'usage.export.tableNamed': 'Export Table Data — {name}',
|
||||
|
||||
// --- Summary tab ---
|
||||
'usage.summary.compute': 'Compute',
|
||||
'usage.summary.tokensOverTime': 'Tokens over time',
|
||||
'usage.summary.gpuHoursOverTime': 'GPU Hours over time',
|
||||
'usage.summary.gbDaysOverTime': 'GB-Days over time',
|
||||
|
||||
// --- GPU Instances / Storage filters ---
|
||||
'usage.filter.instance': 'Filter by instance',
|
||||
'usage.filter.storage': 'Filter by storage',
|
||||
|
||||
// --- Resource events ---
|
||||
'usage.events.resourceType': 'Resource type',
|
||||
'usage.events.eventType': 'Event type',
|
||||
'usage.events.col.time': 'Time',
|
||||
'usage.events.col.resource': 'Resource',
|
||||
'usage.events.col.event': 'Event',
|
||||
'usage.events.col.message': 'Message',
|
||||
'usage.events.resource.gpuInstance': 'GPU Instance',
|
||||
'usage.events.resource.cpuInstance': 'CPU Instance',
|
||||
'usage.events.type.created': 'Created',
|
||||
'usage.events.type.deleted': 'Deleted',
|
||||
'usage.events.type.started': 'Started',
|
||||
'usage.events.type.stopped': 'Stopped',
|
||||
'usage.events.type.updated': 'Updated',
|
||||
'usage.events.type.attached': 'Attached',
|
||||
'usage.events.type.detached': 'Detached'
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
'vendor.hygon': 'Hygon',
|
||||
'vendor.moorthreads': 'Moore Threads',
|
||||
'vendor.iluvatar': 'Iluvatar',
|
||||
'vendor.metax': 'Metax',
|
||||
'vendor.metax': 'MetaX',
|
||||
'vendor.cambricon': 'Cambricon',
|
||||
'vendor.thead': 'T-Head PPU'
|
||||
};
|
||||
|
||||
@@ -23,7 +23,9 @@ export default {
|
||||
'apikeys.accessScope.inference': 'Inference APIs',
|
||||
'apikeys.access.permissions': 'Access Permissions',
|
||||
'apikeys.type.auto': 'Auto-generated',
|
||||
'apikeys.type.custom': 'Custom'
|
||||
'apikeys.type.custom': 'Custom',
|
||||
'apikeys.button.ipConfig': 'IP Access Control',
|
||||
'quotaLimits.button.title': 'Quota Limit'
|
||||
};
|
||||
|
||||
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
||||
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
'clusters.workerpool.batchSize.desc':
|
||||
'Number of workers created simultaneously in the Worker pool',
|
||||
'clusters.create.addworker.tips':
|
||||
'Please make sure the <a href={link} target="_blank">prerequisites</a> for {label} are met before executing the following command.',
|
||||
'Please make sure the <a href={link} target="_blank">prerequisites</a> are met before executing the following command.',
|
||||
'clusters.create.addCommand.tips':
|
||||
'On the Worker that needs to be added, run the following command to join it to the cluster.',
|
||||
'clusters.create.addCommand.k8s.tips':
|
||||
@@ -110,6 +110,10 @@ export default {
|
||||
'{count} new workers have been added to the cluster.',
|
||||
'clusters.create.serverUrl': 'GPUStack Server URL',
|
||||
'clusters.create.workerConfig': 'Worker Configuration',
|
||||
'clusters.edit.k8sOptions.changed.tip':
|
||||
'Kubernetes オプションを変更しました。変更を有効にするには、対象クラスターで登録コマンドを再実行してください。',
|
||||
'clusters.edit.workerConfig.tip':
|
||||
'ワーカー設定の変更は、対象のワーカーを再起動した後に有効になります。',
|
||||
'clusters.addworker.containerName': 'Worker Container Name',
|
||||
'clusters.addworker.containerName.tips':
|
||||
'Specify a name for the worker container.',
|
||||
@@ -165,7 +169,7 @@ export default {
|
||||
'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.k8sOptions.title': 'Kubernetes Deployment Options',
|
||||
'clusters.imageCredentials.title': 'Image Credentials',
|
||||
'clusters.imageCredentials.add': 'Add Credential',
|
||||
'clusters.imageCredentials.registry': 'Registry',
|
||||
@@ -180,9 +184,14 @@ export default {
|
||||
'clusters.namespace.title': 'Namespace',
|
||||
'clusters.namespace.tip':
|
||||
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||
'clusters.gpuInstances.title': 'GPU Instances',
|
||||
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||
'clusters.gpuInstances.staticAddress': 'Static Access Address',
|
||||
'clusters.clusterType.title': 'Cluster Type',
|
||||
'clusters.modelService.title': 'Model Service',
|
||||
'clusters.modelService.tip':
|
||||
'For LLM inference and API serving — e.g. exposing model APIs and token-based services.',
|
||||
'clusters.gpuInstances.title': 'GPU Service',
|
||||
'clusters.gpuInstances.tip':
|
||||
'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.',
|
||||
'clusters.gpuInstances.staticAddress': 'GPU Service Static Access Address',
|
||||
'clusters.gpuInstances.staticAddress.tip':
|
||||
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ export default {
|
||||
'common.button.enabled': '有効',
|
||||
'common.button.disabled': '無効',
|
||||
'common.button.upgrade': 'アップグレード',
|
||||
'common.enterprise.feature': 'Available in GPUStack Enterprise',
|
||||
'common.input.holder': '入力してください',
|
||||
'common.validate.value': '{name} の値は必須です',
|
||||
'common.button.edit': '編集',
|
||||
@@ -55,6 +56,7 @@ export default {
|
||||
'common.button.viewevent': 'イベントを表示',
|
||||
'common.button.recreate': '再作成',
|
||||
'common.table.operation': '操作',
|
||||
'common.table.creator': '作成者',
|
||||
'common.table.createTime': '作成日時',
|
||||
'common.table.updateTime': '更新日時',
|
||||
'common.table.description': '説明',
|
||||
@@ -65,6 +67,7 @@ export default {
|
||||
'common.search.name.placeholder': '名前でフィルタ',
|
||||
'common.search.id.placeholder': 'IDでフィルタ',
|
||||
'common.filter.byId': 'IDでフィルタ',
|
||||
'common.filter.byCreator': '作成者でフィルタ',
|
||||
'common.table.type': 'タイプ',
|
||||
'common.table.default': 'デフォルト値',
|
||||
'common.copy.success': 'コピー成功!',
|
||||
@@ -285,7 +288,9 @@ export default {
|
||||
'common.image.limit.height': 'Image height must be {height}.',
|
||||
'common.remaining': '残り {count}',
|
||||
'common.max': '最大 {count}',
|
||||
'common.validate.group': 'Please complete the {group} configuration'
|
||||
'common.max.count': '{label} 数',
|
||||
'common.validate.group': 'Please complete the {group} configuration',
|
||||
'common.preferences': 'Preferences'
|
||||
};
|
||||
|
||||
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
||||
|
||||
@@ -13,7 +13,11 @@ export default {
|
||||
'gpuservice.template.command.placeholder':
|
||||
'引数はスペースで区切り、スペースを含む引数は引用符で囲んでください。例:/bin/bash -c "echo hello world"',
|
||||
'gpuservice.template.mountPath': 'マウントパス',
|
||||
'gpuservice.template.mountPath.tips':
|
||||
'このテンプレートからインスタンスを作成する際に、ストレージボリュームがデフォルトでマウントされるパスです。インスタンスの実行中に保持する必要があるデータの永続化に使用できます。',
|
||||
'gpuservice.template.containerDisk': 'コンテナディスク (GB)',
|
||||
'gpuservice.template.containerDisk.tips':
|
||||
'コンテナシステムディスクのサイズです。',
|
||||
'gpuservice.template.memory': 'メモリ (GB)',
|
||||
'gpuservice.instance.containerDisk.remaining':
|
||||
'コンテナディスク (最大 {count} GB)',
|
||||
@@ -96,12 +100,18 @@ export default {
|
||||
'gpuservice.instance.templates': 'インスタンステンプレート',
|
||||
'gpuservice.instance.section.storage': 'ストレージボリューム',
|
||||
'gpuservice.instance.type.required': 'インスタンスタイプを選択してください',
|
||||
'gpuservice.instance.type.noAvailable':
|
||||
'利用可能なインスタンスタイプがありません',
|
||||
'gpuservice.instance.gpuCount': 'GPU 数',
|
||||
'gpuservice.instance.gpuCount.required': 'GPU 数を入力してください',
|
||||
'gpuservice.instance.gpuCount.max':
|
||||
'最大 {count} 枚の GPU カードを選択してください',
|
||||
'gpuservice.instance.gpuCount.min':
|
||||
'少なくとも {count} 枚の GPU カードを選択してください',
|
||||
'gpuservice.instance.cpuCount.max':
|
||||
'最大 {count} 個の CPU コアを選択してください',
|
||||
'gpuservice.instance.cpuCount.min':
|
||||
'少なくとも {count} 個の CPU コアを選択してください',
|
||||
'gpuservice.instance.gpuCount.noAvailable':
|
||||
'利用可能な GPU リソースがありません。別のインスタンスタイプを選択してください。',
|
||||
'gpuservice.instance.gpuCount.zero': 'CPU のみを使用し、環境準備用です。',
|
||||
@@ -110,6 +120,10 @@ export default {
|
||||
'gpuservice.instance.memory': 'VRAM',
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.disk': 'ディスク',
|
||||
'gpuservice.table.count': '数量',
|
||||
'gpuservice.instance.disk.system': 'システムディスク',
|
||||
'gpuservice.instance.disk.ephemeral': '一時ストレージ',
|
||||
'gpuservice.instance.disk.persistent': '永続ストレージ',
|
||||
'gpuservice.instance.search.type.placeholder': '名前で検索',
|
||||
'gpuservice.instance.search.template.placeholder':
|
||||
'テンプレート名、イメージまたはマウントパスで検索',
|
||||
@@ -143,7 +157,7 @@ export default {
|
||||
'gpuservice.storage.temporary': '一時',
|
||||
'gpuservice.storage.persistentVolume': '永続',
|
||||
'gpuservice.storage.persistentVolume.required':
|
||||
'永続ボリュームを選択してください',
|
||||
'ストレージを選択してください',
|
||||
'gpuservice.storage.persistentVolume.capacity': '容量 (GB)',
|
||||
'gpuservice.storage.persistentVolume.capacity.required':
|
||||
'容量を入力してください',
|
||||
@@ -157,5 +171,6 @@ export default {
|
||||
'gpuservice.storage.temporary.tips':
|
||||
'Data is cleared when the instance stops.',
|
||||
'gpuservice.storage.persistentVolume.tips':
|
||||
'Data persists across restarts and is deleted only when the instance is terminated. Cannot be shared with other instances.'
|
||||
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
||||
'gpuservice.form.storage.select': 'ストレージを選択'
|
||||
};
|
||||
|
||||
@@ -23,9 +23,12 @@ export default {
|
||||
'menu.resources': 'リソース',
|
||||
'menu.apikeys': 'APIキー',
|
||||
'menu.users': 'ユーザー',
|
||||
'menu.profile': 'プロフィール',
|
||||
'menu.profile': 'Preferences',
|
||||
'menu.login': 'ログイン',
|
||||
'menu.usage': '使用状況',
|
||||
'menu.usage.usage': '使用状況',
|
||||
'menu.billingAndUsage': '使用状況と請求',
|
||||
'menu.billingAndUsage.usage': '使用状況',
|
||||
'menu.404': '404',
|
||||
'menu.settings': 'Settings',
|
||||
'menu.resources.workers': 'Workers',
|
||||
@@ -34,12 +37,12 @@ export default {
|
||||
'menu.accessControl': 'Access Control',
|
||||
'menu.accessControl.apikeys': 'API Keys',
|
||||
'menu.accessControl.users': 'Users',
|
||||
'menu.clusterManagement': 'Cluster Management',
|
||||
'menu.clusterManagement.clusters': 'Clusters',
|
||||
'menu.clusterManagement.credentials': 'Cloud Credentials',
|
||||
'menu.accessControl.organizations': 'Organizations',
|
||||
'menu.resources.clusters': 'Clusters',
|
||||
'menu.resources.credentials': 'Cloud Credentials',
|
||||
'menu.models.userModels': 'My Models',
|
||||
'menu.clusterManagement.clusterDetail': 'Cluster Detail',
|
||||
'menu.clusterManagement.clusterCreate': 'Create Cluster',
|
||||
'menu.resources.clusterDetail': 'Cluster Detail',
|
||||
'menu.resources.clusterCreate': 'Create Cluster',
|
||||
'menu.models.backendsList': 'Inference Backends',
|
||||
'menu.gpuService': 'GPU Service',
|
||||
'menu.gpuService.instances': 'GPU Instances',
|
||||
|
||||
@@ -291,7 +291,7 @@ export default {
|
||||
'models.instance.startHistory': 'Run History',
|
||||
'models.instance.startHistory.tips':
|
||||
'Shows logs from the run before the last error-triggered restart.',
|
||||
'models.form.lora.label': 'LoRA Adapter',
|
||||
'models.form.lora.label': 'LoRA Adapters',
|
||||
'models.form.lora.add': 'Add LoRA Adapter',
|
||||
'models.form.lora.select': 'Select LoRA',
|
||||
'models.form.lora.name': 'LoRA name',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
'organizations.upsell.title': 'Organizations are an Enterprise feature',
|
||||
'organizations.upsell.subtitle':
|
||||
'Multi-tenancy lets you isolate users, resources, and quotas across teams. Upgrade to GPUStack Enterprise to manage organizations.',
|
||||
'organizations.upsell.featuresTitle': 'What you get in Enterprise',
|
||||
'organizations.upsell.feature.orgs':
|
||||
'Create organizations to group users and isolate workloads',
|
||||
'organizations.upsell.feature.members':
|
||||
'Manage members and roles per organization',
|
||||
'organizations.upsell.feature.quotas':
|
||||
'Set resource and token quotas per organization',
|
||||
'organizations.upsell.feature.isolation':
|
||||
'Scope API keys, model deployments, and resources per organization',
|
||||
'organizations.upsell.cta': 'Learn about Enterprise'
|
||||
};
|
||||
@@ -17,9 +17,15 @@ export default {
|
||||
'usage.table.user.apiKeysUsed': 'API Keys Used',
|
||||
'usage.table.lastActive': 'Last Active',
|
||||
'usage.filter.granularity': 'Granularity',
|
||||
'usage.filter.granularity.hour': '時間',
|
||||
'usage.filter.granularity.day': 'Day',
|
||||
'usage.filter.granularity.week': 'Week',
|
||||
'usage.filter.granularity.month': 'Month',
|
||||
'usage.tabs.summary': '概要',
|
||||
'usage.tabs.tokens': 'トークン',
|
||||
'usage.tabs.gpuInstances': 'GPU インスタンス',
|
||||
'usage.tabs.storage': 'ストレージ',
|
||||
'usage.tabs.resourceEvents': 'リソースイベント',
|
||||
'usage.tabs.models': 'Models',
|
||||
'usage.tabs.apikeys': 'API Keys',
|
||||
'usage.tabs.users': 'User',
|
||||
@@ -32,5 +38,69 @@ export default {
|
||||
'usage.chart.cached': 'Cached',
|
||||
'usage.chart.uncached': 'Uncached',
|
||||
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
|
||||
'usage.table.inputTokensCached': 'Input Tokens Cached'
|
||||
'usage.table.inputTokensCached': 'Input Tokens Cached',
|
||||
|
||||
// --- Resource usage: shared metrics & units ---
|
||||
'usage.metric.tokens': 'トークン数',
|
||||
'usage.metric.input': '入力',
|
||||
'usage.metric.output': '出力',
|
||||
'usage.metric.gpuHours': 'GPU 時間',
|
||||
'usage.metric.instanceHours': 'インスタンス時間',
|
||||
'usage.metric.gbDays': 'GB·日',
|
||||
'usage.metric.gbHours': 'GB·時間',
|
||||
'usage.metric.activeUsers': 'アクティブユーザー',
|
||||
'usage.metric.activeInstances': 'アクティブインスタンス',
|
||||
'usage.metric.activeStorage': 'アクティブストレージ',
|
||||
'usage.metric.activeVolumes': 'アクティブボリューム',
|
||||
'usage.metric.storageTypes': 'ストレージタイプ',
|
||||
'usage.metric.gpuHours.tip':
|
||||
'インスタンスの稼働時間を GPU 数で重み付けした値:N 個の GPU を使用するインスタンスが H 時間稼働すると N × H GPU 時間としてカウントされます。すべてのインスタンスが単一の GPU を使用する場合はインスタンス時間と等しくなります。',
|
||||
'usage.metric.instanceHours.tip':
|
||||
'GPU の数に関係なく、すべてのインスタンスの稼働時間を合計した値。1 つのインスタンスが 2 時間稼働 = 2 インスタンス時間。',
|
||||
'usage.metric.gbDays.tip':
|
||||
'ストレージ容量を時間で積分した値(GB × 日):10 GB を 5 日間保持 = 50 GB·日。(= GB·時間 ÷ 24)',
|
||||
'usage.metric.gbHours.tip':
|
||||
'ストレージ容量を時間で積分した値(GB × 時間):10 GB を 5 時間保持 = 50 GB·時間。',
|
||||
|
||||
// --- Resource usage: common table / labels ---
|
||||
'usage.common.noData': 'データがありません',
|
||||
'usage.common.unknown': '不明',
|
||||
'usage.table.date': '日付',
|
||||
'usage.table.name': '名前',
|
||||
'usage.table.user': 'ユーザー',
|
||||
'usage.table.users': 'ユーザー',
|
||||
'usage.table.type': 'タイプ',
|
||||
'usage.table.instance': 'インスタンス',
|
||||
'usage.table.instanceType': 'インスタンスタイプ',
|
||||
'usage.table.instanceTypes': 'インスタンスタイプ',
|
||||
'usage.table.instances': 'インスタンス',
|
||||
'usage.table.capacity': '容量',
|
||||
'usage.export.tableNamed': 'テーブルデータをエクスポート — {name}',
|
||||
|
||||
// --- Summary tab ---
|
||||
'usage.summary.compute': 'コンピュート',
|
||||
'usage.summary.tokensOverTime': 'トークン数の推移',
|
||||
'usage.summary.gpuHoursOverTime': 'GPU 時間の推移',
|
||||
'usage.summary.gbDaysOverTime': 'GB·日の推移',
|
||||
|
||||
// --- GPU Instances / Storage filters ---
|
||||
'usage.filter.instance': 'インスタンスで絞り込み',
|
||||
'usage.filter.storage': 'ストレージで絞り込み',
|
||||
|
||||
// --- Resource events ---
|
||||
'usage.events.resourceType': 'リソースタイプ',
|
||||
'usage.events.eventType': 'イベントタイプ',
|
||||
'usage.events.col.time': '時刻',
|
||||
'usage.events.col.resource': 'リソース',
|
||||
'usage.events.col.event': 'イベント',
|
||||
'usage.events.col.message': 'メッセージ',
|
||||
'usage.events.resource.gpuInstance': 'GPU インスタンス',
|
||||
'usage.events.resource.cpuInstance': 'CPU インスタンス',
|
||||
'usage.events.type.created': '作成済み',
|
||||
'usage.events.type.deleted': '削除済み',
|
||||
'usage.events.type.started': '開始',
|
||||
'usage.events.type.stopped': '停止',
|
||||
'usage.events.type.updated': '更新済み',
|
||||
'usage.events.type.attached': 'アタッチ済み',
|
||||
'usage.events.type.detached': 'デタッチ済み'
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
'vendor.hygon': 'Hygon',
|
||||
'vendor.moorthreads': 'Moore Threads',
|
||||
'vendor.iluvatar': 'Iluvatar',
|
||||
'vendor.metax': 'Metax',
|
||||
'vendor.metax': 'MetaX',
|
||||
'vendor.cambricon': 'Cambricon',
|
||||
'vendor.thead': 'T-Head PPU'
|
||||
};
|
||||
|
||||
@@ -23,7 +23,9 @@ export default {
|
||||
'apikeys.accessScope.inference': 'Inference APIs',
|
||||
'apikeys.access.permissions': 'Access Permissions',
|
||||
'apikeys.type.auto': 'Auto-generated',
|
||||
'apikeys.type.custom': 'Custom'
|
||||
'apikeys.type.custom': 'Custom',
|
||||
'apikeys.button.ipConfig': 'IP Access Control',
|
||||
'quotaLimits.button.title': 'Quota Limit'
|
||||
};
|
||||
|
||||
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
||||
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
'clusters.workerpool.batchSize.desc':
|
||||
'Количество воркеров, создаваемых одновременно в пуле воркеров',
|
||||
'clusters.create.addworker.tips':
|
||||
'Пожалуйста, убедитесь, что выполнены <a href={link} target="_blank">предварительные условия</a> для {label} перед выполнением следующей команды.',
|
||||
'Пожалуйста, убедитесь, что выполнены <a href={link} target="_blank">предварительные условия</a> перед выполнением следующей команды.',
|
||||
'clusters.create.addCommand.tips':
|
||||
'На воркере, который необходимо добавить, выполните следующую команду, чтобы присоединить его к кластеру.',
|
||||
'clusters.create.addCommand.k8s.tips':
|
||||
@@ -110,6 +110,10 @@ export default {
|
||||
'{count} новых воркеров были добавлены в кластер.',
|
||||
'clusters.create.serverUrl': 'URL сервера GPUStack',
|
||||
'clusters.create.workerConfig': 'Конфигурация воркера',
|
||||
'clusters.edit.k8sOptions.changed.tip':
|
||||
'Вы изменили параметры Kubernetes. Чтобы изменения вступили в силу, повторно выполните команду регистрации в целевом кластере.',
|
||||
'clusters.edit.workerConfig.tip':
|
||||
'Изменения конфигурации воркера вступают в силу только после перезапуска соответствующих воркеров.',
|
||||
'clusters.addworker.containerName': 'Имя контейнера воркера',
|
||||
'clusters.addworker.containerName.tips':
|
||||
'Укажите имя для контейнера воркера.',
|
||||
@@ -166,7 +170,7 @@ export default {
|
||||
'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.k8sOptions.title': 'Kubernetes Deployment Options',
|
||||
'clusters.imageCredentials.title': 'Image Credentials',
|
||||
'clusters.imageCredentials.add': 'Add Credential',
|
||||
'clusters.imageCredentials.registry': 'Registry',
|
||||
@@ -181,9 +185,14 @@ export default {
|
||||
'clusters.namespace.title': 'Namespace',
|
||||
'clusters.namespace.tip':
|
||||
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||
'clusters.gpuInstances.title': 'GPU Instances',
|
||||
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||
'clusters.gpuInstances.staticAddress': 'Static Access Address',
|
||||
'clusters.clusterType.title': 'Cluster Type',
|
||||
'clusters.modelService.title': 'Model Service',
|
||||
'clusters.modelService.tip':
|
||||
'For LLM inference and API serving — e.g. exposing model APIs and token-based services.',
|
||||
'clusters.gpuInstances.title': 'GPU Service',
|
||||
'clusters.gpuInstances.tip':
|
||||
'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.',
|
||||
'clusters.gpuInstances.staticAddress': 'GPU Service Static Access Address',
|
||||
'clusters.gpuInstances.staticAddress.tip':
|
||||
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ export default {
|
||||
'common.button.enabled': 'Активно',
|
||||
'common.button.disabled': 'Отключено',
|
||||
'common.button.upgrade': 'Обновить',
|
||||
'common.enterprise.feature': 'Available in GPUStack Enterprise',
|
||||
'common.input.holder': 'Введите значение',
|
||||
'common.validate.value': 'Поле {name} обязательно',
|
||||
'common.button.edit': 'Редактировать',
|
||||
@@ -55,6 +56,7 @@ export default {
|
||||
'common.button.viewevent': 'Просмотр событий',
|
||||
'common.button.recreate': 'Пересоздать',
|
||||
'common.table.operation': 'Действия',
|
||||
'common.table.creator': 'Создатель',
|
||||
'common.table.createTime': 'Создано',
|
||||
'common.table.updateTime': 'Обновлено',
|
||||
'common.table.description': 'Описание',
|
||||
@@ -65,6 +67,7 @@ export default {
|
||||
'common.search.name.placeholder': 'Фильтр по названию',
|
||||
'common.search.id.placeholder': 'Фильтр по ID',
|
||||
'common.filter.byId': 'Фильтр по ID',
|
||||
'common.filter.byCreator': 'Фильтр по создателю',
|
||||
'common.table.type': 'Тип',
|
||||
'common.table.default': 'Значение по умолчанию',
|
||||
'common.copy.success': 'Скопировано!',
|
||||
@@ -260,7 +263,7 @@ export default {
|
||||
'common.sso.noConfig':
|
||||
'Единый вход не настроен в этой системе. Пожалуйста, обратитесь к администратору.',
|
||||
'common.button.edit.item': 'Редактировать {name}',
|
||||
'common.button.copy.item': 'Duplicate {name}',
|
||||
'common.button.copy.item': 'Дублировать {name}',
|
||||
'common.button.terminal': 'Терминал',
|
||||
'common.button.addItem': 'Добавить элемент',
|
||||
'common.help.default': 'По умолчанию: {content}',
|
||||
@@ -284,7 +287,9 @@ export default {
|
||||
'common.image.limit.height': 'Image height must be {height}.',
|
||||
'common.remaining': 'Остаток {count}',
|
||||
'common.max': 'Макс. {count}',
|
||||
'common.validate.group': 'Please complete the {group} configuration'
|
||||
'common.max.count': 'Количество {label}',
|
||||
'common.validate.group': 'Please complete the {group} configuration',
|
||||
'common.preferences': 'Preferences'
|
||||
};
|
||||
|
||||
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
||||
|
||||
@@ -14,7 +14,11 @@ export default {
|
||||
'gpuservice.template.command.placeholder':
|
||||
'Разделяйте аргументы пробелами; аргументы с пробелами заключайте в кавычки, например: /bin/bash -c "echo hello world"',
|
||||
'gpuservice.template.mountPath': 'Путь монтирования',
|
||||
'gpuservice.template.mountPath.tips':
|
||||
'Путь, по которому том хранилища монтируется по умолчанию при создании экземпляра из этого шаблона. Может использоваться для сохранения данных, которые нужно сохранить во время работы экземпляра.',
|
||||
'gpuservice.template.containerDisk': 'Диск контейнера (GB)',
|
||||
'gpuservice.template.containerDisk.tips':
|
||||
'Размер системного диска контейнера.',
|
||||
'gpuservice.template.memory': 'Память (GB)',
|
||||
'gpuservice.instance.containerDisk.remaining':
|
||||
'Диск контейнера (Макс. {count} GB)',
|
||||
@@ -100,10 +104,13 @@ export default {
|
||||
'gpuservice.instance.templates': 'Шаблоны экземпляров',
|
||||
'gpuservice.instance.section.storage': 'Том хранилища',
|
||||
'gpuservice.instance.type.required': 'Выберите тип экземпляра',
|
||||
'gpuservice.instance.type.noAvailable': 'Нет доступных типов экземпляров',
|
||||
'gpuservice.instance.gpuCount': 'Количество GPU',
|
||||
'gpuservice.instance.gpuCount.required': 'Введите количество GPU',
|
||||
'gpuservice.instance.gpuCount.max': 'Выберите максимум {count} GPU-карт',
|
||||
'gpuservice.instance.gpuCount.min': 'Выберите минимум {count} GPU-карт',
|
||||
'gpuservice.instance.cpuCount.max': 'Выберите максимум {count} ядер CPU',
|
||||
'gpuservice.instance.cpuCount.min': 'Выберите минимум {count} ядер CPU',
|
||||
'gpuservice.instance.gpuCount.noAvailable':
|
||||
'Нет доступных ресурсов GPU, выберите другой тип экземпляра.',
|
||||
'gpuservice.instance.gpuCount.zero': 'Только CPU, для подготовки окружения.',
|
||||
@@ -112,6 +119,10 @@ export default {
|
||||
'gpuservice.instance.memory': 'VRAM',
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.disk': 'Диск',
|
||||
'gpuservice.table.count': 'Количество',
|
||||
'gpuservice.instance.disk.system': 'Системный диск',
|
||||
'gpuservice.instance.disk.ephemeral': 'Временное хранилище',
|
||||
'gpuservice.instance.disk.persistent': 'Постоянное хранилище',
|
||||
'gpuservice.instance.search.type.placeholder': 'Поиск по имени',
|
||||
'gpuservice.instance.search.template.placeholder':
|
||||
'Поиск по имени шаблона, образу или пути монтирования',
|
||||
@@ -144,7 +155,7 @@ export default {
|
||||
'gpuservice.storage.persistent': 'Постоянное',
|
||||
'gpuservice.storage.temporary': 'Временное',
|
||||
'gpuservice.storage.persistentVolume': 'Постоянное',
|
||||
'gpuservice.storage.persistentVolume.required': 'Выберите постоянный том',
|
||||
'gpuservice.storage.persistentVolume.required': 'Выберите хранилище',
|
||||
'gpuservice.storage.persistentVolume.capacity': 'Ёмкость (ГБ)',
|
||||
'gpuservice.storage.persistentVolume.capacity.required': 'Введите ёмкость',
|
||||
'gpuservice.storage.persistentVolume.releaseWithInstance':
|
||||
@@ -157,5 +168,6 @@ export default {
|
||||
'gpuservice.storage.temporary.tips':
|
||||
'Data is cleared when the instance stops.',
|
||||
'gpuservice.storage.persistentVolume.tips':
|
||||
'Data persists across restarts and is deleted only when the instance is terminated. Cannot be shared with other instances.'
|
||||
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
||||
'gpuservice.form.storage.select': 'Выберите хранилище'
|
||||
};
|
||||
|
||||
@@ -23,9 +23,12 @@ export default {
|
||||
'menu.resources': 'Ресурсы',
|
||||
'menu.apikeys': 'API-ключи',
|
||||
'menu.users': 'Пользователи',
|
||||
'menu.profile': 'Профиль',
|
||||
'menu.profile': 'Preferences',
|
||||
'menu.login': 'Авторизация',
|
||||
'menu.usage': 'Использование',
|
||||
'menu.usage.usage': 'Использование',
|
||||
'menu.billingAndUsage': 'Использование и биллинг',
|
||||
'menu.billingAndUsage.usage': 'Использование',
|
||||
'menu.404': 'Ошибка 404',
|
||||
'menu.resources.workers': 'Воркеры',
|
||||
'menu.resources.gpus': 'GPUs',
|
||||
@@ -33,12 +36,12 @@ export default {
|
||||
'menu.accessControl': 'Управление доступом',
|
||||
'menu.accessControl.apikeys': 'API Ключи',
|
||||
'menu.accessControl.users': 'Пользователи',
|
||||
'menu.clusterManagement': 'Управление кластерами',
|
||||
'menu.clusterManagement.clusters': 'Кластеры',
|
||||
'menu.clusterManagement.credentials': 'Облачные аккаунты',
|
||||
'menu.accessControl.organizations': 'Организации',
|
||||
'menu.resources.clusters': 'Кластеры',
|
||||
'menu.resources.credentials': 'Облачные аккаунты',
|
||||
'menu.models.userModels': 'Мои модели',
|
||||
'menu.clusterManagement.clusterDetail': 'Детали кластера',
|
||||
'menu.clusterManagement.clusterCreate': 'Создать кластер',
|
||||
'menu.resources.clusterDetail': 'Детали кластера',
|
||||
'menu.resources.clusterCreate': 'Создать кластер',
|
||||
'menu.models.backendsList': 'Бэкенды запуска',
|
||||
'menu.settings': 'Settings',
|
||||
'menu.gpuService': 'GPU Service',
|
||||
|
||||
@@ -295,7 +295,7 @@ export default {
|
||||
'models.instance.startHistory': 'Run History',
|
||||
'models.instance.startHistory.tips':
|
||||
'Shows logs from the run before the last error-triggered restart.',
|
||||
'models.form.lora.label': 'LoRA Adapter',
|
||||
'models.form.lora.label': 'LoRA Adapters',
|
||||
'models.form.lora.add': 'Add LoRA Adapter',
|
||||
'models.form.lora.select': 'Select LoRA',
|
||||
'models.form.lora.name': 'LoRA name',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
'organizations.upsell.title': 'Organizations are an Enterprise feature',
|
||||
'organizations.upsell.subtitle':
|
||||
'Multi-tenancy lets you isolate users, resources, and quotas across teams. Upgrade to GPUStack Enterprise to manage organizations.',
|
||||
'organizations.upsell.featuresTitle': 'What you get in Enterprise',
|
||||
'organizations.upsell.feature.orgs':
|
||||
'Create organizations to group users and isolate workloads',
|
||||
'organizations.upsell.feature.members':
|
||||
'Manage members and roles per organization',
|
||||
'organizations.upsell.feature.quotas':
|
||||
'Set resource and token quotas per organization',
|
||||
'organizations.upsell.feature.isolation':
|
||||
'Scope API keys, model deployments, and resources per organization',
|
||||
'organizations.upsell.cta': 'Learn about Enterprise'
|
||||
};
|
||||
@@ -17,9 +17,15 @@ export default {
|
||||
'usage.table.user.apiKeysUsed': 'API Keys Used',
|
||||
'usage.table.lastActive': 'Last Active',
|
||||
'usage.filter.granularity': 'Granularity',
|
||||
'usage.filter.granularity.hour': 'Час',
|
||||
'usage.filter.granularity.day': 'Day',
|
||||
'usage.filter.granularity.week': 'Week',
|
||||
'usage.filter.granularity.month': 'Month',
|
||||
'usage.tabs.summary': 'Обзор',
|
||||
'usage.tabs.tokens': 'Токены',
|
||||
'usage.tabs.gpuInstances': 'GPU-инстансы',
|
||||
'usage.tabs.storage': 'Хранилище',
|
||||
'usage.tabs.resourceEvents': 'События ресурсов',
|
||||
'usage.tabs.models': 'Models',
|
||||
'usage.tabs.apikeys': 'API Keys',
|
||||
'usage.tabs.users': 'User',
|
||||
@@ -32,5 +38,69 @@ export default {
|
||||
'usage.chart.cached': 'Cached',
|
||||
'usage.chart.uncached': 'Uncached',
|
||||
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
|
||||
'usage.table.inputTokensCached': 'Input Tokens Cached'
|
||||
'usage.table.inputTokensCached': 'Input Tokens Cached',
|
||||
|
||||
// --- Resource usage: shared metrics & units ---
|
||||
'usage.metric.tokens': 'Токены',
|
||||
'usage.metric.input': 'Вход',
|
||||
'usage.metric.output': 'Выход',
|
||||
'usage.metric.gpuHours': 'GPU-часы',
|
||||
'usage.metric.instanceHours': 'Часы инстансов',
|
||||
'usage.metric.gbDays': 'ГБ-дни',
|
||||
'usage.metric.gbHours': 'ГБ-часы',
|
||||
'usage.metric.activeUsers': 'Активные пользователи',
|
||||
'usage.metric.activeInstances': 'Активные инстансы',
|
||||
'usage.metric.activeStorage': 'Активное хранилище',
|
||||
'usage.metric.activeVolumes': 'Активные тома',
|
||||
'usage.metric.storageTypes': 'Типы хранилищ',
|
||||
'usage.metric.gpuHours.tip':
|
||||
'Время работы инстанса, взвешенное по количеству GPU: инстанс с N GPU, работающий H часов, считается как N × H GPU-часов. Равно часам инстансов, когда каждый инстанс использует один GPU.',
|
||||
'usage.metric.instanceHours.tip':
|
||||
'Суммарное время работы всех инстансов независимо от количества используемых GPU. Один инстанс, работающий 2 часа = 2 часа инстанса.',
|
||||
'usage.metric.gbDays.tip':
|
||||
'Ёмкость хранилища, проинтегрированная по времени, в ГБ × дни: 10 ГБ в течение 5 дней = 50 ГБ-дней. (= ГБ-часы ÷ 24)',
|
||||
'usage.metric.gbHours.tip':
|
||||
'Ёмкость хранилища, проинтегрированная по времени, в ГБ × часы: 10 ГБ в течение 5 часов = 50 ГБ-часов.',
|
||||
|
||||
// --- Resource usage: common table / labels ---
|
||||
'usage.common.noData': 'Нет данных',
|
||||
'usage.common.unknown': 'неизвестно',
|
||||
'usage.table.date': 'Дата',
|
||||
'usage.table.name': 'Имя',
|
||||
'usage.table.user': 'Пользователь',
|
||||
'usage.table.users': 'Пользователи',
|
||||
'usage.table.type': 'Тип',
|
||||
'usage.table.instance': 'Инстанс',
|
||||
'usage.table.instanceType': 'Тип инстанса',
|
||||
'usage.table.instanceTypes': 'Типы инстансов',
|
||||
'usage.table.instances': 'Инстансы',
|
||||
'usage.table.capacity': 'Ёмкость',
|
||||
'usage.export.tableNamed': 'Экспорт данных таблицы — {name}',
|
||||
|
||||
// --- Summary tab ---
|
||||
'usage.summary.compute': 'Вычисления',
|
||||
'usage.summary.tokensOverTime': 'Токены по времени',
|
||||
'usage.summary.gpuHoursOverTime': 'GPU-часы по времени',
|
||||
'usage.summary.gbDaysOverTime': 'ГБ-дни по времени',
|
||||
|
||||
// --- GPU Instances / Storage filters ---
|
||||
'usage.filter.instance': 'Фильтр по инстансу',
|
||||
'usage.filter.storage': 'Фильтр по хранилищу',
|
||||
|
||||
// --- Resource events ---
|
||||
'usage.events.resourceType': 'Тип ресурса',
|
||||
'usage.events.eventType': 'Тип события',
|
||||
'usage.events.col.time': 'Время',
|
||||
'usage.events.col.resource': 'Ресурс',
|
||||
'usage.events.col.event': 'Событие',
|
||||
'usage.events.col.message': 'Сообщение',
|
||||
'usage.events.resource.gpuInstance': 'GPU-инстанс',
|
||||
'usage.events.resource.cpuInstance': 'CPU-инстанс',
|
||||
'usage.events.type.created': 'Создан',
|
||||
'usage.events.type.deleted': 'Удалён',
|
||||
'usage.events.type.started': 'Запущен',
|
||||
'usage.events.type.stopped': 'Остановлен',
|
||||
'usage.events.type.updated': 'Обновлён',
|
||||
'usage.events.type.attached': 'Подключён',
|
||||
'usage.events.type.detached': 'Отключён'
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
'vendor.hygon': 'Hygon',
|
||||
'vendor.moorthreads': 'Moore Threads',
|
||||
'vendor.iluvatar': 'Iluvatar',
|
||||
'vendor.metax': 'Metax',
|
||||
'vendor.metax': 'MetaX',
|
||||
'vendor.cambricon': 'Cambricon',
|
||||
'vendor.thead': 'T-Head PPU'
|
||||
};
|
||||
|
||||
@@ -23,5 +23,7 @@ export default {
|
||||
'apikeys.accessScope.inference': 'Inference APIs',
|
||||
'apikeys.access.permissions': 'Access Permissions',
|
||||
'apikeys.type.auto': 'Auto-generated',
|
||||
'apikeys.type.custom': 'Custom'
|
||||
'apikeys.type.custom': 'Custom',
|
||||
'apikeys.button.ipConfig': 'IP Access Control',
|
||||
'quotaLimits.button.title': 'Quota Limit'
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
'clusters.workerpool.batchSize.desc':
|
||||
'İşçi havuzunda eşzamanlı olarak oluşturulan işçi düğüm sayısı',
|
||||
'clusters.create.addworker.tips':
|
||||
'Aşağıdaki komutu çalıştırmadan önce lütfen {label} için <a href={link} target="_blank">ön koşulların</a> karşılandığından emin olun.',
|
||||
'Aşağıdaki komutu çalıştırmadan önce lütfen <a href={link} target="_blank">ön koşulların</a> karşılandığından emin olun.',
|
||||
'clusters.create.addCommand.tips':
|
||||
'Eklenmesi gereken İşçi Düğümde, kümeye katılması için aşağıdaki komutu çalıştırın.',
|
||||
'clusters.create.addCommand.k8s.tips':
|
||||
@@ -110,6 +110,10 @@ export default {
|
||||
'{count} yeni işçi düğüm kümeye eklendi.',
|
||||
'clusters.create.serverUrl': "GPUStack Sunucu URL'si",
|
||||
'clusters.create.workerConfig': 'İşçi Düğüm Yapılandırması',
|
||||
'clusters.edit.k8sOptions.changed.tip':
|
||||
'Kubernetes seçeneklerini değiştirdiniz. Değişikliklerin etkili olması için kayıt komutunu hedef kümede yeniden çalıştırın.',
|
||||
'clusters.edit.workerConfig.tip':
|
||||
'İşçi düğüm yapılandırmasındaki değişiklikler yalnızca ilgili işçi düğümleri yeniden başlatıldıktan sonra etkili olur.',
|
||||
'clusters.addworker.containerName': 'İşçi Düğüm Konteyner Adı',
|
||||
'clusters.addworker.containerName.tips':
|
||||
'İşçi düğüm konteyneri için bir ad belirtin.',
|
||||
@@ -166,7 +170,7 @@ export default {
|
||||
'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.k8sOptions.title': 'Kubernetes Deployment Options',
|
||||
'clusters.imageCredentials.title': 'Image Credentials',
|
||||
'clusters.imageCredentials.add': 'Add Credential',
|
||||
'clusters.imageCredentials.registry': 'Registry',
|
||||
@@ -181,9 +185,14 @@ export default {
|
||||
'clusters.namespace.title': 'Namespace',
|
||||
'clusters.namespace.tip':
|
||||
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||
'clusters.gpuInstances.title': 'GPU Instances',
|
||||
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||
'clusters.gpuInstances.staticAddress': 'Static Access Address',
|
||||
'clusters.clusterType.title': 'Cluster Type',
|
||||
'clusters.modelService.title': 'Model Service',
|
||||
'clusters.modelService.tip':
|
||||
'For LLM inference and API serving — e.g. exposing model APIs and token-based services.',
|
||||
'clusters.gpuInstances.title': 'GPU Service',
|
||||
'clusters.gpuInstances.tip':
|
||||
'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.',
|
||||
'clusters.gpuInstances.staticAddress': 'GPU Service Static Access Address',
|
||||
'clusters.gpuInstances.staticAddress.tip':
|
||||
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ export default {
|
||||
'common.button.enabled': 'Etkin',
|
||||
'common.button.disabled': 'Devre dışı',
|
||||
'common.button.upgrade': 'Yükselt',
|
||||
'common.enterprise.feature': 'Available in GPUStack Enterprise',
|
||||
'common.input.holder': 'Lütfen girin',
|
||||
'common.validate.value': '{name} değeri gereklidir',
|
||||
'common.button.edit': 'Düzenle',
|
||||
@@ -55,6 +56,7 @@ export default {
|
||||
'common.button.viewevent': 'Olayları Görüntüle',
|
||||
'common.button.recreate': 'Yeniden Oluştur',
|
||||
'common.table.operation': 'İşlemler',
|
||||
'common.table.creator': 'Oluşturan',
|
||||
'common.table.createTime': 'Oluşturulma',
|
||||
'common.table.updateTime': 'Güncellenme',
|
||||
'common.table.description': 'Açıklama',
|
||||
@@ -65,6 +67,7 @@ export default {
|
||||
'common.search.name.placeholder': 'ada göre filtrele',
|
||||
'common.search.id.placeholder': 'kimliğe göre filtrele',
|
||||
'common.filter.byId': 'kimliğe göre filtrele',
|
||||
'common.filter.byCreator': 'Oluşturana göre filtrele',
|
||||
'common.table.type': 'Tür',
|
||||
'common.table.default': 'Varsayılan Değer',
|
||||
'common.copy.success': 'Kopyalama başarılı!',
|
||||
@@ -288,5 +291,7 @@ export default {
|
||||
'common.image.limit.height': 'Image height must be {height}.',
|
||||
'common.remaining': 'Kalan {count}',
|
||||
'common.max': 'Maks. {count}',
|
||||
'common.validate.group': 'Please complete the {group} configuration'
|
||||
'common.max.count': '{label} Sayısı',
|
||||
'common.validate.group': 'Please complete the {group} configuration',
|
||||
'common.preferences': 'Tercihler'
|
||||
};
|
||||
|
||||
@@ -13,7 +13,10 @@ export default {
|
||||
'gpuservice.template.command.placeholder':
|
||||
'Argümanları boşlukla ayırın; boşluk içeren argümanları tırnak içine alın, örn.: /bin/bash -c "echo hello world"',
|
||||
'gpuservice.template.mountPath': 'Bağlama Yolu',
|
||||
'gpuservice.template.mountPath.tips':
|
||||
'Bu şablondan bir örnek oluşturulurken depolama biriminin varsayılan olarak bağlanacağı yol. Örnek çalışırken saklanması gereken verileri kalıcı hale getirmek için kullanılabilir.',
|
||||
'gpuservice.template.containerDisk': 'Konteyner Diski (GB)',
|
||||
'gpuservice.template.containerDisk.tips': 'Konteyner sistem diskinin boyutu.',
|
||||
'gpuservice.template.memory': 'Bellek (GB)',
|
||||
'gpuservice.instance.containerDisk.remaining':
|
||||
'Konteyner Diski (Maks. {count} GB)',
|
||||
@@ -97,10 +100,13 @@ export default {
|
||||
'gpuservice.instance.templates': 'Örnek Şablonları',
|
||||
'gpuservice.instance.section.storage': 'Depolama Hacmi',
|
||||
'gpuservice.instance.type.required': 'Lütfen bir örnek türü seçin',
|
||||
'gpuservice.instance.type.noAvailable': 'Kullanılabilir örnek türü yok',
|
||||
'gpuservice.instance.gpuCount': 'GPU Sayısı',
|
||||
'gpuservice.instance.gpuCount.required': 'Lütfen GPU sayısını girin',
|
||||
'gpuservice.instance.gpuCount.max': 'En fazla {count} GPU kartı seçin',
|
||||
'gpuservice.instance.gpuCount.min': 'En az {count} GPU kartı seçin',
|
||||
'gpuservice.instance.cpuCount.max': 'En fazla {count} CPU çekirdeği seçin',
|
||||
'gpuservice.instance.cpuCount.min': 'En az {count} CPU çekirdeği seçin',
|
||||
'gpuservice.instance.gpuCount.noAvailable':
|
||||
'Kullanılabilir GPU kaynağı yok, lütfen başka bir örnek türü seçin.',
|
||||
'gpuservice.instance.gpuCount.zero': 'Yalnızca CPU, ortam hazırlığı için.',
|
||||
@@ -109,6 +115,10 @@ export default {
|
||||
'gpuservice.instance.memory': 'VRAM',
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.disk': 'Disk',
|
||||
'gpuservice.table.count': 'Sayı',
|
||||
'gpuservice.instance.disk.system': 'Sistem Diski',
|
||||
'gpuservice.instance.disk.ephemeral': 'Geçici Depolama',
|
||||
'gpuservice.instance.disk.persistent': 'Kalıcı Depolama',
|
||||
'gpuservice.instance.search.type.placeholder': 'Ada göre ara',
|
||||
'gpuservice.instance.search.template.placeholder':
|
||||
'Şablon adına, imaja veya bağlama yoluna göre ara',
|
||||
@@ -142,8 +152,7 @@ export default {
|
||||
'gpuservice.storage.persistent': 'Kalıcı',
|
||||
'gpuservice.storage.temporary': 'Geçici',
|
||||
'gpuservice.storage.persistentVolume': 'Kalıcı',
|
||||
'gpuservice.storage.persistentVolume.required':
|
||||
'Lütfen bir kalıcı hacim seçin',
|
||||
'gpuservice.storage.persistentVolume.required': 'Lütfen bir depolama seçin',
|
||||
'gpuservice.storage.persistentVolume.capacity': 'Kapasite (GB)',
|
||||
'gpuservice.storage.persistentVolume.capacity.required':
|
||||
'Lütfen kapasiteyi girin',
|
||||
@@ -157,5 +166,6 @@ export default {
|
||||
'gpuservice.storage.temporary.tips':
|
||||
'Data is cleared when the instance stops.',
|
||||
'gpuservice.storage.persistentVolume.tips':
|
||||
'Data persists across restarts and is deleted only when the instance is terminated. Cannot be shared with other instances.'
|
||||
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
||||
'gpuservice.form.storage.select': 'Depolamayı Seç'
|
||||
};
|
||||
|
||||
@@ -29,15 +29,18 @@ export default {
|
||||
'menu.accessControl': 'Erişim Kontrolü',
|
||||
'menu.accessControl.apikeys': 'API Anahtarları',
|
||||
'menu.accessControl.users': 'Kullanıcılar',
|
||||
'menu.profile': 'Profil',
|
||||
'menu.accessControl.organizations': 'Organizasyonlar',
|
||||
'menu.profile': 'Preferences',
|
||||
'menu.login': 'Giriş',
|
||||
'menu.usage': 'Kullanım',
|
||||
'menu.usage.usage': 'Kullanım',
|
||||
'menu.billingAndUsage': 'Kullanım ve Faturalandırma',
|
||||
'menu.billingAndUsage.usage': 'Kullanım',
|
||||
'menu.404': '404',
|
||||
'menu.clusterManagement': 'Küme Yönetimi',
|
||||
'menu.clusterManagement.clusters': 'Kümeler',
|
||||
'menu.clusterManagement.credentials': 'Bulut Kimlik Bilgileri',
|
||||
'menu.clusterManagement.clusterDetail': 'Küme Detayı',
|
||||
'menu.clusterManagement.clusterCreate': 'Küme Oluştur',
|
||||
'menu.resources.clusters': 'Kümeler',
|
||||
'menu.resources.credentials': 'Bulut Kimlik Bilgileri',
|
||||
'menu.resources.clusterDetail': 'Küme Detayı',
|
||||
'menu.resources.clusterCreate': 'Küme Oluştur',
|
||||
'menu.models.backendsList': 'Çıkarım Altyapıları',
|
||||
'menu.models.instances': 'Instances',
|
||||
'menu.settings': 'Settings',
|
||||
|
||||
@@ -291,7 +291,7 @@ export default {
|
||||
'models.instance.startHistory': 'Run History',
|
||||
'models.instance.startHistory.tips':
|
||||
'Shows logs from the run before the last error-triggered restart.',
|
||||
'models.form.lora.label': 'LoRA Adapter',
|
||||
'models.form.lora.label': 'LoRA Adapters',
|
||||
'models.form.lora.add': 'Add LoRA Adapter',
|
||||
'models.form.lora.select': 'Select LoRA',
|
||||
'models.form.lora.name': 'LoRA name',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
'organizations.upsell.title': 'Organizations are an Enterprise feature',
|
||||
'organizations.upsell.subtitle':
|
||||
'Multi-tenancy lets you isolate users, resources, and quotas across teams. Upgrade to GPUStack Enterprise to manage organizations.',
|
||||
'organizations.upsell.featuresTitle': 'What you get in Enterprise',
|
||||
'organizations.upsell.feature.orgs':
|
||||
'Create organizations to group users and isolate workloads',
|
||||
'organizations.upsell.feature.members':
|
||||
'Manage members and roles per organization',
|
||||
'organizations.upsell.feature.quotas':
|
||||
'Set resource and token quotas per organization',
|
||||
'organizations.upsell.feature.isolation':
|
||||
'Scope API keys, model deployments, and resources per organization',
|
||||
'organizations.upsell.cta': 'Learn about Enterprise'
|
||||
};
|
||||
@@ -17,9 +17,15 @@ export default {
|
||||
'usage.table.user.apiKeysUsed': 'API Keys Used',
|
||||
'usage.table.lastActive': 'Last Active',
|
||||
'usage.filter.granularity': 'Granularity',
|
||||
'usage.filter.granularity.hour': 'Saat',
|
||||
'usage.filter.granularity.day': 'Day',
|
||||
'usage.filter.granularity.week': 'Week',
|
||||
'usage.filter.granularity.month': 'Month',
|
||||
'usage.tabs.summary': 'Özet',
|
||||
'usage.tabs.tokens': 'Token',
|
||||
'usage.tabs.gpuInstances': 'GPU Örnekleri',
|
||||
'usage.tabs.storage': 'Depolama',
|
||||
'usage.tabs.resourceEvents': 'Kaynak Olayları',
|
||||
'usage.tabs.models': 'Models',
|
||||
'usage.tabs.apikeys': 'API Keys',
|
||||
'usage.tabs.users': 'User',
|
||||
@@ -32,5 +38,69 @@ export default {
|
||||
'usage.chart.cached': 'Cached',
|
||||
'usage.chart.uncached': 'Uncached',
|
||||
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
|
||||
'usage.table.inputTokensCached': 'Input Tokens Cached'
|
||||
'usage.table.inputTokensCached': 'Input Tokens Cached',
|
||||
|
||||
// --- Resource usage: shared metrics & units ---
|
||||
'usage.metric.tokens': 'Token',
|
||||
'usage.metric.input': 'Girdi',
|
||||
'usage.metric.output': 'Çıktı',
|
||||
'usage.metric.gpuHours': 'GPU Saati',
|
||||
'usage.metric.instanceHours': 'Örnek Saati',
|
||||
'usage.metric.gbDays': 'GB-Gün',
|
||||
'usage.metric.gbHours': 'GB-Saat',
|
||||
'usage.metric.activeUsers': 'Aktif Kullanıcılar',
|
||||
'usage.metric.activeInstances': 'Aktif Örnekler',
|
||||
'usage.metric.activeStorage': 'Aktif Depolama',
|
||||
'usage.metric.activeVolumes': 'Aktif Birimler',
|
||||
'usage.metric.storageTypes': 'Depolama Türleri',
|
||||
'usage.metric.gpuHours.tip':
|
||||
'Örnek çalışma süresinin GPU sayısına göre ağırlıklandırılmış hali: N GPU kullanan bir örnek H saat çalıştığında N × H GPU-saat olarak sayılır. Her örnek tek bir GPU kullandığında Örnek Saati ile eşittir.',
|
||||
'usage.metric.instanceHours.tip':
|
||||
'Her örneğin kullandığı GPU sayısından bağımsız olarak tüm örneklerin toplam çalışma süresi. 2 saat çalışan bir örnek = 2 örnek-saat.',
|
||||
'usage.metric.gbDays.tip':
|
||||
'Depolama kapasitesinin zamana göre integrali, GB × gün cinsinden: 5 gün boyunca tutulan 10 GB = 50 GB-gün. (= GB-Saat ÷ 24)',
|
||||
'usage.metric.gbHours.tip':
|
||||
'Depolama kapasitesinin zamana göre integrali, GB × saat cinsinden: 5 saat boyunca tutulan 10 GB = 50 GB-saat.',
|
||||
|
||||
// --- Resource usage: common table / labels ---
|
||||
'usage.common.noData': 'Veri yok',
|
||||
'usage.common.unknown': 'bilinmiyor',
|
||||
'usage.table.date': 'Tarih',
|
||||
'usage.table.name': 'Ad',
|
||||
'usage.table.user': 'Kullanıcı',
|
||||
'usage.table.users': 'Kullanıcılar',
|
||||
'usage.table.type': 'Tür',
|
||||
'usage.table.instance': 'Örnek',
|
||||
'usage.table.instanceType': 'Örnek Türü',
|
||||
'usage.table.instanceTypes': 'Örnek Türleri',
|
||||
'usage.table.instances': 'Örnekler',
|
||||
'usage.table.capacity': 'Kapasite',
|
||||
'usage.export.tableNamed': 'Tablo Verilerini Dışa Aktar — {name}',
|
||||
|
||||
// --- Summary tab ---
|
||||
'usage.summary.compute': 'Hesaplama',
|
||||
'usage.summary.tokensOverTime': 'Zaman içinde Token',
|
||||
'usage.summary.gpuHoursOverTime': 'Zaman içinde GPU Saati',
|
||||
'usage.summary.gbDaysOverTime': 'Zaman içinde GB-Gün',
|
||||
|
||||
// --- GPU Instances / Storage filters ---
|
||||
'usage.filter.instance': 'Örneğe göre filtrele',
|
||||
'usage.filter.storage': 'Depolamaya göre filtrele',
|
||||
|
||||
// --- Resource events ---
|
||||
'usage.events.resourceType': 'Kaynak türü',
|
||||
'usage.events.eventType': 'Olay türü',
|
||||
'usage.events.col.time': 'Zaman',
|
||||
'usage.events.col.resource': 'Kaynak',
|
||||
'usage.events.col.event': 'Olay',
|
||||
'usage.events.col.message': 'Mesaj',
|
||||
'usage.events.resource.gpuInstance': 'GPU Örneği',
|
||||
'usage.events.resource.cpuInstance': 'CPU Örneği',
|
||||
'usage.events.type.created': 'Oluşturuldu',
|
||||
'usage.events.type.deleted': 'Silindi',
|
||||
'usage.events.type.started': 'Başlatıldı',
|
||||
'usage.events.type.stopped': 'Durduruldu',
|
||||
'usage.events.type.updated': 'Güncellendi',
|
||||
'usage.events.type.attached': 'Eklendi',
|
||||
'usage.events.type.detached': 'Ayrıldı'
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
'vendor.hygon': 'Hygon',
|
||||
'vendor.moorthreads': 'Moore Threads',
|
||||
'vendor.iluvatar': 'Iluvatar',
|
||||
'vendor.metax': 'Metax',
|
||||
'vendor.metax': 'MetaX',
|
||||
'vendor.cambricon': 'Cambricon',
|
||||
'vendor.thead': 'T-Head PPU'
|
||||
};
|
||||
|
||||
@@ -22,5 +22,7 @@ export default {
|
||||
'apikeys.accessScope.inference': '推理接口',
|
||||
'apikeys.access.permissions': '访问权限',
|
||||
'apikeys.type.auto': '自动生成',
|
||||
'apikeys.type.custom': '自定义'
|
||||
'apikeys.type.custom': '自定义',
|
||||
'apikeys.button.ipConfig': 'IP 访问控制',
|
||||
'quotaLimits.button.title': '配额限制'
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ export default {
|
||||
'clusters.create.noRegions': '无可用的区域',
|
||||
'clusters.workerpool.batchSize.desc': '节点池中同时创建的节点数量。',
|
||||
'clusters.create.addworker.tips':
|
||||
'在执行以下命令之前,请确保已满足 {label} 的<a href={link} target="_blank">先决条件</a>。',
|
||||
'在执行以下命令之前,请确保已满足<a href={link} target="_blank">先决条件</a>。',
|
||||
'clusters.create.addCommand.tips':
|
||||
'在需要添加的节点上运行以下命令,将其加入到集群中。',
|
||||
'clusters.create.addCommand.k8s.tips':
|
||||
@@ -108,6 +108,10 @@ export default {
|
||||
'已将 {count} 个新节点添加到集群中。',
|
||||
'clusters.create.serverUrl': 'GPUStack Server 节点地址',
|
||||
'clusters.create.workerConfig': '节点配置',
|
||||
'clusters.edit.k8sOptions.changed.tip':
|
||||
'您已修改 Kubernetes 选项,需要在目标集群上重新运行注册命令才会生效。',
|
||||
'clusters.edit.workerConfig.tip':
|
||||
'修改节点配置后,需要重启对应节点才会生效。',
|
||||
'clusters.addworker.containerName': '节点容器名称',
|
||||
'clusters.addworker.containerName.tips': '为节点容器指定一个名称。',
|
||||
'clusters.addworker.dataVolume': 'GPUStack 数据卷',
|
||||
@@ -158,7 +162,7 @@ export default {
|
||||
'clusters.systemDefaultContainerRegistry.title': '默认容器镜像仓库',
|
||||
'clusters.systemDefaultContainerRegistry.tip':
|
||||
'用于解析该集群 GPUStack 镜像的默认镜像仓库。未设置时回退到服务端默认值。',
|
||||
'clusters.k8sOptions.title': 'K8s 部署选项',
|
||||
'clusters.k8sOptions.title': 'Kubernetes 部署选项',
|
||||
'clusters.imageCredentials.title': '镜像仓库凭证',
|
||||
'clusters.imageCredentials.add': '添加凭证',
|
||||
'clusters.imageCredentials.registry': '镜像仓库地址',
|
||||
@@ -173,9 +177,14 @@ export default {
|
||||
'clusters.namespace.title': '命名空间',
|
||||
'clusters.namespace.tip':
|
||||
'集群清单渲染所使用的 Kubernetes 命名空间。留空则使用 gpustack-system。',
|
||||
'clusters.gpuInstances.title': 'GPU 实例',
|
||||
'clusters.gpuInstances.tip': '为该集群启用 GPU 实例支持。',
|
||||
'clusters.gpuInstances.staticAddress': '静态访问地址',
|
||||
'clusters.clusterType.title': '集群类型',
|
||||
'clusters.modelService.title': '模型服务',
|
||||
'clusters.modelService.tip':
|
||||
'适用于大模型推理与 API 服务化场景,例如对外提供模型 API 与 Token 服务能力。',
|
||||
'clusters.gpuInstances.title': 'GPU 服务',
|
||||
'clusters.gpuInstances.tip':
|
||||
'适用于按需分配 GPU 计算资源的场景,例如交互式开发、训练任务或自定义运行环境。',
|
||||
'clusters.gpuInstances.staticAddress': 'GPU 服务静态访问地址',
|
||||
'clusters.gpuInstances.staticAddress.tip':
|
||||
'Operator 访问该集群 GPU 实例所使用的静态地址(例如 LoadBalancer VIP)。可选。'
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ export default {
|
||||
'common.button.rollback': '回滚',
|
||||
'common.button.new': '新建{ text }',
|
||||
'common.button.upgrade': '升级',
|
||||
'common.enterprise.feature': 'GPUStack 企业版可用',
|
||||
'common.input.holder': '请输入',
|
||||
'common.holder.search': '搜索',
|
||||
'common.button.edit': '编辑',
|
||||
@@ -51,8 +52,8 @@ export default {
|
||||
'common.button.confirm': '确定',
|
||||
'common.button.viewlog': '查看日志',
|
||||
'common.button.viewevent': '查看事件',
|
||||
'common.button.recreate': '重新创建',
|
||||
'common.table.operation': '操作',
|
||||
'common.table.creator': '创建者',
|
||||
'common.table.createTime': '创建时间',
|
||||
'common.table.updateTime': '更新时间',
|
||||
'common.table.description': '描述',
|
||||
@@ -233,6 +234,7 @@ export default {
|
||||
'common.text.tips': '提示',
|
||||
'settings.system': '系统设置',
|
||||
'common.filter.byId': '按 ID 查询',
|
||||
'common.filter.byCreator': '按创建者筛选',
|
||||
'common.appearance': '外观',
|
||||
'common.appearance.dark': '深色',
|
||||
'common.appearance.light': '浅色',
|
||||
@@ -278,5 +280,7 @@ export default {
|
||||
'common.image.limit.height': '图片高度须为{height}。',
|
||||
'common.remaining': '剩余 {count}',
|
||||
'common.max': '最大 {count}',
|
||||
'common.validate.group': '请填写完整的{group}配置'
|
||||
'common.max.count': '{label} 数量',
|
||||
'common.validate.group': '请填写完整的{group}配置',
|
||||
'common.preferences': '偏好设置'
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ export default {
|
||||
'dashboard.workers': '节点',
|
||||
'dashboard.deployments': '部署',
|
||||
'dashboard.clusters': '集群',
|
||||
'dashboard.totalgpus': 'GPUs',
|
||||
'dashboard.totalgpus': 'GPU',
|
||||
'dashboard.systemload': '系统负载',
|
||||
'dashboard.memory': '内存',
|
||||
'dashboard.vram': '显存',
|
||||
|
||||
@@ -13,10 +13,13 @@ export default {
|
||||
'gpuservice.template.command.placeholder':
|
||||
'使用空格分隔参数;含空格的参数请用引号包裹,例如:/bin/bash -c "echo hello world"',
|
||||
'gpuservice.template.mountPath': '挂载路径',
|
||||
'gpuservice.template.containerDisk': '容器磁盘(GB)',
|
||||
'gpuservice.template.memory': '内存(GB)',
|
||||
'gpuservice.instance.containerDisk.remaining': '容器磁盘(最大{count}GB)',
|
||||
'gpuservice.instance.memory.remaining': '内存(最大{count}GB)',
|
||||
'gpuservice.template.mountPath.tips':
|
||||
'在使用该模板创建实例时,存储卷默认挂载的路径,可用于持久化实例运行过程中需要保留的数据。',
|
||||
'gpuservice.template.containerDisk': '容器磁盘(GB)',
|
||||
'gpuservice.template.containerDisk.tips': '容器系统盘大小。',
|
||||
'gpuservice.template.memory': '内存(GB)',
|
||||
'gpuservice.instance.containerDisk.remaining': '容器磁盘(最大 {count} GB)',
|
||||
'gpuservice.instance.memory.remaining': '内存(最大 {count} GB)',
|
||||
'gpuservice.template.displayName': '显示名称',
|
||||
'gpuservice.template.displayName.max': '显示名称不能超过 63 个字符',
|
||||
'gpuservice.template.ports': '端口',
|
||||
@@ -92,10 +95,13 @@ export default {
|
||||
'gpuservice.instance.templates': '实例模板',
|
||||
'gpuservice.instance.section.storage': '存储卷',
|
||||
'gpuservice.instance.type.required': '请选择实例类型',
|
||||
'gpuservice.instance.type.noAvailable': '无可用的实例类型',
|
||||
'gpuservice.instance.gpuCount': 'GPU 数量',
|
||||
'gpuservice.instance.gpuCount.required': '请输入 GPU 数量',
|
||||
'gpuservice.instance.gpuCount.max': '最多选择 {count} 张卡',
|
||||
'gpuservice.instance.gpuCount.min': '至少选择 {count} 张卡',
|
||||
'gpuservice.instance.cpuCount.max': '最多选择 {count} 核',
|
||||
'gpuservice.instance.cpuCount.min': '至少选择 {count} 核',
|
||||
'gpuservice.instance.gpuCount.noAvailable':
|
||||
'没有可用的 GPU 资源,请选择其他实例类型。',
|
||||
'gpuservice.instance.gpuCount.zero': '仅使用 CPU,用于环境准备。',
|
||||
@@ -104,6 +110,10 @@ export default {
|
||||
'gpuservice.instance.memory': '显存',
|
||||
'gpuservice.instance.ram': '内存',
|
||||
'gpuservice.instance.disk': '磁盘',
|
||||
'gpuservice.table.count': '数量',
|
||||
'gpuservice.instance.disk.system': '系统盘',
|
||||
'gpuservice.instance.disk.ephemeral': '临时存储',
|
||||
'gpuservice.instance.disk.persistent': '持久存储',
|
||||
'gpuservice.instance.search.type.placeholder': '搜索名称',
|
||||
'gpuservice.instance.search.template.placeholder':
|
||||
'搜索模板名称、镜像或挂载路径',
|
||||
@@ -137,13 +147,14 @@ export default {
|
||||
'gpuservice.storage.persistentVolume': '持久',
|
||||
'gpuservice.storage.temporary.tips': '实例停止后,数据将被清除。',
|
||||
'gpuservice.storage.persistentVolume.tips':
|
||||
'数据在实例重启后仍会保留,仅在实例被终止时删除。无法被其它实例共享。',
|
||||
'gpuservice.storage.persistentVolume.required': '请选择持久卷',
|
||||
'数据在实例重启后仍会保留。持久卷不会随实例终止而删除,并可被多个实例共享。',
|
||||
'gpuservice.storage.persistentVolume.required': '请选择存储',
|
||||
'gpuservice.storage.persistentVolume.capacity': '容量(GB)',
|
||||
'gpuservice.storage.persistentVolume.capacity.required': '请输入容量',
|
||||
'gpuservice.storage.persistentVolume.releaseWithInstance': '随实例释放',
|
||||
'gpuservice.storage.tempCapacity': '容量(GB)',
|
||||
'gpuservice.storage.tempCapacity.required': '请输入临时存储容量',
|
||||
'gpuservice.form.rule.name':
|
||||
'由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。'
|
||||
'由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。',
|
||||
'gpuservice.form.storage.select': '选择存储'
|
||||
};
|
||||
|
||||
@@ -25,21 +25,24 @@ export default {
|
||||
'menu.resources': '资源',
|
||||
'menu.apikeys': 'API 密钥',
|
||||
'menu.users': '用户',
|
||||
'menu.profile': '个人信息',
|
||||
'menu.profile': '偏好',
|
||||
'menu.login': '登录',
|
||||
'menu.usage': '使用量',
|
||||
'menu.usage.usage': '使用量',
|
||||
'menu.billingAndUsage': '用量与计费',
|
||||
'menu.billingAndUsage.usage': '用量统计',
|
||||
'menu.404': '404',
|
||||
'menu.resources.workers': '节点',
|
||||
'menu.resources.gpus': 'GPUs',
|
||||
'menu.resources.gpus': 'GPU',
|
||||
'menu.models.modelfiles': '模型文件',
|
||||
'menu.accessControl': '访问控制',
|
||||
'menu.accessControl.apikeys': 'API 密钥',
|
||||
'menu.accessControl.users': '用户',
|
||||
'menu.clusterManagement': '集群管理',
|
||||
'menu.clusterManagement.clusters': '集群',
|
||||
'menu.clusterManagement.credentials': '云凭证',
|
||||
'menu.clusterManagement.clusterDetail': '集群详情',
|
||||
'menu.clusterManagement.clusterCreate': '创建集群',
|
||||
'menu.accessControl.organizations': '组织',
|
||||
'menu.resources.clusters': '集群',
|
||||
'menu.resources.credentials': '云凭证',
|
||||
'menu.resources.clusterDetail': '集群详情',
|
||||
'menu.resources.clusterCreate': '创建集群',
|
||||
'menu.models.backendsList': '推理后端',
|
||||
'menu.gpuService': 'GPU 服务',
|
||||
'menu.gpuService.instances': 'GPU 实例',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
'organizations.upsell.title': '组织是企业版功能',
|
||||
'organizations.upsell.subtitle':
|
||||
'多租户可在团队间隔离用户、资源与配额。升级到 GPUStack 企业版即可管理组织。',
|
||||
'organizations.upsell.featuresTitle': '企业版包含的能力',
|
||||
'organizations.upsell.feature.orgs': '创建组织来分组用户并隔离工作负载',
|
||||
'organizations.upsell.feature.members': '为每个组织管理成员与角色',
|
||||
'organizations.upsell.feature.quotas': '为每个组织设置资源与 Token 配额',
|
||||
'organizations.upsell.feature.isolation':
|
||||
'按组织隔离 API 密钥、模型部署与资源',
|
||||
'organizations.upsell.cta': '了解企业版'
|
||||
};
|
||||
@@ -17,9 +17,15 @@ export default {
|
||||
'usage.table.user.apiKeysUsed': '使用的 API 密钥',
|
||||
'usage.table.lastActive': '最后活跃时间',
|
||||
'usage.filter.granularity': '粒度',
|
||||
'usage.filter.granularity.hour': '按小时',
|
||||
'usage.filter.granularity.day': '按天',
|
||||
'usage.filter.granularity.week': '按周',
|
||||
'usage.filter.granularity.month': '按月',
|
||||
'usage.tabs.summary': '总览',
|
||||
'usage.tabs.tokens': 'Token',
|
||||
'usage.tabs.gpuInstances': 'GPU 实例',
|
||||
'usage.tabs.storage': '存储',
|
||||
'usage.tabs.resourceEvents': '资源事件',
|
||||
'usage.tabs.models': '模型',
|
||||
'usage.tabs.apikeys': 'API 密钥',
|
||||
'usage.tabs.users': '用户',
|
||||
@@ -32,5 +38,69 @@ export default {
|
||||
'usage.chart.cached': '缓存',
|
||||
'usage.chart.uncached': '非缓存',
|
||||
'usage.chart.inputTokensCached': '输入 Token 数(缓存/非缓存)',
|
||||
'usage.table.inputTokensCached': '输入 Token 缓存数'
|
||||
'usage.table.inputTokensCached': '输入 Token 缓存数',
|
||||
|
||||
// --- Resource usage: shared metrics & units ---
|
||||
'usage.metric.tokens': 'Token 数',
|
||||
'usage.metric.input': '输入',
|
||||
'usage.metric.output': '输出',
|
||||
'usage.metric.gpuHours': 'GPU 小时',
|
||||
'usage.metric.instanceHours': '实例小时',
|
||||
'usage.metric.gbDays': 'GB·天',
|
||||
'usage.metric.gbHours': 'GB·小时',
|
||||
'usage.metric.activeUsers': '活跃用户',
|
||||
'usage.metric.activeInstances': '活跃实例',
|
||||
'usage.metric.activeStorage': '活跃存储',
|
||||
'usage.metric.activeVolumes': '活跃存储卷',
|
||||
'usage.metric.storageTypes': '存储类型',
|
||||
'usage.metric.gpuHours.tip':
|
||||
'实例运行时长按 GPU 数量加权:一个使用 N 个 GPU 的实例运行 H 小时记为 N × H GPU 小时。当每个实例仅使用单个 GPU 时,等于实例小时。',
|
||||
'usage.metric.instanceHours.tip':
|
||||
'所有实例运行时长的总和,与每个实例使用的 GPU 数量无关。一个实例运行 2 小时 = 2 实例小时。',
|
||||
'usage.metric.gbDays.tip':
|
||||
'存储容量随时间的积分,单位为 GB × 天:10 GB 保留 5 天 = 50 GB·天。(= GB·小时 ÷ 24)',
|
||||
'usage.metric.gbHours.tip':
|
||||
'存储容量随时间的积分,单位为 GB × 小时:10 GB 保留 5 小时 = 50 GB·小时。',
|
||||
|
||||
// --- Resource usage: common table / labels ---
|
||||
'usage.common.noData': '暂无数据',
|
||||
'usage.common.unknown': '未知',
|
||||
'usage.table.date': '日期',
|
||||
'usage.table.name': '名称',
|
||||
'usage.table.user': '用户',
|
||||
'usage.table.users': '用户',
|
||||
'usage.table.type': '类型',
|
||||
'usage.table.instance': '实例',
|
||||
'usage.table.instanceType': '实例类型',
|
||||
'usage.table.instanceTypes': '实例类型',
|
||||
'usage.table.instances': '实例',
|
||||
'usage.table.capacity': '容量',
|
||||
'usage.export.tableNamed': '导出表格数据 — {name}',
|
||||
|
||||
// --- Summary tab ---
|
||||
'usage.summary.compute': '算力',
|
||||
'usage.summary.tokensOverTime': 'Token 数趋势',
|
||||
'usage.summary.gpuHoursOverTime': 'GPU 小时趋势',
|
||||
'usage.summary.gbDaysOverTime': 'GB·天趋势',
|
||||
|
||||
// --- GPU Instances / Storage filters ---
|
||||
'usage.filter.instance': '按实例查询',
|
||||
'usage.filter.storage': '按存储查询',
|
||||
|
||||
// --- Resource events ---
|
||||
'usage.events.resourceType': '资源类型',
|
||||
'usage.events.eventType': '事件类型',
|
||||
'usage.events.col.time': '时间',
|
||||
'usage.events.col.resource': '资源',
|
||||
'usage.events.col.event': '事件',
|
||||
'usage.events.col.message': '消息',
|
||||
'usage.events.resource.gpuInstance': 'GPU 实例',
|
||||
'usage.events.resource.cpuInstance': 'CPU 实例',
|
||||
'usage.events.type.created': '已创建',
|
||||
'usage.events.type.deleted': '已删除',
|
||||
'usage.events.type.started': '已启动',
|
||||
'usage.events.type.stopped': '已停止',
|
||||
'usage.events.type.updated': '已更新',
|
||||
'usage.events.type.attached': '已挂载',
|
||||
'usage.events.type.detached': '已卸载'
|
||||
};
|
||||
|
||||
@@ -100,7 +100,11 @@ const BarChart: React.FC<BarChartProps> = (props) => {
|
||||
data: processedData,
|
||||
type: 'bar',
|
||||
barMaxWidth: 20,
|
||||
barMinWidth: 8,
|
||||
// Keep a small floor only — a large barMinWidth would force wide bars
|
||||
// when there are many categories (e.g. hourly buckets), squeezing out
|
||||
// the category gap so bars look fused. A low floor lets barCategoryGap
|
||||
// win, so even dense hourly views keep visible gaps.
|
||||
barMinWidth: 2,
|
||||
barGap: '30%',
|
||||
barCategoryGap: '50%',
|
||||
...(stack === false || stack === undefined ? {} : { stack }),
|
||||
@@ -122,7 +126,7 @@ const BarChart: React.FC<BarChartProps> = (props) => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: title ? 30 : 10,
|
||||
bottom: 28,
|
||||
bottom: 0,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { currentOrganizationIdAtom } from '@/atoms/user';
|
||||
import { Input as CInput } from '@gpustack/core-ui';
|
||||
import { Form } from 'antd';
|
||||
import type { NamePath } from 'antd/es/form/interface';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface OwnerPrincipalIdFieldProps {
|
||||
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> = ({
|
||||
name = 'owner_principal_id'
|
||||
}) => {
|
||||
const currentOrgId = useAtomValue(currentOrganizationIdAtom);
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentOrgId != null) {
|
||||
form.setFieldValue(name, currentOrgId);
|
||||
}
|
||||
}, [currentOrgId, name, form]);
|
||||
|
||||
return (
|
||||
<Form.Item name={name} hidden>
|
||||
<CInput.Input />
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default OwnerPrincipalIdField;
|
||||
@@ -19,6 +19,11 @@ interface PieChartProps {
|
||||
totalLabel?: string;
|
||||
}
|
||||
|
||||
// Donut sits on the left; a vertical legend hugs it just to the right and
|
||||
// scrolls when there are many entries.
|
||||
const CENTER_X = '32%';
|
||||
const round2 = (n: number) => Math.round((Number(n) || 0) * 100) / 100;
|
||||
|
||||
const PieChart: React.FC<PieChartProps> = ({
|
||||
data,
|
||||
height = 300,
|
||||
@@ -40,8 +45,20 @@ const PieChart: React.FC<PieChartProps> = ({
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
color: colors,
|
||||
grid: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
position: 'top',
|
||||
// Keep the tooltip inside the chart box — the donut sits on the left, so
|
||||
// a left-slice tooltip could otherwise spill out and get hidden behind
|
||||
// the side menu.
|
||||
confine: false,
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderColor: 'transparent',
|
||||
formatter: (params: any) => {
|
||||
@@ -51,7 +68,7 @@ const PieChart: React.FC<PieChartProps> = ({
|
||||
<span class="tooltip-item-dot" style="border-radius:50%;background-color:${params.color};"></span>
|
||||
<span class="tooltip-item-title">${params.name}</span>:
|
||||
</span>
|
||||
<span class="tooltip-value">${formatLargeNumber(params.value)}</span>
|
||||
<span class="tooltip-value">${formatLargeNumber(round2(params.value))} (${params.percent}%)</span>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -59,18 +76,21 @@ const PieChart: React.FC<PieChartProps> = ({
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
orient: 'vertical',
|
||||
right: 0,
|
||||
top: 18,
|
||||
bottom: 18,
|
||||
left: '56%',
|
||||
top: 'middle',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
itemGap: 12,
|
||||
itemGap: 10,
|
||||
// Long names are truncated in the legend; hovering shows the full name
|
||||
// in a tooltip.
|
||||
tooltip: { show: true },
|
||||
textStyle: {
|
||||
color: token.colorTextTertiary,
|
||||
overflow: 'truncate',
|
||||
width: 180
|
||||
width: 120
|
||||
},
|
||||
pageTextStyle: { color: token.colorTextTertiary },
|
||||
pageIconSize: 10,
|
||||
pageIconColor: token.colorTextTertiary,
|
||||
pageIconInactiveColor: token.colorTextDisabled,
|
||||
data: data.map((item) => item.name),
|
||||
@@ -79,24 +99,19 @@ const PieChart: React.FC<PieChartProps> = ({
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['52%', '72%'],
|
||||
center: ['34%', '50%'],
|
||||
radius: ['54%', '78%'],
|
||||
center: [CENTER_X, '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
// No on-arc label — the percentage lives in the tooltip instead, so
|
||||
// hovering shows value + percent in one place.
|
||||
label: { show: false },
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: any) => `${params.percent}%`,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: token.colorText
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
show: false
|
||||
},
|
||||
scale: true
|
||||
},
|
||||
labelLine: { show: false },
|
||||
data
|
||||
}
|
||||
]
|
||||
@@ -137,7 +152,7 @@ const PieChart: React.FC<PieChartProps> = ({
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '34%',
|
||||
left: CENTER_X,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
display: 'flex',
|
||||
|
||||
@@ -98,10 +98,11 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
expires_in: getExpireValue(data.expires_in)
|
||||
};
|
||||
const res = await createApisKey({ data: params });
|
||||
|
||||
onOk();
|
||||
// if custom value
|
||||
if (data.custom) {
|
||||
return onOk();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
setAPIKeyValue(res.value);
|
||||
@@ -111,6 +112,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
const updateAPIKey = async (data: FormData) => {
|
||||
await updateApisKey(currentData?.id as number, { data });
|
||||
onOk();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleOnOk = async (formdata: FormData) => {
|
||||
@@ -142,7 +144,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
onOk();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleAfterOpenChange = (isOpen: boolean) => {
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
// columns.ts
|
||||
import { tableSorter } from '@/config/settings';
|
||||
import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui';
|
||||
import { DashboardOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
AutoTooltip,
|
||||
DropdownButtons,
|
||||
IconFont,
|
||||
icons
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { MenuProps, Tag } from 'antd';
|
||||
import { MenuProps, Tag, Tooltip } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
import { ListItem } from '../config/types';
|
||||
import type { APIKeyConfigAction } from '../plugin';
|
||||
|
||||
type APIKeyAction = Global.ActionItem<ListItem> & {
|
||||
type APIKeyAction = Omit<Global.ActionItem<ListItem>, 'disabled' | 'label'> & {
|
||||
// Per-row callback (function) is the host's default; placeholders use a
|
||||
// plain boolean to render the menu item grayed out unconditionally.
|
||||
disabled?: boolean | ((record: ListItem) => boolean);
|
||||
// ReactNode allowed so disabled placeholders can render a Tooltip-
|
||||
// wrapped label (paired with `locale: false`).
|
||||
label: string | React.ReactNode;
|
||||
onClick?: (record: ListItem) => void;
|
||||
};
|
||||
|
||||
@@ -18,7 +30,10 @@ type RankedAction = APIKeyAction & { priority: number };
|
||||
interface ColumnsHookProps {
|
||||
handleSelect: (val: string, record: ListItem, item?: APIKeyAction) => void;
|
||||
sortOrder: string[];
|
||||
is_admin?: boolean;
|
||||
// Reveal the Creator column to callers who can see other users' keys
|
||||
// (platform admin or current-Org owner). Members only see their own
|
||||
// keys, so the column would be redundant for them.
|
||||
showCreator?: boolean;
|
||||
configActions?: APIKeyConfigAction[];
|
||||
// Dispatches the click for a plugin-contributed dropdown entry to the
|
||||
// controller `useCreate()` returned for that entry.
|
||||
@@ -28,7 +43,7 @@ interface ColumnsHookProps {
|
||||
const useModelsColumns = ({
|
||||
handleSelect,
|
||||
sortOrder,
|
||||
is_admin,
|
||||
showCreator,
|
||||
configActions = [],
|
||||
onConfigAction
|
||||
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
||||
@@ -65,10 +80,48 @@ const useModelsColumns = ({
|
||||
onClick: (record: ListItem) => onConfigAction?.(a.key, record)
|
||||
}));
|
||||
|
||||
return [...builtIns, ...fromPlugins].sort(
|
||||
// Show disabled placeholders for IP Access Control / Quota Limit in
|
||||
// the OSS build only — when the enterprise plugin contributes the
|
||||
// real entry under the same key, skip the placeholder so the live
|
||||
// action takes over. Keeps the dropdown's surface area consistent
|
||||
// between editions while making the upgrade path discoverable.
|
||||
const pluginKeys = new Set(configActions.map((a) => a.key));
|
||||
const enterpriseTooltip = intl.formatMessage({
|
||||
id: 'common.enterprise.feature'
|
||||
});
|
||||
const enterprisePlaceholder = (labelId: string): React.ReactNode => (
|
||||
<Tooltip title={enterpriseTooltip} placement="left">
|
||||
<span style={{ display: 'inline-block' }}>
|
||||
{intl.formatMessage({ id: labelId })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
const placeholders: RankedAction[] = [];
|
||||
if (!pluginKeys.has('ipConfig')) {
|
||||
placeholders.push({
|
||||
key: 'ipConfig',
|
||||
label: enterprisePlaceholder('apikeys.button.ipConfig'),
|
||||
locale: false,
|
||||
icon: <IconFont type="icon-safe-ip" />,
|
||||
disabled: true,
|
||||
priority: 12
|
||||
});
|
||||
}
|
||||
if (!pluginKeys.has('quotaLimit')) {
|
||||
placeholders.push({
|
||||
key: 'quotaLimit',
|
||||
label: enterprisePlaceholder('quotaLimits.button.title'),
|
||||
locale: false,
|
||||
icon: <DashboardOutlined />,
|
||||
disabled: true,
|
||||
priority: 14
|
||||
});
|
||||
}
|
||||
|
||||
return [...builtIns, ...fromPlugins, ...placeholders].sort(
|
||||
(a, b) => a.priority - b.priority
|
||||
);
|
||||
}, [configActions, onConfigAction]);
|
||||
}, [intl, configActions, onConfigAction]);
|
||||
|
||||
return useMemo(() => {
|
||||
return [
|
||||
@@ -109,17 +162,6 @@ const useModelsColumns = ({
|
||||
</AutoTooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'users.table.username' }),
|
||||
dataIndex: 'user_name',
|
||||
key: 'user_name',
|
||||
hidden: !is_admin,
|
||||
render: (text: string, record: ListItem) => (
|
||||
<AutoTooltip ghost style={{ maxWidth: 200 }}>
|
||||
{text || '-'}
|
||||
</AutoTooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'apikeys.form.expiretime' }),
|
||||
dataIndex: 'expires_at',
|
||||
@@ -187,6 +229,17 @@ const useModelsColumns = ({
|
||||
<AutoTooltip ghost>{text}</AutoTooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.creator' }),
|
||||
dataIndex: 'user_name',
|
||||
key: 'user_name',
|
||||
hidden: !showCreator,
|
||||
render: (text: string) => (
|
||||
<AutoTooltip ghost style={{ maxWidth: 200 }}>
|
||||
{text || '-'}
|
||||
</AutoTooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.createTime' }),
|
||||
dataIndex: 'created_at',
|
||||
@@ -216,7 +269,7 @@ const useModelsColumns = ({
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [intl, is_admin, handleSelect, actionList]);
|
||||
}, [intl, showCreator, handleSelect, actionList]);
|
||||
};
|
||||
|
||||
export default useModelsColumns;
|
||||
|
||||
@@ -3,9 +3,8 @@ import { PaginationKey } from '@/config/settings';
|
||||
import type { PageActionType } from '@/config/types';
|
||||
import useTableFetch from '@/hooks/use-table-fetch';
|
||||
import useQueryUserList from '@/pages/users/services/use-query-user-list';
|
||||
import { useModel } from '@@/plugin-model';
|
||||
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
|
||||
import { ConfigProvider, Table } from 'antd';
|
||||
import _ from 'lodash';
|
||||
@@ -22,8 +21,12 @@ import {
|
||||
} from './plugin';
|
||||
|
||||
const APIKeys: React.FC = () => {
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const currentUser = initialState?.currentUser;
|
||||
const access = useAccess();
|
||||
// `canSeeOrgAdmin` widens to Org owners in the enterprise build —
|
||||
// mirrors the BE's "platform admin OR current-Org owner" gate on
|
||||
// listing every key in scope. Personal/member users continue to see
|
||||
// only their own keys (`user_id: undefined`).
|
||||
const canSeeAllKeys = !!access.canSeeOrgAdmin;
|
||||
const {
|
||||
TABLE_SORT_DIRECTIONS,
|
||||
dataSource,
|
||||
@@ -45,7 +48,7 @@ const APIKeys: React.FC = () => {
|
||||
deleteAPI: deleteApisKey,
|
||||
contentForDelete: 'apikeys.table.apikeys',
|
||||
defaultQueryParams: {
|
||||
user_id: currentUser?.is_admin ? '*' : undefined
|
||||
user_id: canSeeAllKeys ? '*' : undefined
|
||||
}
|
||||
});
|
||||
const {
|
||||
@@ -94,8 +97,15 @@ const APIKeys: React.FC = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// `scope=current_org` limits the creator dropdown to members of the
|
||||
// active Org. Without it, an Org owner sees every user in the
|
||||
// system — most of whom can't own a key visible in this list, so
|
||||
// selecting them produces an empty result. The BE drops the
|
||||
// param silently when the request has no Org context, so callers
|
||||
// without an Org keep the full-directory behavior.
|
||||
fetchUserData({
|
||||
page: -1
|
||||
page: -1,
|
||||
scope: 'current_org'
|
||||
});
|
||||
return () => {
|
||||
cancelUserRequest();
|
||||
@@ -123,12 +133,6 @@ const APIKeys: React.FC = () => {
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
await fetchData();
|
||||
setOpenAddModal({
|
||||
open: false,
|
||||
title: '',
|
||||
action: PageAction.CREATE,
|
||||
currentData: null
|
||||
});
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
@@ -198,7 +202,7 @@ const APIKeys: React.FC = () => {
|
||||
const columns = useKeysColumns({
|
||||
handleSelect: onSelect,
|
||||
sortOrder,
|
||||
is_admin: currentUser?.is_admin,
|
||||
showCreator: canSeeAllKeys,
|
||||
configActions,
|
||||
onConfigAction: handleConfigAction
|
||||
});
|
||||
@@ -209,10 +213,10 @@ const APIKeys: React.FC = () => {
|
||||
<FilterBar
|
||||
marginBottom={22}
|
||||
marginTop={30}
|
||||
showSelect={currentUser?.is_admin}
|
||||
showSelect={canSeeAllKeys}
|
||||
selectOptions={userList}
|
||||
select={{ showSearch: { optionFilterProp: 'label' } }}
|
||||
selectHolder={intl.formatMessage({ id: 'models.table.filterByName' })}
|
||||
selectHolder={intl.formatMessage({ id: 'common.filter.byCreator' })}
|
||||
buttonText={intl.formatMessage({ id: 'apikeys.button.create' })}
|
||||
handleSearch={handleSearch}
|
||||
handleDeleteByBatch={handleDeleteBatch}
|
||||
|
||||
@@ -276,7 +276,9 @@ const VersionsForm: React.FC<AddModalProps> = ({
|
||||
key={name}
|
||||
defaultOpen
|
||||
styles={{
|
||||
body: collapseKey.has(name) ? { padding: 16 } : {},
|
||||
body: collapseKey.has(name)
|
||||
? { paddingInline: 16, paddingBlock: '16px 0' }
|
||||
: {},
|
||||
content: { paddingTop: 0 },
|
||||
header: {
|
||||
backgroundColor: 'unset'
|
||||
|
||||
@@ -84,13 +84,6 @@ const Instance: React.FC = () => {
|
||||
}),
|
||||
children: renderParams(instanceData?.backend_parameters || [])
|
||||
},
|
||||
{
|
||||
key: '1-1',
|
||||
label: intl.formatMessage({
|
||||
id: 'models.instance.params.autoInjected'
|
||||
}),
|
||||
children: renderParams(instanceData?.injected_backend_parameters || [])
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: intl.formatMessage({ id: 'benchmark.detail.kvCache' }),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { modelCategoriesMap } from '@/pages/llmodels/config';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { BaseSelect, FilterForm } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
@@ -23,17 +22,13 @@ interface FilterFormContentProps {
|
||||
initialValues?: any;
|
||||
open?: boolean;
|
||||
ref?: any;
|
||||
modelList?: Global.BaseOption<number, { categories: string[] }>[];
|
||||
onClose?: () => void;
|
||||
onClear?: () => void;
|
||||
onValuesChange: (values: any) => void;
|
||||
}
|
||||
|
||||
const FilterFormContent: React.FC<FilterFormContentProps> = forwardRef(
|
||||
(
|
||||
{ initialValues, onClose, onClear, onValuesChange, open, modelList },
|
||||
ref
|
||||
) => {
|
||||
({ initialValues, onClose, onClear, onValuesChange, open }, ref) => {
|
||||
const intl = useIntl();
|
||||
const filterRef = useRef<any>(null);
|
||||
|
||||
@@ -41,15 +36,6 @@ const FilterFormContent: React.FC<FilterFormContentProps> = forwardRef(
|
||||
onValuesChange?.(allValues);
|
||||
};
|
||||
|
||||
const modelOptions = modelList
|
||||
?.filter((item) => {
|
||||
return item.categories?.includes(modelCategoriesMap.llm);
|
||||
})
|
||||
.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.label
|
||||
}));
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset: () => {
|
||||
filterRef.current?.reset();
|
||||
@@ -116,16 +102,17 @@ const FilterFormContent: React.FC<FilterFormContentProps> = forwardRef(
|
||||
</Form.Item>
|
||||
<Label>{intl.formatMessage({ id: 'benchmark.table.model' })}</Label>
|
||||
<Form.Item noStyle name="model_name">
|
||||
{/* <PillButtonGroup
|
||||
options={modelCategories.filter((item) => item.value)}
|
||||
></PillButtonGroup> */}
|
||||
<BaseSelect
|
||||
allowClear
|
||||
<Input
|
||||
prefix={
|
||||
<SearchOutlined
|
||||
style={{ color: 'var(--ant-color-text-placeholder)' }}
|
||||
></SearchOutlined>
|
||||
}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'benchmark.table.filter.bymodel'
|
||||
})}
|
||||
options={modelOptions}
|
||||
></BaseSelect>
|
||||
allowClear
|
||||
></Input>
|
||||
</Form.Item>
|
||||
</Content>
|
||||
</FilterForm>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||
import { modelNameReg, PageAction } from '@/config';
|
||||
import { ClusterStatusValueMap } from '@/pages/cluster-management/config';
|
||||
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
|
||||
import { useBenchmarkTargetInstance } from '@/pages/llmodels/hooks/use-run-benchmark';
|
||||
import {
|
||||
Input as CInput,
|
||||
@@ -8,7 +10,7 @@ import {
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Form } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useFormContext } from '../config/form-context';
|
||||
import { FormData } from '../config/types';
|
||||
import ModelInstanceForm from './model-instance';
|
||||
@@ -20,31 +22,70 @@ const BasicForm: React.FC = () => {
|
||||
const { action, open, clusterList } = useFormContext();
|
||||
const { benchmarkTargetInstance } = useBenchmarkTargetInstance();
|
||||
|
||||
// `organization_id` is owned by the create-scope picker slot (admin "All"
|
||||
// view). When a platform admin targets an org (and we're not pre-filling
|
||||
// from a launched instance), fetch *that org's* clusters directly — the
|
||||
// request header carries the chosen org — rather than filtering the
|
||||
// page-level list, which is fetched once and may not include the org's
|
||||
// clusters. The benchmark's owner is derived from the chosen cluster.
|
||||
const scopeOrgId = Form.useWatch('organization_id', form);
|
||||
const orgScoped = scopeOrgId != null && !benchmarkTargetInstance.cluster_id;
|
||||
const {
|
||||
clusterList: scopedClusterList,
|
||||
fetchClusterList: fetchScopedClusters
|
||||
} = useQueryClusterList();
|
||||
// The org-scoped fetch returns clusters *visible* to the org — its own plus
|
||||
// any granted via cluster_access (and the platform principal can see a lot).
|
||||
// A benchmark's owner is the chosen cluster's owner, so keep only clusters
|
||||
// actually owned by the selected org. The owner filter also keeps this
|
||||
// correct if the request header isn't applied (fetch falls back to all).
|
||||
const effectiveClusterList = useMemo(() => {
|
||||
if (!orgScoped) {
|
||||
return clusterList || [];
|
||||
}
|
||||
return (scopedClusterList || []).filter(
|
||||
(item: any) => item.owner_principal_id === scopeOrgId
|
||||
);
|
||||
}, [orgScoped, scopedClusterList, clusterList, scopeOrgId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (action === PageAction.CREATE && orgScoped) {
|
||||
fetchScopedClusters({ page: -1 });
|
||||
}
|
||||
}, [scopeOrgId, orgScoped, action]);
|
||||
|
||||
useEffect(() => {
|
||||
if (action !== PageAction.CREATE) {
|
||||
return;
|
||||
}
|
||||
const clusterValue = (item: any) => item?.value ?? item?.id;
|
||||
const initClusterId = (list: any[]) => {
|
||||
// Find default cluster
|
||||
const defaultCluster = list?.find((item) => item.is_default);
|
||||
if (defaultCluster) {
|
||||
return defaultCluster.id;
|
||||
return clusterValue(defaultCluster);
|
||||
}
|
||||
|
||||
const cluster_id =
|
||||
list?.find((item) => item.state === ClusterStatusValueMap.Ready)?.id ||
|
||||
list?.[0]?.id;
|
||||
|
||||
return cluster_id;
|
||||
};
|
||||
if (
|
||||
clusterList &&
|
||||
clusterList?.length > 0 &&
|
||||
action === PageAction.CREATE
|
||||
) {
|
||||
form.setFieldValue(
|
||||
'cluster_id',
|
||||
benchmarkTargetInstance.cluster_id || initClusterId(clusterList)
|
||||
const readyCluster = list?.find(
|
||||
(item) => item.state === ClusterStatusValueMap.Ready
|
||||
);
|
||||
return clusterValue(readyCluster) ?? clusterValue(list?.[0]);
|
||||
};
|
||||
const current = form.getFieldValue('cluster_id');
|
||||
const stillValid = effectiveClusterList.some(
|
||||
(item: any) => clusterValue(item) === current
|
||||
);
|
||||
if (stillValid) {
|
||||
return;
|
||||
}
|
||||
}, [form, action, clusterList, benchmarkTargetInstance]);
|
||||
// Re-pick within the (org-scoped) list. When the chosen org owns no
|
||||
// clusters this resolves to undefined, clearing a stale cross-org cluster
|
||||
// instead of leaving it selected.
|
||||
form.setFieldValue(
|
||||
'cluster_id',
|
||||
benchmarkTargetInstance.cluster_id || initClusterId(effectiveClusterList)
|
||||
);
|
||||
}, [form, action, effectiveClusterList, benchmarkTargetInstance]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -67,6 +108,7 @@ const BasicForm: React.FC = () => {
|
||||
required
|
||||
></CInput.Input>
|
||||
</Form.Item>
|
||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||
<Form.Item<FormData>
|
||||
name="cluster_id"
|
||||
rules={[
|
||||
@@ -78,7 +120,7 @@ const BasicForm: React.FC = () => {
|
||||
>
|
||||
<SealSelect
|
||||
disabled={action === PageAction.EDIT}
|
||||
options={clusterList}
|
||||
options={effectiveClusterList}
|
||||
label={intl.formatMessage({ id: 'clusters.title' })}
|
||||
required
|
||||
></SealSelect>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useQueryModelList } from '@/pages/llmodels/services/use-query-model-lis
|
||||
import { Cascader as SealCascader, useAppUtils } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Form, Tooltip } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useFormContext } from '../config/form-context';
|
||||
import { FormData } from '../config/types';
|
||||
|
||||
@@ -45,6 +45,11 @@ const ModelInstanceForm: React.FC = () => {
|
||||
const form = Form.useFormInstance();
|
||||
const { getRuleMessage } = useAppUtils();
|
||||
const { action, open } = useFormContext();
|
||||
// Owned by the create-scope picker slot (admin "All" view). The model list
|
||||
// is tenant-scoped by the request header, so refetch it when the org
|
||||
// changes so only the chosen org's models/instances are offered.
|
||||
const scopeOrgId = Form.useWatch('organization_id', form);
|
||||
const prevScopeRef = useRef<number | null | undefined>(undefined);
|
||||
const [modelList, setModelList] = React.useState<any[]>([]);
|
||||
const {
|
||||
loading: modelLoading,
|
||||
@@ -158,14 +163,29 @@ const ModelInstanceForm: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (open && action === PageAction.CREATE) {
|
||||
// On a genuine org change, clear the stale (possibly cross-org) target
|
||||
// so the refetched list re-selects within the new org.
|
||||
if (
|
||||
prevScopeRef.current !== undefined &&
|
||||
prevScopeRef.current !== scopeOrgId
|
||||
) {
|
||||
form.setFieldsValue({
|
||||
model_name: undefined,
|
||||
model_id: undefined,
|
||||
model_instance_name: undefined,
|
||||
model_instance: undefined
|
||||
});
|
||||
}
|
||||
prevScopeRef.current = scopeOrgId;
|
||||
initModelInstance();
|
||||
}
|
||||
if (!open) {
|
||||
prevScopeRef.current = undefined;
|
||||
cancelModelRequest();
|
||||
cancelInstanceRequest();
|
||||
clearBenchmarkTargetInstance();
|
||||
}
|
||||
}, [open, benchmarkTargetInstance, action]);
|
||||
}, [open, benchmarkTargetInstance, action, scopeOrgId]);
|
||||
|
||||
return (
|
||||
<Form.Item<FormData>
|
||||
|
||||
@@ -27,7 +27,7 @@ const useBenchmarkColumns = (params: {
|
||||
dataIndex: 'name',
|
||||
sorter: tableSorter(1),
|
||||
render: (text: string, record) => (
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
<AutoTooltip ghost minWidth={20} title={text}>
|
||||
<Typography.Link onClick={() => onCellClick?.(record, 'name')}>
|
||||
{text}
|
||||
</Typography.Link>
|
||||
|
||||
@@ -113,7 +113,8 @@ export async function queryClusterList(
|
||||
return request<Global.PageResponse<ClusterListItem>>(`${CLUSTERS_API}`, {
|
||||
method: 'GET',
|
||||
params,
|
||||
cancelToken: options?.token
|
||||
cancelToken: options?.token,
|
||||
skipErrorHandler: options?.skipErrorHandler
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -62,13 +62,15 @@ const ClusterDetailModal = () => {
|
||||
key: 'workers',
|
||||
label: `Workers`,
|
||||
icon: <IconFont type="icon-resources" />,
|
||||
children: <WorkerList clusterId={Number(id)} />
|
||||
children: (
|
||||
<WorkerList clusterId={Number(id)} source="clusterDetail" />
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'gpus',
|
||||
label: `GPUs`,
|
||||
icon: <IconFont type="icon-gpu1" />,
|
||||
children: <GPUList clusterId={Number(id)} />
|
||||
children: <GPUList clusterId={Number(id)} source="clusterDetail" />
|
||||
},
|
||||
...extraTabs
|
||||
]}
|
||||
|
||||
@@ -271,7 +271,7 @@ const Clusters: React.FC = () => {
|
||||
const handleOnCell = useMemoizedFn((record: ClusterListItem, dataIndex) => {
|
||||
if (dataIndex === 'name') {
|
||||
navigate(
|
||||
`/cluster-management/clusters/detail?id=${record.id}&name=${record.name}&page=clusters`
|
||||
`/resources/clusters/detail?id=${record.id}&name=${record.name}&page=clusters`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -461,7 +461,7 @@ const Clusters: React.FC = () => {
|
||||
<DeleteModal ref={modalRef}></DeleteModal>
|
||||
<ClusterModal
|
||||
title={intl.formatMessage({
|
||||
id: 'menu.clusterManagement.clusterCreate'
|
||||
id: 'menu.resources.clusterCreate'
|
||||
})}
|
||||
open={clusterModalStatus.open}
|
||||
providerHint={pendingProviderHint}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import { FormDrawer } from '@gpustack/core-ui';
|
||||
import React, { useRef } from 'react';
|
||||
import { ProviderType } from '../config';
|
||||
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { AlertBlockInfo, FormDrawer, ModalFooter } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { ProviderType, ProviderValueMap } from '../config';
|
||||
import {
|
||||
ClusterFormData as FormData,
|
||||
ClusterListItem as ListItem
|
||||
} from '../config/types';
|
||||
import ClusterForm from './cluster-form';
|
||||
|
||||
const ModalFooterStyle = {
|
||||
padding: '16px 24px 8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end'
|
||||
};
|
||||
|
||||
type AddModalProps = {
|
||||
title: string;
|
||||
action: PageActionType;
|
||||
@@ -28,7 +37,12 @@ const AddCluster: React.FC<AddModalProps> = ({
|
||||
onOk,
|
||||
onCancel
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const form = useRef<any>(null);
|
||||
// Whether the user has changed any k8s_options field. Lifted from ClusterForm
|
||||
// so the "re-run registration" notice can sit in the drawer footer, above the
|
||||
// Save/Cancel buttons (mirrors the model edit interaction).
|
||||
const [k8sOptionsChanged, setK8sOptionsChanged] = useState<boolean>(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
form.current?.submit();
|
||||
@@ -53,6 +67,27 @@ const AddCluster: React.FC<AddModalProps> = ({
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
width={710}
|
||||
footer={
|
||||
<>
|
||||
{action === PageAction.EDIT &&
|
||||
provider === ProviderValueMap.Kubernetes &&
|
||||
k8sOptionsChanged && (
|
||||
<AlertBlockInfo
|
||||
type="warning"
|
||||
style={{ margin: '8px 24px 0' }}
|
||||
icon={<ExclamationCircleFilled />}
|
||||
message={intl.formatMessage({
|
||||
id: 'clusters.edit.k8sOptions.changed.tip'
|
||||
})}
|
||||
></AlertBlockInfo>
|
||||
)}
|
||||
<ModalFooter
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
style={ModalFooterStyle}
|
||||
></ModalFooter>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ClusterForm
|
||||
ref={form}
|
||||
@@ -61,6 +96,7 @@ const AddCluster: React.FC<AddModalProps> = ({
|
||||
action={action}
|
||||
currentData={currentData}
|
||||
onFinish={handleOk}
|
||||
onK8sOptionsChange={setK8sOptionsChanged}
|
||||
/>
|
||||
</FormDrawer>
|
||||
);
|
||||
|
||||
@@ -67,6 +67,7 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
|
||||
);
|
||||
const { update, summary, register } = useSummaryStatus();
|
||||
const { addedCount, createModelsChunkRequest } = useAddWorkerMessage();
|
||||
console.log('addedCount=========', addedCount);
|
||||
|
||||
const onToggle = (open: boolean, key: string) => {
|
||||
setCollapseKey(open ? new Set([key]) : new Set());
|
||||
@@ -81,6 +82,7 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
|
||||
setCollapseKey(new Set([stepList[0]]));
|
||||
}, [stepList]);
|
||||
|
||||
console.log('actionSource=========', actionSource, registrationInfo);
|
||||
React.useEffect(() => {
|
||||
// this effect is only triggered when used in cluster create page inner
|
||||
if (actionSource === 'page' && registrationInfo?.cluster_id) {
|
||||
@@ -164,6 +166,7 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
|
||||
</>
|
||||
)}
|
||||
{actionSource === 'modal' && (
|
||||
// show in cluster create page inner
|
||||
<AddedMessage addedCount={addedCount}></AddedMessage>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
@@ -75,6 +75,7 @@ const AddWorker: React.FC<AddWorkerProps> = (props) => {
|
||||
});
|
||||
|
||||
const handleOnClusterChange = async (value: number, row?: any) => {
|
||||
console.log('handleOnClusterChange value, row=========', value, row);
|
||||
try {
|
||||
createModelsChunkRequest({ cluster_id: value });
|
||||
axiosTokenRef.current?.cancel?.();
|
||||
@@ -121,6 +122,8 @@ const AddWorker: React.FC<AddWorkerProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
console.log('addedCount 1========', addedCount);
|
||||
|
||||
return (
|
||||
<GSDrawer
|
||||
title={title}
|
||||
|
||||
@@ -36,9 +36,7 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
// for the Kubernetes provider; other providers stay single-select.
|
||||
const multiCapable = provider === ProviderValueMap.Kubernetes;
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([
|
||||
GPUDriverMap.NVIDIA
|
||||
]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
|
||||
// No vendor is gated anymore — every card stays selectable.
|
||||
const availableKeys = undefined;
|
||||
@@ -49,18 +47,6 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
{}
|
||||
);
|
||||
|
||||
// Push current selection into the shared summary so consumers
|
||||
// (K8sRunCommand, CheckEnvironment, VendorNotes) can read it.
|
||||
useEffect(() => {
|
||||
const primary = selectedKeys[0] || '';
|
||||
updateField('currentGPU', primary);
|
||||
updateField('selectedGPUs', selectedKeys);
|
||||
updateField(
|
||||
'workerCommand',
|
||||
primary ? buildWorkerCommand(primary, itemMetaRef.current[primary]) : null
|
||||
);
|
||||
}, [selectedKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister1 = registerField('currentGPU');
|
||||
const unregister2 = registerField('workerCommand');
|
||||
@@ -72,6 +58,29 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const buildSelectedKeys = (key: string) => {
|
||||
const prev = [...selectedKeys];
|
||||
const has = prev.includes(key);
|
||||
if (has) {
|
||||
// Clicking a selected card always toggles it off.
|
||||
return prev.filter((v) => v !== key);
|
||||
}
|
||||
// K8s clusters support multiple GPU runtimes, so accumulate picks.
|
||||
// Other providers stay single-select and replace the current pick.
|
||||
if (multiCapable) return [...prev, key];
|
||||
return [key];
|
||||
};
|
||||
|
||||
const updateFieldsOnSelect = (keys: string[]) => {
|
||||
const primary = keys[0] || '';
|
||||
updateField('currentGPU', primary);
|
||||
updateField('selectedGPUs', keys);
|
||||
updateField(
|
||||
'workerCommand',
|
||||
primary ? buildWorkerCommand(primary, itemMetaRef.current[primary]) : null
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelect = (key: string, item: any) => {
|
||||
if (item) {
|
||||
itemMetaRef.current[key] = {
|
||||
@@ -79,19 +88,25 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
link: item.link
|
||||
};
|
||||
}
|
||||
setSelectedKeys((prev) => {
|
||||
const has = prev.includes(key);
|
||||
if (has) {
|
||||
// Clicking a selected card always toggles it off.
|
||||
return prev.filter((v) => v !== key);
|
||||
}
|
||||
// K8s clusters support multiple GPU runtimes, so accumulate picks.
|
||||
// Other providers stay single-select and replace the current pick.
|
||||
if (multiCapable) return [...prev, key];
|
||||
return [key];
|
||||
});
|
||||
const keys = buildSelectedKeys(key);
|
||||
updateFieldsOnSelect(keys);
|
||||
setSelectedKeys(keys);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// init a default selection
|
||||
handleSelect(GPUDriverMap.NVIDIA, {
|
||||
label: 'NVIDIA',
|
||||
hiddenTitle: true,
|
||||
value: GPUDriverMap.NVIDIA,
|
||||
description: '',
|
||||
key: GPUDriverMap.NVIDIA,
|
||||
locale: false,
|
||||
notes: AddWorkerDockerNotes[GPUDriverMap.NVIDIA],
|
||||
link: 'https://docs.gpustack.ai/latest/installation/requirements/#nvidia-gpu'
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StepCollapse
|
||||
disabled={disabled}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface StepItemProps {
|
||||
const Box = styled.div`
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
&.step-collapse-open {
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ const NotFoundCredentialContent: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={'/cluster-management/credentials'} onClick={handleOnClick}>
|
||||
<Link to={'/resources/credentials'} onClick={handleOnClick}>
|
||||
{intl.formatMessage({ id: 'clusters.button.addCredential' })}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -11,15 +11,24 @@ import {
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Form } from 'antd';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState
|
||||
} from 'react';
|
||||
import { ProviderType, ProviderValueMap } from '../config';
|
||||
import { FormContext } from '../config/form-context';
|
||||
import {
|
||||
ClusterFormData as FormData,
|
||||
ClusterListItem as ListItem
|
||||
} from '../config/types';
|
||||
import AdvanceConfig from '../step-forms/advance-config';
|
||||
import CloudProvider from './cloud-provider-form';
|
||||
import K8sPodSpec from './k8s-pod-spec';
|
||||
import K8sAdvancedOptions, {
|
||||
ClusterTypeSelector,
|
||||
K8sOptionsChangeWatcher
|
||||
} from './k8s-pod-spec';
|
||||
|
||||
type AddModalProps = {
|
||||
action: PageActionType;
|
||||
@@ -27,18 +36,27 @@ type AddModalProps = {
|
||||
provider: ProviderType;
|
||||
credentialList: Global.BaseOption<number>[];
|
||||
onFinish: (values: FormData) => void;
|
||||
// Reports whether the user has changed any k8s_options field, so the parent
|
||||
// can show the "re-run registration" notice in the footer.
|
||||
onK8sOptionsChange?: (changed: boolean) => void;
|
||||
ref?: any;
|
||||
};
|
||||
const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
({ action, provider, currentData, credentialList, onFinish }, ref) => {
|
||||
(
|
||||
{
|
||||
action,
|
||||
provider,
|
||||
currentData,
|
||||
credentialList,
|
||||
onFinish,
|
||||
onK8sOptionsChange
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [form] = Form.useForm();
|
||||
const intl = useIntl();
|
||||
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 [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const advanceConfigRef = React.useRef<any>(null);
|
||||
const systemConfig = useAtomValue(systemConfigAtom);
|
||||
|
||||
@@ -59,20 +77,8 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
}
|
||||
}, [activeKey, action]);
|
||||
|
||||
// The backend models the optional k8s_options string knobs as
|
||||
// Optional[str] and treats null/absent as "use the server default" or
|
||||
// "no auth". Coerce empty form values to null before sending so a blank
|
||||
// input is unambiguous rather than an empty string that defeats fallbacks.
|
||||
const normalizeOutgoing = (values: any): any => {
|
||||
const base: any = { ...values };
|
||||
// Top-level cluster field shared by Docker and K8s. Trim then coerce a
|
||||
// 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;
|
||||
@@ -88,32 +94,14 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
}));
|
||||
}
|
||||
|
||||
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());
|
||||
// antd's onFinish only delivers values for registered Form.Items.
|
||||
// Spreading those on top of `getFieldsValue(true)` clobbers nested
|
||||
// objects (e.g. `k8s_options` would lose values set via setFieldValue),
|
||||
// so we go straight to the full store.
|
||||
const fullValues = form.getFieldsValue(true);
|
||||
|
||||
onFinish(
|
||||
normalizeOutgoing({
|
||||
...fullValues,
|
||||
...values,
|
||||
worker_config: {
|
||||
...workerConfig
|
||||
}
|
||||
@@ -203,11 +191,12 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
};
|
||||
},
|
||||
validateFields: async () => {
|
||||
// Run validation first to display any field errors. Then read the
|
||||
// FULL store via `getFieldsValue(true)` so values that were set via
|
||||
// setFieldValue on non-registered paths are still included in what we
|
||||
// hand to the API.
|
||||
await form.validateFields();
|
||||
try {
|
||||
await form.validateFields();
|
||||
} catch (e) {
|
||||
setSubmitAttempted(true);
|
||||
throw e;
|
||||
}
|
||||
const values = form.getFieldsValue(true);
|
||||
|
||||
const workerConfig = yaml2Json(
|
||||
@@ -223,108 +212,109 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
}
|
||||
}));
|
||||
|
||||
const handleOnFinishFailed = () => {
|
||||
setSubmitAttempted(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
name="clusterForm"
|
||||
form={form}
|
||||
onFinish={handleOnFinish}
|
||||
preserve={false}
|
||||
scrollToFirstError={true}
|
||||
initialValues={currentData}
|
||||
>
|
||||
<Form.Item<FormData>
|
||||
name="name"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage(
|
||||
{ id: 'common.form.rule.input' },
|
||||
{
|
||||
name: intl.formatMessage({ id: 'common.table.name' })
|
||||
}
|
||||
)
|
||||
}
|
||||
]}
|
||||
<FormContext.Provider value={{ submitAttempted }}>
|
||||
<Form
|
||||
name="clusterForm"
|
||||
form={form}
|
||||
onFinish={handleOnFinish}
|
||||
onFinishFailed={handleOnFinishFailed}
|
||||
preserve={false}
|
||||
scrollToFirstError={true}
|
||||
initialValues={currentData}
|
||||
>
|
||||
<CInput.Input
|
||||
label={intl.formatMessage({ id: 'common.table.name' })}
|
||||
required
|
||||
trim={false}
|
||||
></CInput.Input>
|
||||
</Form.Item>
|
||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||
{provider === ProviderValueMap.DigitalOcean && (
|
||||
<CloudProvider
|
||||
provider={provider}
|
||||
action={action}
|
||||
credentialID={currentData?.credential_id}
|
||||
credentialList={credentialList}
|
||||
></CloudProvider>
|
||||
)}
|
||||
<Form.Item<FormData>
|
||||
name="name"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage(
|
||||
{ id: 'common.form.rule.input' },
|
||||
{
|
||||
name: intl.formatMessage({ id: 'common.table.name' })
|
||||
}
|
||||
)
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CInput.Input
|
||||
label={intl.formatMessage({ id: 'common.table.name' })}
|
||||
required
|
||||
trim={false}
|
||||
></CInput.Input>
|
||||
</Form.Item>
|
||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||
{provider === ProviderValueMap.DigitalOcean && (
|
||||
<CloudProvider
|
||||
provider={provider}
|
||||
action={action}
|
||||
credentialID={currentData?.credential_id}
|
||||
credentialList={credentialList}
|
||||
></CloudProvider>
|
||||
)}
|
||||
|
||||
<Form.Item<FormData>
|
||||
name="description"
|
||||
rules={[{ required: false }]}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<SealTextArea
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
label={intl.formatMessage({ id: 'common.table.description' })}
|
||||
></SealTextArea>
|
||||
</Form.Item>
|
||||
<Form.Item<FormData>
|
||||
name="description"
|
||||
rules={[{ required: false }]}
|
||||
// For Kubernetes the cluster type selector follows directly, so
|
||||
// fall back to the default item margin (matching the name field)
|
||||
// to keep the description spacing symmetric; other providers keep
|
||||
// the tighter gap before the advanced panel.
|
||||
style={{
|
||||
marginBottom:
|
||||
provider === ProviderValueMap.Kubernetes ? undefined : 8
|
||||
}}
|
||||
>
|
||||
<SealTextArea
|
||||
scaleSize
|
||||
label={intl.formatMessage({ id: 'common.table.description' })}
|
||||
></SealTextArea>
|
||||
</Form.Item>
|
||||
|
||||
{provider === ProviderValueMap.Kubernetes && <ClusterTypeSelector />}
|
||||
|
||||
{provider === ProviderValueMap.Kubernetes && (
|
||||
<CollapsePanel
|
||||
accordion={false}
|
||||
activeKey={k8sActiveKey}
|
||||
onChange={(keys) =>
|
||||
setK8sActiveKey(Array.isArray(keys) ? keys : [keys])
|
||||
}
|
||||
activeKey={activeKey}
|
||||
onChange={handleOnCollapseChange}
|
||||
items={[
|
||||
{
|
||||
key: 'k8sOptions',
|
||||
label: intl.formatMessage({ id: 'clusters.k8sOptions.title' }),
|
||||
key: 'advanceConfig',
|
||||
label: intl.formatMessage({ id: 'resources.form.advanced' }),
|
||||
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>
|
||||
<>
|
||||
{provider === ProviderValueMap.Kubernetes && (
|
||||
<K8sAdvancedOptions
|
||||
key={currentData?.id ?? 'new'}
|
||||
action={action}
|
||||
></K8sAdvancedOptions>
|
||||
)}
|
||||
<AdvanceConfig
|
||||
action={action}
|
||||
provider={provider}
|
||||
currentData={currentData}
|
||||
ref={advanceConfigRef}
|
||||
></AdvanceConfig>
|
||||
</>
|
||||
)
|
||||
}
|
||||
]}
|
||||
></CollapsePanel>
|
||||
)}
|
||||
|
||||
<CollapsePanel
|
||||
accordion={false}
|
||||
activeKey={activeKey}
|
||||
onChange={handleOnCollapseChange}
|
||||
items={[
|
||||
{
|
||||
key: 'advanceConfig',
|
||||
label: intl.formatMessage({ id: 'resources.form.advanced' }),
|
||||
forceRender: true,
|
||||
children: (
|
||||
<AdvanceConfig
|
||||
action={action}
|
||||
provider={provider}
|
||||
currentData={currentData}
|
||||
ref={advanceConfigRef}
|
||||
></AdvanceConfig>
|
||||
)
|
||||
}
|
||||
]}
|
||||
></CollapsePanel>
|
||||
</Form>
|
||||
{provider === ProviderValueMap.Kubernetes && onK8sOptionsChange && (
|
||||
<K8sOptionsChangeWatcher
|
||||
action={action}
|
||||
currentData={currentData}
|
||||
onChange={onK8sOptionsChange}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</FormContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Input as CInput } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Divider } from 'antd';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { ImageCredential } from '../config/types';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface CredentialItemProps {
|
||||
item: ImageCredential;
|
||||
validated: boolean;
|
||||
index: number;
|
||||
onChange: (partial: Partial<ImageCredential>) => void;
|
||||
}
|
||||
|
||||
const CredentialItem: React.FC<CredentialItemProps> = ({
|
||||
item,
|
||||
index,
|
||||
validated,
|
||||
onChange
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const registryEmpty = !item.registry?.trim();
|
||||
const registryStatus =
|
||||
validated && registryEmpty ? ('error' as const) : ('success' as const);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
{index !== 0 && (
|
||||
<Divider style={{ marginBlock: 8 }} variant="dashed"></Divider>
|
||||
)}
|
||||
<CInput.Input
|
||||
required
|
||||
status={registryStatus}
|
||||
value={item.registry}
|
||||
onChange={(e) => onChange({ registry: e.target.value })}
|
||||
label={intl.formatMessage({
|
||||
id: 'clusters.imageCredentials.registry'
|
||||
})}
|
||||
/>
|
||||
<div className="row">
|
||||
<CInput.Input
|
||||
value={item.username}
|
||||
status="success"
|
||||
onChange={(e) => onChange({ username: e.target.value })}
|
||||
label={intl.formatMessage({
|
||||
id: 'clusters.imageCredentials.username'
|
||||
})}
|
||||
/>
|
||||
<CInput.Password
|
||||
status="success"
|
||||
value={item.password}
|
||||
onChange={(e) => onChange({ password: e.target.value })}
|
||||
label={intl.formatMessage({
|
||||
id: 'clusters.imageCredentials.password'
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialItem;
|
||||
@@ -19,7 +19,7 @@ const Container = styled.div`
|
||||
height: 168px;
|
||||
.left {
|
||||
padding: 16px 24px;
|
||||
width: 160px;
|
||||
width: 124px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -143,17 +143,23 @@ const ClusterBasic: React.FC<{ clusterId: number }> = ({ clusterId }) => {
|
||||
<span className="value">
|
||||
{clusterDetail.ready_workers}/{clusterDetail.workers}
|
||||
</span>
|
||||
<span className="label">Workers</span>
|
||||
<span className="label">
|
||||
{intl.formatMessage({ id: 'resources.nodes' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="item">
|
||||
<IconFont type="icon-rocket-launch-fill"></IconFont>
|
||||
<span className="value">{clusterDetail.models}</span>
|
||||
<span className="label">Deployments</span>
|
||||
<span className="label">
|
||||
{intl.formatMessage({ id: 'clusters.table.deployments' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="item">
|
||||
<IconFont type="icon-gpu"></IconFont>
|
||||
<span className="value">{clusterDetail.gpus}</span>
|
||||
<span className="label">GPUs</span>
|
||||
<span className="label">
|
||||
{intl.formatMessage({ id: 'menu.resources.gpus' })}
|
||||
</span>
|
||||
</div>
|
||||
</Resources>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CardWrapper } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Col, Progress, Row, Tag } from 'antd';
|
||||
import { round } from 'lodash';
|
||||
import React, { useEffect } from 'react';
|
||||
@@ -15,7 +16,7 @@ const Container = styled.div`
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-weight: 400;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
@@ -38,6 +39,7 @@ const Container = styled.div`
|
||||
|
||||
const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
|
||||
const { systemLoad, fetchClusterSystemLoad } = useClusterSystemLoad();
|
||||
const intl = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
if (clusterId) {
|
||||
@@ -74,14 +76,15 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
|
||||
};
|
||||
|
||||
console.log('systemLoad', systemLoad);
|
||||
|
||||
return (
|
||||
<Row gutter={16} style={{ marginTop: 24 }}>
|
||||
<Col span={6}>
|
||||
<CardWrapper style={{ padding: '16px', height: 120 }}>
|
||||
<Container>
|
||||
<div className="title">
|
||||
<span>GPU Utilization</span>
|
||||
<span>
|
||||
{intl.formatMessage({ id: 'dashboard.gpuutilization' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<div className="value">
|
||||
@@ -101,14 +104,16 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
|
||||
<CardWrapper style={{ padding: '16px', height: 120 }}>
|
||||
<Container>
|
||||
<div className="title">
|
||||
<span>VRAM Utilization</span>
|
||||
<span>
|
||||
{intl.formatMessage({ id: 'dashboard.vramutilization' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<div className="value">{`${round(systemLoad.current.vram, 1)}%`}</div>
|
||||
<div className="chart">
|
||||
{renderStepsProgress(round(systemLoad.current.vram, 1), {
|
||||
color: 'purple',
|
||||
text: 'VRAM'
|
||||
text: intl.formatMessage({ id: 'dashboard.vram' })
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,7 +124,9 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
|
||||
<CardWrapper style={{ padding: '16px', height: 120 }}>
|
||||
<Container>
|
||||
<div className="title">
|
||||
<span>CPU Utilization</span>
|
||||
<span>
|
||||
{intl.formatMessage({ id: 'dashboard.cpuutilization' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<div className="value">
|
||||
@@ -139,7 +146,9 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
|
||||
<CardWrapper style={{ padding: '16px', height: 120 }}>
|
||||
<Container>
|
||||
<div className="title">
|
||||
<span>Memo Utilization</span>
|
||||
<span>
|
||||
{intl.formatMessage({ id: 'dashboard.memoryutilization' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<div className="value">
|
||||
@@ -148,7 +157,7 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
|
||||
<div className="chart">
|
||||
{renderStepsProgress(round(systemLoad.current.ram, 1), {
|
||||
color: 'green',
|
||||
text: 'RAM'
|
||||
text: intl.formatMessage({ id: 'dashboard.memory' })
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { MetadataList, useAppUtils } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Form } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormContext } from '../config/form-context';
|
||||
import { ImageCredential as ImageCredentialType } from '../config/types';
|
||||
import imgCredentialStyle from '../styles/img-credential.less';
|
||||
import CredentialItem from './credential-item';
|
||||
|
||||
const FIELD_PATH = ['k8s_options', 'imageCredentials'];
|
||||
|
||||
const ImageCredential: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { getRuleMessage } = useAppUtils();
|
||||
const { submitAttempted } = useFormContext();
|
||||
const validated = !!submitAttempted;
|
||||
const form = Form.useFormInstance();
|
||||
const credentials: ImageCredentialType[] =
|
||||
Form.useWatch(FIELD_PATH, form) || [];
|
||||
|
||||
const updateList = (list: ImageCredentialType[]) => {
|
||||
form.setFieldValue(FIELD_PATH, list);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (validated) {
|
||||
form.validateFields([FIELD_PATH]).catch(() => {});
|
||||
}
|
||||
}, [credentials, validated]);
|
||||
|
||||
const handleAdd = () => {
|
||||
updateList([...credentials, { registry: '', username: '', password: '' }]);
|
||||
};
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
const next = [...credentials];
|
||||
next.splice(index, 1);
|
||||
updateList(next);
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
index: number,
|
||||
partial: Partial<ImageCredentialType>
|
||||
) => {
|
||||
const next = [...credentials];
|
||||
next[index] = { ...next[index], ...partial };
|
||||
updateList(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={imgCredentialStyle.container}>
|
||||
<Form.Item
|
||||
name={FIELD_PATH}
|
||||
style={{ marginTop: 24 }}
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value: ImageCredentialType[]) => {
|
||||
if (!value?.length) return;
|
||||
const hasMissingRegistry = value.some(
|
||||
(item) => !item?.registry?.trim()
|
||||
);
|
||||
if (hasMissingRegistry) {
|
||||
throw new Error(
|
||||
getRuleMessage('input', 'clusters.imageCredentials.registry')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MetadataList
|
||||
label={intl.formatMessage({ id: 'clusters.imageCredentials.title' })}
|
||||
btnText={intl.formatMessage({ id: 'clusters.imageCredentials.add' })}
|
||||
dataList={credentials}
|
||||
onAdd={handleAdd}
|
||||
onDelete={handleDelete}
|
||||
styles={{
|
||||
delBtn: {
|
||||
marginTop: 44
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(item: ImageCredentialType, index: number) => (
|
||||
<CredentialItem
|
||||
item={item}
|
||||
index={index}
|
||||
key={index}
|
||||
validated={validated}
|
||||
onChange={(partial) => handleChange(index, partial)}
|
||||
/>
|
||||
)}
|
||||
</MetadataList>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageCredential;
|
||||
@@ -1,177 +1,34 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import {
|
||||
MinusOutlined,
|
||||
PlusOutlined,
|
||||
QuestionCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { Input as CInput, LabelSelector, useAppUtils } from '@gpustack/core-ui';
|
||||
import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, Form, Switch, Tooltip } from 'antd';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Form } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import React, { useEffect, useId } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { GpuInstanceOptions } from '../config/types';
|
||||
import { ClusterListItem as ListItem } from '../config/types';
|
||||
import ImageCredential from './image-credential';
|
||||
import K8SVolumeMount from './k8s-volume-mount';
|
||||
|
||||
const Title = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: transparent;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
padding-top: 0px;
|
||||
padding-bottom: 8px;
|
||||
`;
|
||||
|
||||
const SectionWrap = styled.div`
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const ImageCredentialsForm: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { getRuleMessage } = useAppUtils();
|
||||
|
||||
return (
|
||||
<SectionWrap>
|
||||
<Form.List name={['k8s_options', 'imageCredentials']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Title>
|
||||
<div className="flex-center gap-8">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
id: 'clusters.imageCredentials.title'
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() =>
|
||||
add({ registry: '', username: '', password: '' })
|
||||
}
|
||||
>
|
||||
<PlusOutlined />{' '}
|
||||
{intl.formatMessage({
|
||||
id: 'clusters.imageCredentials.add'
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</Title>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{fields.map(({ key, name }) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
border: '1px solid var(--ant-color-split)',
|
||||
borderRadius: 'var(--ant-border-radius-lg)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name={[name, 'registry']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: getRuleMessage(
|
||||
'input',
|
||||
'clusters.imageCredentials.registry'
|
||||
)
|
||||
}
|
||||
]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<CInput.Input
|
||||
required
|
||||
label={intl.formatMessage({
|
||||
id: 'clusters.imageCredentials.registry'
|
||||
})}
|
||||
></CInput.Input>
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item
|
||||
name={[name, 'username']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<CInput.Input
|
||||
label={intl.formatMessage({
|
||||
id: 'clusters.imageCredentials.username'
|
||||
})}
|
||||
></CInput.Input>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item
|
||||
name={[name, 'password']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<CInput.Password
|
||||
label={intl.formatMessage({
|
||||
id: 'clusters.imageCredentials.password'
|
||||
})}
|
||||
></CInput.Password>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
shape="circle"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => remove(name)}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</SectionWrap>
|
||||
);
|
||||
};
|
||||
|
||||
const NodeSelectorForm: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<SectionWrap>
|
||||
<Title>
|
||||
<span className="flex-center gap-4">
|
||||
<span>
|
||||
{intl.formatMessage({ id: 'clusters.nodeSelector.title' })}
|
||||
</span>
|
||||
<Tooltip
|
||||
title={intl.formatMessage({ id: 'clusters.nodeSelector.tip' })}
|
||||
>
|
||||
<QuestionCircleOutlined
|
||||
style={{ color: 'var(--ant-color-text-secondary)' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</Title>
|
||||
<Form.Item name={['k8s_options', 'nodeSelector']}>
|
||||
<LabelSelector
|
||||
label={intl.formatMessage({ id: 'clusters.nodeSelector.title' })}
|
||||
description={intl.formatMessage({ id: 'clusters.nodeSelector.tip' })}
|
||||
></LabelSelector>
|
||||
</Form.Item>
|
||||
</SectionWrap>
|
||||
);
|
||||
};
|
||||
|
||||
// Render namespace. Kept as the first field of the section so the most
|
||||
// fundamental K8s deployment knob is set before the rest.
|
||||
const NamespaceForm: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -180,6 +37,7 @@ const NamespaceForm: React.FC = () => {
|
||||
<Form.Item
|
||||
name={['k8s_options', 'namespace']}
|
||||
style={{ marginBottom: 0 }}
|
||||
normalize={(value) => value || null}
|
||||
>
|
||||
<CInput.Input
|
||||
label={intl.formatMessage({ id: 'clusters.namespace.title' })}
|
||||
@@ -191,9 +49,7 @@ const NamespaceForm: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 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 = () => {
|
||||
export const OperatorImageForm: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
@@ -201,6 +57,7 @@ const OperatorImageForm: React.FC = () => {
|
||||
<Form.Item
|
||||
name={['k8s_options', 'operatorImage']}
|
||||
style={{ marginBottom: 0 }}
|
||||
normalize={(value) => value || null}
|
||||
>
|
||||
<CInput.Input
|
||||
label={intl.formatMessage({ id: 'clusters.operatorImage.title' })}
|
||||
@@ -211,92 +68,215 @@ const OperatorImageForm: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 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 }) => {
|
||||
// The presence of `gpuInstanceOptions` on `k8s_options` is the source of truth
|
||||
// for whether GPU instances are enabled. Both the cluster-type selector
|
||||
// (rendered up top) and the static-address field (rendered in the advanced
|
||||
// section) watch this same path so they stay in sync without sharing local
|
||||
// state.
|
||||
const GPU_INSTANCE_OPTIONS_PATH = ['k8s_options', 'gpuInstanceOptions'];
|
||||
|
||||
// Visual parity with @gpustack/core-ui's SwitchCard so the selector blends
|
||||
// in with surrounding form fields: same border, radius, padding, and
|
||||
// typography. The only differences are the two-column grid layout and an
|
||||
// active state (blue border + tinted background) to mark the selection.
|
||||
const ClusterTypeWrap = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const ClusterTypeLabel = styled.div`
|
||||
color: var(--ant-color-text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
.required {
|
||||
color: var(--ant-color-error);
|
||||
margin-left: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ClusterTypeGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const ClusterTypeCard = styled.div<{ $active: boolean }>`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
border: 1px solid
|
||||
${(p) =>
|
||||
p.$active ? 'var(--ant-color-primary)' : 'var(--ant-color-border)'};
|
||||
background: ${(p) =>
|
||||
p.$active ? 'var(--ant-color-primary-bg)' : 'transparent'};
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background-color 0.2s;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--ant-control-outline);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.title {
|
||||
color: var(--ant-color-text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.description {
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
`;
|
||||
|
||||
const RadioDot = styled.span<{ $active: boolean }>`
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 3px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid
|
||||
${(p) =>
|
||||
p.$active ? 'var(--ant-color-primary)' : 'var(--ant-color-border)'};
|
||||
background: ${(p) =>
|
||||
p.$active ? 'var(--ant-color-primary)' : 'transparent'};
|
||||
transition: all 0.2s;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
opacity: ${(p) => (p.$active ? 1 : 0)};
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
`;
|
||||
|
||||
// Card-based selector for cluster type. The two options are mutually exclusive
|
||||
// and the choice maps directly to the presence/absence of `gpuInstanceOptions`
|
||||
// on the form — "model" clears it, "gpu" seeds it to {} (preserving any
|
||||
// already-entered static address). No standalone form field is registered;
|
||||
// state is read via useWatch with `preserve: true` so it tracks updates made
|
||||
// through setFieldValue.
|
||||
export const ClusterTypeSelector: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const form = Form.useFormInstance();
|
||||
const [enabled, setEnabled] = useState<boolean>(!!initialValue);
|
||||
const [address, setAddress] = useState<string>(
|
||||
initialValue?.gpuInstancesAccessStaticAddress || ''
|
||||
const labelId = useId();
|
||||
const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
|
||||
form,
|
||||
preserve: true
|
||||
});
|
||||
const value: 'model' | 'gpu' = gpuInstanceOptions ? 'gpu' : 'model';
|
||||
|
||||
const handleSelect = (next: 'model' | 'gpu') => {
|
||||
if (!form || next === value) return;
|
||||
if (next === 'gpu') {
|
||||
form.setFieldValue(
|
||||
GPU_INSTANCE_OPTIONS_PATH,
|
||||
form.getFieldValue(GPU_INSTANCE_OPTIONS_PATH) ?? {}
|
||||
);
|
||||
} else {
|
||||
form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const options: {
|
||||
key: 'model' | 'gpu';
|
||||
title: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
key: 'model',
|
||||
title: intl.formatMessage({ id: 'clusters.modelService.title' }),
|
||||
description: intl.formatMessage({ id: 'clusters.modelService.tip' })
|
||||
},
|
||||
{
|
||||
key: 'gpu',
|
||||
title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }),
|
||||
description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' })
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ClusterTypeWrap>
|
||||
<ClusterTypeLabel id={labelId}>
|
||||
{intl.formatMessage({ id: 'clusters.clusterType.title' })}
|
||||
<span className="required">*</span>
|
||||
</ClusterTypeLabel>
|
||||
<ClusterTypeGrid role="radiogroup" aria-labelledby={labelId}>
|
||||
{options.map((opt) => {
|
||||
const active = value === opt.key;
|
||||
return (
|
||||
<ClusterTypeCard
|
||||
key={opt.key}
|
||||
$active={active}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelect(opt.key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect(opt.key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RadioDot $active={active} />
|
||||
<div className="body">
|
||||
<div className="title">{opt.title}</div>
|
||||
<div className="description">{opt.description}</div>
|
||||
</div>
|
||||
</ClusterTypeCard>
|
||||
);
|
||||
})}
|
||||
</ClusterTypeGrid>
|
||||
</ClusterTypeWrap>
|
||||
);
|
||||
const initializedRef = useRef<boolean>(!!initialValue);
|
||||
};
|
||||
|
||||
const writeForm = (en: boolean, addr: string) => {
|
||||
form.setFieldValue(
|
||||
['k8s_options', 'gpuInstanceOptions'],
|
||||
en ? { gpuInstancesAccessStaticAddress: addr } : undefined
|
||||
);
|
||||
};
|
||||
// Static access address for GPU instances. Only shown when "GPU 服务" is
|
||||
// the selected cluster type. Rendered in the advanced section, between the
|
||||
// default container registry and the worker config (节点配置).
|
||||
export const GpuInstancesStaticAddressForm: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
// See note in ClusterTypeSelector: watch the full store so this field's
|
||||
// visibility tracks the selector even before it has mounted its own
|
||||
// Form.Item.
|
||||
const enabled = !!Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
|
||||
preserve: true
|
||||
});
|
||||
|
||||
// Mirror a seeded initial value into the form on mount so submit collects it.
|
||||
useEffect(() => {
|
||||
if (initialValue) {
|
||||
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Adopt currentData arriving after mount (async edit load), once. After the
|
||||
// user has interacted (`initializedRef`), local state owns the section.
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
if (initialValue) {
|
||||
setEnabled(true);
|
||||
setAddress(initialValue.gpuInstancesAccessStaticAddress || '');
|
||||
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
|
||||
initializedRef.current = true;
|
||||
}
|
||||
}, [initialValue]);
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
initializedRef.current = true;
|
||||
setEnabled(checked);
|
||||
if (!checked) {
|
||||
setAddress('');
|
||||
}
|
||||
writeForm(checked, checked ? address : '');
|
||||
};
|
||||
|
||||
const handleAddressChange = (e: any) => {
|
||||
const next = typeof e === 'string' ? e : (e?.target?.value ?? '');
|
||||
setAddress(next);
|
||||
writeForm(true, next);
|
||||
};
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionWrap>
|
||||
<Title>
|
||||
<div className="flex-center gap-8">
|
||||
<span className="flex-center gap-4">
|
||||
<span>
|
||||
{intl.formatMessage({ id: 'clusters.gpuInstances.title' })}
|
||||
</span>
|
||||
<Tooltip
|
||||
title={intl.formatMessage({ id: 'clusters.gpuInstances.tip' })}
|
||||
>
|
||||
<QuestionCircleOutlined
|
||||
style={{ color: 'var(--ant-color-text-secondary)' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Switch checked={enabled} onChange={handleToggle} />
|
||||
</div>
|
||||
</Title>
|
||||
{enabled && (
|
||||
<Form.Item
|
||||
name={[
|
||||
'k8s_options',
|
||||
'gpuInstanceOptions',
|
||||
'gpuInstancesAccessStaticAddress'
|
||||
]}
|
||||
style={{ marginBottom: 0 }}
|
||||
normalize={(value) => value || null}
|
||||
>
|
||||
<CInput.Input
|
||||
isInFormItems={false}
|
||||
value={address}
|
||||
onChange={handleAddressChange}
|
||||
label={intl.formatMessage({
|
||||
id: 'clusters.gpuInstances.staticAddress'
|
||||
})}
|
||||
@@ -304,25 +284,82 @@ const GpuInstanceOptionsForm: React.FC<{
|
||||
id: 'clusters.gpuInstances.staticAddress.tip'
|
||||
})}
|
||||
></CInput.Input>
|
||||
)}
|
||||
</Form.Item>
|
||||
</SectionWrap>
|
||||
);
|
||||
};
|
||||
|
||||
const K8sPodSpec: React.FC<{
|
||||
// Strip UI-only / undefined-valued noise so two k8s_options snapshots compare
|
||||
// on real content. `sourceType` is derived from `volumeSource` purely for the
|
||||
// volume-mount UI (see cluster-form init), and the JSON round-trip drops
|
||||
// undefined-valued keys so a missing key and `key: undefined` compare equal.
|
||||
const cleanK8sOptions = (opts: any) => {
|
||||
const cloned = _.cloneDeep(opts || {});
|
||||
if (Array.isArray(cloned.volumeMounts)) {
|
||||
cloned.volumeMounts = cloned.volumeMounts.map(
|
||||
({ sourceType, ...rest }: any) => rest
|
||||
);
|
||||
}
|
||||
// The edit form always seeds k8s_options.volumeMounts to [] even when the
|
||||
// saved cluster had no value, so an absent field would otherwise read as a
|
||||
// change the moment the drawer opens. Drop empty top-level arrays on both
|
||||
// sides: "absent" and "empty list" both mean nothing configured. A
|
||||
// non-empty -> empty edit is still detected, since only the empty side drops.
|
||||
Object.keys(cloned).forEach((key) => {
|
||||
if (Array.isArray(cloned[key]) && cloned[key].length === 0) {
|
||||
delete cloned[key];
|
||||
}
|
||||
});
|
||||
return JSON.parse(JSON.stringify(cloned));
|
||||
};
|
||||
|
||||
// Headless watcher: in EDIT mode it reports (via onChange) whether the user has
|
||||
// changed any k8s_options field from the cluster's saved values. It renders
|
||||
// nothing — the notice itself is shown in the form footer, above Save/Cancel
|
||||
// (see cluster-create.tsx), mirroring the model edit interaction. Must be
|
||||
// mounted inside the cluster <Form> so the watch reads the form store.
|
||||
export const K8sOptionsChangeWatcher: React.FC<{
|
||||
action: PageActionType;
|
||||
initialGpuInstanceOptions?: GpuInstanceOptions;
|
||||
}> = ({ action, initialGpuInstanceOptions }) => {
|
||||
currentData?: ListItem;
|
||||
onChange: (changed: boolean) => void;
|
||||
}> = ({ action, currentData, onChange }) => {
|
||||
// `preserve: true` so the watch tracks the full store, including
|
||||
// gpuInstanceOptions which is toggled via setFieldValue without a mounted
|
||||
// Form.Item (mirrors ClusterTypeSelector).
|
||||
const k8sOptions = Form.useWatch(['k8s_options'], { preserve: true });
|
||||
|
||||
const changed =
|
||||
action === PageAction.EDIT &&
|
||||
!_.isEqual(
|
||||
cleanK8sOptions(currentData?.k8s_options),
|
||||
cleanK8sOptions(k8sOptions)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(changed);
|
||||
}, [changed, onChange]);
|
||||
|
||||
// Clear the footer notice when this form unmounts (e.g. switching steps or
|
||||
// provider) so a stale warning never lingers over the buttons.
|
||||
useEffect(() => {
|
||||
return () => onChange(false);
|
||||
}, [onChange]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Kubernetes-specific options that live inside the cluster's advanced section.
|
||||
const K8sAdvancedOptions: React.FC<{
|
||||
action: PageActionType;
|
||||
}> = ({ action }) => {
|
||||
return (
|
||||
<>
|
||||
<NamespaceForm />
|
||||
<K8SVolumeMount action={action}></K8SVolumeMount>
|
||||
<ImageCredentialsForm />
|
||||
<ImageCredential />
|
||||
<NodeSelectorForm />
|
||||
<OperatorImageForm />
|
||||
<GpuInstanceOptionsForm initialValue={initialGpuInstanceOptions} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default K8sPodSpec;
|
||||
export default K8sAdvancedOptions;
|
||||
|
||||
@@ -40,7 +40,7 @@ const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => {
|
||||
const k8sVolumeMounts = Form.useWatch(['k8s_options', 'volumeMounts'], form);
|
||||
|
||||
const [collapseKey, setCollapseKey] = useState<Set<number | string>>(
|
||||
new Set([0])
|
||||
new Set()
|
||||
);
|
||||
|
||||
const volumeList = [
|
||||
@@ -169,7 +169,9 @@ const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => {
|
||||
open={collapseKey.has(name)}
|
||||
onToggle={(open: boolean) => onToggle(open, name)}
|
||||
styles={{
|
||||
body: collapseKey.has(name) ? { padding: 16 } : {},
|
||||
body: collapseKey.has(name)
|
||||
? { paddingBlock: '16px 0', paddingInline: 16 }
|
||||
: {},
|
||||
content: { paddingTop: 0 },
|
||||
header: {
|
||||
backgroundColor: 'unset'
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { ClusterListItem } from './types';
|
||||
|
||||
// for cluster form
|
||||
interface FormContextProps {
|
||||
currentData?: ClusterListItem;
|
||||
submitAttempted?: boolean;
|
||||
}
|
||||
|
||||
export const FormContext = createContext<FormContextProps>({});
|
||||
|
||||
export const useFormContext = () => useContext(FormContext);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { workerAddedCountAtom } from '@/atoms/clusters';
|
||||
import useSetChunkRequest from '@/hooks/use-chunk-request';
|
||||
import useUpdateChunkedList from '@/hooks/use-update-chunk-list';
|
||||
import useQueryWorkerList from '@/pages/resources/services/use-query-worker-list';
|
||||
import { useAtom } from 'jotai';
|
||||
import _ from 'lodash';
|
||||
import qs from 'query-string';
|
||||
@@ -13,7 +14,31 @@ export default function useAddWorkerMessage() {
|
||||
const [addedCount, setAddedCount] = useState(0);
|
||||
const timerRef = useRef<any>(null);
|
||||
const triggerAtRef = useRef<number>(0);
|
||||
const existingIdsRef = useRef<Set<string | number>>(new Set());
|
||||
const snapshotReceivedRef = useRef<boolean>(false);
|
||||
const [, setWorkerAddedCount] = useAtom(workerAddedCountAtom);
|
||||
// fetchData auto-cancels any in-flight request on each call and on unmount,
|
||||
// so a stale seed from a previous open/cluster can't leak in
|
||||
const { fetchData: fetchWorkerList, cancelRequest: cancelWorkerListRequest } =
|
||||
useQueryWorkerList();
|
||||
|
||||
const isNewWorker = (item: any) => {
|
||||
console.log(
|
||||
'isNewWorker item:',
|
||||
item,
|
||||
existingIdsRef.current,
|
||||
snapshotReceivedRef.current
|
||||
);
|
||||
if (item?.id == null) {
|
||||
return false;
|
||||
}
|
||||
if (existingIdsRef.current.has(item.id)) {
|
||||
return false;
|
||||
}
|
||||
existingIdsRef.current.add(item.id);
|
||||
// before the full snapshot has been received, every item is pre-existing
|
||||
return snapshotReceivedRef.current;
|
||||
};
|
||||
|
||||
const updateAddedCount = (count: number) => {
|
||||
setAddedCount(count);
|
||||
@@ -30,8 +55,9 @@ export default function useAddWorkerMessage() {
|
||||
const { updateChunkedList } = useUpdateChunkedList({
|
||||
events: ['CREATE', 'INSERT'],
|
||||
dataList: [],
|
||||
triggerAt: triggerAtRef,
|
||||
isNewItem: isNewWorker,
|
||||
onCreate: (newItems: any) => {
|
||||
console.log('onCreate newItems:', newItems, triggerAtRef.current);
|
||||
if (triggerAtRef.current) {
|
||||
newItemsRef.current = newItemsRef.current.concat(newItems);
|
||||
showAddWorkerMessage();
|
||||
@@ -49,6 +75,27 @@ export default function useAddWorkerMessage() {
|
||||
_.each(list, (data: any) => {
|
||||
updateChunkedList(data);
|
||||
});
|
||||
// fallback: if seeding the baseline failed, the first watch chunk marks the
|
||||
// snapshot as received (only reliable when there is at least one worker)
|
||||
snapshotReceivedRef.current = true;
|
||||
};
|
||||
|
||||
// Seed the baseline set of already-existing worker ids via REST before the
|
||||
// watch starts. Relying on the watch's first chunk fails when a cluster has
|
||||
// zero workers (no CREATE event is sent, so the snapshot flag never flips and
|
||||
// genuinely new workers get misclassified as pre-existing).
|
||||
const seedExistingWorkers = async (params: Record<string, any>) => {
|
||||
try {
|
||||
const items = await fetchWorkerList({ ...params, page: -1 } as any);
|
||||
(items || []).forEach((item: any) => {
|
||||
if (item?.id != null) {
|
||||
existingIdsRef.current.add(item.id);
|
||||
}
|
||||
});
|
||||
snapshotReceivedRef.current = true;
|
||||
} catch (error) {
|
||||
// ignore: fall back to the first watch chunk (see updateHandler)
|
||||
}
|
||||
};
|
||||
|
||||
const resetAddedCount = () => {
|
||||
@@ -56,11 +103,18 @@ export default function useAddWorkerMessage() {
|
||||
chunkRequestRef.current?.current?.cancel?.();
|
||||
newItemsRef.current = [];
|
||||
triggerAtRef.current = 0;
|
||||
existingIdsRef.current = new Set();
|
||||
snapshotReceivedRef.current = false;
|
||||
// cancel any in-flight seed request
|
||||
cancelWorkerListRequest();
|
||||
clearTimeout(timerRef.current);
|
||||
};
|
||||
|
||||
const createModelsChunkRequest = async (params = {}) => {
|
||||
resetAddedCount();
|
||||
// seed the baseline before watching so new workers are detected even when
|
||||
// the cluster currently has zero workers
|
||||
await seedExistingWorkers(params);
|
||||
try {
|
||||
chunkRequestRef.current = setChunkRequest({
|
||||
url: `${WORKERS_API}?${qs.stringify(_.pickBy(params, (val: any) => !!val))}`,
|
||||
|
||||
@@ -19,7 +19,12 @@ export default function useClusterList() {
|
||||
|
||||
const fetchClusterList = async () => {
|
||||
try {
|
||||
const res = await queryClusterList({ page: -1 });
|
||||
const res = await queryClusterList(
|
||||
{ page: -1 },
|
||||
{
|
||||
skipErrorHandler: true
|
||||
}
|
||||
);
|
||||
const list = res?.items?.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
@@ -35,7 +40,12 @@ export default function useClusterList() {
|
||||
|
||||
const fetchWorkerList = async () => {
|
||||
try {
|
||||
const res = await queryWorkersList({ page: -1 });
|
||||
const res = await queryWorkersList(
|
||||
{ page: -1 },
|
||||
{
|
||||
skipErrorHandler: true
|
||||
}
|
||||
);
|
||||
const list = res?.items?.map((item: any) => ({
|
||||
cluster_id: item.cluster_id,
|
||||
state: item.state,
|
||||
|
||||
@@ -20,7 +20,7 @@ const useCredentialColumns = (
|
||||
dataIndex: 'name',
|
||||
sorter: tableSorter(1),
|
||||
render: (text: string) => (
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
<AutoTooltip ghost minWidth={20} title={text}>
|
||||
<span className="text-primary">{text}</span>
|
||||
</AutoTooltip>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import useUserSettings from '@/hooks/use-user-settings';
|
||||
import { Input as CInput, IconFont } from '@gpustack/core-ui';
|
||||
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { AlertBlockInfo, Input as CInput, IconFont } from '@gpustack/core-ui';
|
||||
import { YamlEditor } from '@gpustack/core-ui/yaml-editor';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, Form } from 'antd';
|
||||
import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
GpuInstancesStaticAddressForm,
|
||||
OperatorImageForm
|
||||
} from '../components/k8s-pod-spec';
|
||||
import { ProviderType, ProviderValueMap } from '../config';
|
||||
import {
|
||||
ClusterFormData as FormData,
|
||||
@@ -20,7 +25,6 @@ const Title = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--ant-color-bg-container);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
padding-top: 0px;
|
||||
@@ -95,6 +99,7 @@ const ClusterAdvanceConfig: React.FC<{
|
||||
<Form.Item<FormData>
|
||||
name="system_default_container_registry"
|
||||
style={{ marginBottom: 16 }}
|
||||
normalize={(value) => value?.trim?.() || null}
|
||||
>
|
||||
<CInput.Input
|
||||
label={intl.formatMessage({
|
||||
@@ -106,9 +111,23 @@ const ClusterAdvanceConfig: React.FC<{
|
||||
placeholder="docker.io"
|
||||
></CInput.Input>
|
||||
</Form.Item>
|
||||
{provider === ProviderValueMap.Kubernetes && (
|
||||
<>
|
||||
<OperatorImageForm />
|
||||
<GpuInstancesStaticAddressForm />
|
||||
</>
|
||||
)}
|
||||
<Title>
|
||||
{intl.formatMessage({ id: 'clusters.create.workerConfig' })}
|
||||
</Title>
|
||||
{action === PageAction.EDIT && (
|
||||
<AlertBlockInfo
|
||||
type="warning"
|
||||
style={{ marginBottom: 8 }}
|
||||
icon={<ExclamationCircleFilled />}
|
||||
message={intl.formatMessage({ id: 'clusters.edit.workerConfig.tip' })}
|
||||
></AlertBlockInfo>
|
||||
)}
|
||||
<YamlEditor
|
||||
ref={editorRef}
|
||||
isDarkTheme={isDarkTheme}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.container {
|
||||
:global(.item-container) {
|
||||
align-items: flex-start;
|
||||
|
||||
&:nth-child(2) {
|
||||
:global(.btn) {
|
||||
margin-top: 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,16 @@ const ActiveTable = () => {
|
||||
ellipsis: true,
|
||||
render: (text: any, record: any) => {
|
||||
return (
|
||||
<AutoTooltip ghost>
|
||||
<AutoTooltip
|
||||
ghost
|
||||
title={
|
||||
<span>
|
||||
{record.provider_name
|
||||
? `${record.provider_name}/${text}`
|
||||
: text}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="text-primary">
|
||||
{record.provider_name ? `${record.provider_name}/${text}` : text}
|
||||
</span>
|
||||
|
||||
@@ -117,7 +117,7 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
|
||||
showSearch
|
||||
mode="multiple"
|
||||
options={userList}
|
||||
maxTagCount={0}
|
||||
maxTagCount={'responsive'}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'dashboard.usage.selectuser'
|
||||
})}
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
|
||||
firstAddCluster: true
|
||||
});
|
||||
|
||||
navigate(`/cluster-management/clusters/list`);
|
||||
navigate(`/resources/clusters/list`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
|
||||
firstAddWorker: true,
|
||||
firstAddCluster: false
|
||||
});
|
||||
navigate(`/cluster-management/clusters/list`);
|
||||
navigate(`/resources/clusters/list`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -75,6 +75,18 @@ export async function deleteGPUServiceInstance(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopGPUServiceInstance(id: number) {
|
||||
return request(`${GPU_SERVICE_INSTANCES_API}/${id}/stop`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
export async function startGPUServiceInstance(id: number) {
|
||||
return request(`${GPU_SERVICE_INSTANCES_API}/${id}/start`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
// =========== Instance Types ===========
|
||||
|
||||
export async function queryGPUServiceInstanceTypes(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
|
||||
import Separator from '@/pages/llmodels/components/separator';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Empty, Input, Typography } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||
import useQueryTemplates from '../../templates/services/use-query-templates';
|
||||
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
||||
@@ -97,8 +98,58 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
} = useQueryInstanceTypes();
|
||||
const { detailData: templatesData, fetchData: fetchTemplates } =
|
||||
useQueryTemplates();
|
||||
const { clusterList, fetchClusterList } = useQueryClusterList();
|
||||
// Set by the create-scope picker (admin "All" view) via onScopeChange.
|
||||
// undefined = no picker (org context) → no client-side scoping.
|
||||
const [scopeOrgId, setScopeOrgId] = useState<number | null | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const templateList = templatesData?.items || [];
|
||||
|
||||
// A GPU instance is scheduled on the chosen instance type's cluster, and
|
||||
// its owner is that cluster's owner. So when a platform admin targets an
|
||||
// org, restrict each instance type's candidates to clusters that org owns
|
||||
// (dropping tiers/types left with none). Header-independent: filters the
|
||||
// fetched list client-side, so it doesn't rely on the request scope.
|
||||
const filterTypesByOwner = (
|
||||
types: InstanceTypeItem[],
|
||||
clusters: Array<{
|
||||
id?: number;
|
||||
value?: number;
|
||||
owner_principal_id?: number;
|
||||
}>,
|
||||
orgId?: number | null
|
||||
): InstanceTypeItem[] => {
|
||||
if (orgId == null) return types;
|
||||
const owned = new Set(
|
||||
(clusters || [])
|
||||
.filter((c) => c.owner_principal_id === orgId)
|
||||
.map((c) => c.id ?? c.value)
|
||||
);
|
||||
return types
|
||||
.map((it) => ({
|
||||
...it,
|
||||
status: {
|
||||
...it.status,
|
||||
tiers: (it.status?.tiers ?? [])
|
||||
.map((tier: any) => ({
|
||||
...tier,
|
||||
candidates: (tier.candidates ?? []).filter((c: any) =>
|
||||
owned.has(Number(c.cluster))
|
||||
)
|
||||
}))
|
||||
.filter((tier: any) => (tier.candidates ?? []).length > 0)
|
||||
}
|
||||
}))
|
||||
.filter((it) => (it.status?.tiers ?? []).length > 0);
|
||||
};
|
||||
|
||||
const ownedInstanceTypes = useMemo(
|
||||
() => filterTypesByOwner(instanceTypeList, clusterList as any, scopeOrgId),
|
||||
|
||||
[instanceTypeList, clusterList, scopeOrgId]
|
||||
);
|
||||
// const readonly = action === PageAction.VIEW;
|
||||
const readonly = false;
|
||||
const isRecreate = realAction === PageAction.CREATE;
|
||||
@@ -166,7 +217,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
): InstanceTypeItem | undefined => {
|
||||
if (!candidateName) return undefined;
|
||||
return instanceTypes.find((item) =>
|
||||
(item.status?.acceleratorTiers ?? []).some((tier) =>
|
||||
(item.status?.tiers ?? []).some((tier) =>
|
||||
(tier.candidates ?? []).some(
|
||||
(c) => c.name === candidateName && Number(c.cluster) === clusterId
|
||||
)
|
||||
@@ -177,7 +228,13 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
// initial for first
|
||||
const applyAutoSelection = (
|
||||
instanceTypes: InstanceTypeItem[],
|
||||
templates: TemplateItem[]
|
||||
templates: TemplateItem[],
|
||||
clusters?: Array<{
|
||||
id?: number;
|
||||
value?: number;
|
||||
owner_principal_id?: number;
|
||||
}>,
|
||||
orgId?: number | null
|
||||
) => {
|
||||
// On edit / view, surface the persisted selection in the card list.
|
||||
if (!shouldAutoSelectResource) {
|
||||
@@ -195,9 +252,24 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const first = instanceTypes.find((item) => !item.disabled);
|
||||
// Scope to clusters the chosen org owns (admin "All" view).
|
||||
const owned = filterTypesByOwner(instanceTypes, clusters || [], orgId);
|
||||
const first = owned.find((item) => !item.disabled);
|
||||
|
||||
if (!first) return;
|
||||
if (!first) {
|
||||
// The chosen org has no clusters (hence no instance types). Clear any
|
||||
// prior pick so a stale instance type / cross-org cluster isn't left
|
||||
// on the form.
|
||||
setInstanceTypeSelection({
|
||||
instanceType: undefined,
|
||||
manufacturer: undefined
|
||||
});
|
||||
setTemplateId(undefined);
|
||||
form.current?.applyInstanceType?.(undefined);
|
||||
form.current?.setFieldValue?.('clusterId', null);
|
||||
form.current?.setFieldValue?.(['spec', 'type'], undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// On create, auto-select the first instance type in the list
|
||||
|
||||
@@ -209,6 +281,52 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
applySelection(first, template);
|
||||
};
|
||||
|
||||
// Fetch the (tenant-scoped) instance types + templates and auto-select.
|
||||
// The query hook cancels any in-flight request on each new call, so when
|
||||
// this runs twice in quick succession (drawer open, then the scope
|
||||
// picker settling on its default) the latest scope's result wins.
|
||||
const loadCreateResources = (orgId?: number | null) => {
|
||||
const session = ++sessionRef.current;
|
||||
Promise.all([
|
||||
fetchData({ page: -1 }),
|
||||
fetchTemplates({ page: -1 }),
|
||||
fetchClusterList({ page: -1 })
|
||||
]).then(([instanceResItems, templatesRes, clusters]) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
applyAutoSelection(
|
||||
instanceResItems || [],
|
||||
templatesRes?.items || [],
|
||||
(Array.isArray(clusters) ? clusters : (clusters as any)?.items) || [],
|
||||
orgId
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Platform admin retargeted the create to another org (or Global). The
|
||||
// instance-type / cluster offerings are tenant-scoped, so drop the
|
||||
// current pick and reload for the new scope. The request interceptor
|
||||
// already carries the new org header by the time this fires.
|
||||
const handleScopeChange = (orgId?: number | null) => {
|
||||
if (!open || action !== PageAction.CREATE) return;
|
||||
setScopeOrgId(orgId);
|
||||
setInstanceTypeSelection({
|
||||
instanceType: undefined,
|
||||
manufacturer: undefined
|
||||
});
|
||||
setTemplateId(undefined);
|
||||
// Also clear the instance-type-derived form state (the selected type
|
||||
// card + its limits, the cluster, and spec.type). The cluster decides
|
||||
// where the instance is scheduled, so a stale pick from the previous
|
||||
// scope must not survive — otherwise an instance owned by the newly
|
||||
// chosen org could land on the old org's cluster. The reload's
|
||||
// owner-scoped auto-selection re-fills them from the new org, or leaves
|
||||
// them empty (blocking submit) when the chosen org has no clusters.
|
||||
form.current?.applyInstanceType?.(undefined);
|
||||
form.current?.setFieldValue?.('clusterId', null);
|
||||
form.current?.setFieldValue?.(['spec', 'type'], undefined);
|
||||
loadCreateResources(orgId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
sessionRef.current += 1;
|
||||
@@ -219,25 +337,27 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
setTemplateId(undefined);
|
||||
setInstanceKeyword('');
|
||||
setTemplateKeyword('');
|
||||
setScopeOrgId(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === PageAction.CREATE) {
|
||||
const session = ++sessionRef.current;
|
||||
Promise.all([fetchData({ page: -1 }), fetchTemplates({ page: -1 })]).then(
|
||||
([instanceResItems, templatesRes]) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
applyAutoSelection(instanceResItems || [], templatesRes?.items || []);
|
||||
}
|
||||
);
|
||||
loadCreateResources();
|
||||
}
|
||||
}, [open, shouldAutoSelectResource, action]);
|
||||
|
||||
// filter instance types
|
||||
const filteredInstanceTypes = instanceTypeList.filter((item) =>
|
||||
// filter instance types (already scoped to the chosen org's clusters)
|
||||
const filteredInstanceTypes = ownedInstanceTypes.filter((item) =>
|
||||
matchKeyword([item.name], instanceKeyword)
|
||||
);
|
||||
|
||||
// No instance types for the chosen org (e.g. it owns no clusters), and not
|
||||
// mid-fetch — drives the "no available instance type" message in the form.
|
||||
const noAvailableInstanceTypes =
|
||||
action === PageAction.CREATE &&
|
||||
!instanceTypesLoading &&
|
||||
ownedInstanceTypes.length === 0;
|
||||
|
||||
// filter templates based on selection and keyword
|
||||
const filteredTemplates = templateList.filter((item) => {
|
||||
if (
|
||||
@@ -268,6 +388,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
await onOk({
|
||||
...values
|
||||
});
|
||||
console.log('submit form values', values);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -444,8 +565,10 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
currentData={data}
|
||||
disabled={readonly}
|
||||
onFinish={onFinish}
|
||||
onScopeChange={handleScopeChange}
|
||||
open={open}
|
||||
instanceTypeList={instanceTypeList}
|
||||
instanceTypeList={ownedInstanceTypes}
|
||||
noAvailableInstanceTypes={noAvailableInstanceTypes}
|
||||
/>
|
||||
</>
|
||||
</ColumnWrapper>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { AutoTooltip, IconFont } from '@gpustack/core-ui';
|
||||
import { Flex, Tooltip } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
export type InstanceTypeSection = {
|
||||
icon: string;
|
||||
name: string;
|
||||
// [label, value] — a null label renders a single value with no sub-label;
|
||||
// rows whose value is falsy or "-" are dropped.
|
||||
rows: [string | null, string | undefined][];
|
||||
};
|
||||
|
||||
/**
|
||||
* "Instance Type" cell shared by the GPU Instances list and the Usage GPU
|
||||
* Instances table: a primary product label (e.g. "NVIDIA-GeForce-RTX-5090-D x
|
||||
* 1") plus a question/info icon whose dark popover breaks the spec down by
|
||||
* category (GPU / CPU / Memory / Disk), titled with the instance name.
|
||||
*/
|
||||
const InstanceTypeCell: React.FC<{
|
||||
title: string;
|
||||
name?: string;
|
||||
sections: InstanceTypeSection[];
|
||||
}> = ({ title, name, sections }) => {
|
||||
const specInfo = (
|
||||
<div style={{ minWidth: 200 }}>
|
||||
{name && <div style={{ fontWeight: 600, marginBottom: 8 }}>{name}</div>}
|
||||
{sections.map((sec) => {
|
||||
const rows = sec.rows.filter(([, v]) => v && v !== '-');
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<div
|
||||
key={sec.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 16,
|
||||
paddingBlock: 4,
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.2)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
minWidth: 88,
|
||||
lineHeight: '22px'
|
||||
}}
|
||||
>
|
||||
<IconFont type={sec.icon} />
|
||||
<span>{sec.name}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{rows.map(([label, value], i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ display: 'flex', gap: 16, lineHeight: '22px' }}
|
||||
>
|
||||
<span style={{ opacity: 0.65, minWidth: 96 }}>
|
||||
{label || ''}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex align="center" style={{ gap: 6 }}>
|
||||
<AutoTooltip ghost title={<span>{title}</span>}>
|
||||
<span className="text-primary">{title}</span>
|
||||
</AutoTooltip>
|
||||
<Tooltip
|
||||
title={specInfo}
|
||||
styles={{ container: { width: 'max-content', maxWidth: 480 } }}
|
||||
>
|
||||
<InfoCircleOutlined
|
||||
style={{ color: 'var(--ant-color-primary)', cursor: 'pointer' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstanceTypeCell;
|
||||
@@ -1,15 +1,11 @@
|
||||
import { ceilMilliToCore } from '@/pages/gpu-service/utils';
|
||||
import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Flex } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
import { manufactureColorMap } from '../../templates/config';
|
||||
import { convertKiToGi } from '../config';
|
||||
import { formatMemoryDisplay } from '../config';
|
||||
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
|
||||
|
||||
const toDisplayUnit = (value?: string | null) =>
|
||||
value ? value.replace(/Gi$/, 'GB').replace(/Ti$/, 'TB') : value;
|
||||
|
||||
const Title = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -23,28 +19,20 @@ const Title = styled.div`
|
||||
const Meta = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, auto);
|
||||
grid-auto-rows: minmax(15px, auto);
|
||||
justify-content: start;
|
||||
column-gap: 4px;
|
||||
row-gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--ant-color-text-secondary);
|
||||
color: var(--ant-color-text-tertiary);
|
||||
font-size: 13px;
|
||||
|
||||
.meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
align-items: center;
|
||||
min-height: 15px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--ant-color-text-quaternary);
|
||||
margin: 0 4px;
|
||||
margin: 0 6px;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
@@ -56,13 +44,16 @@ const Meta = styled.div`
|
||||
|
||||
interface InstanceTypeItemProps {
|
||||
item: InstanceTypeItemModel;
|
||||
showStatus?: boolean;
|
||||
}
|
||||
|
||||
interface MetadataSectionProps {
|
||||
spec: InstanceTypeItemModel['spec'];
|
||||
}
|
||||
|
||||
const MetaItem: React.FC<{
|
||||
icon: string;
|
||||
label?: string;
|
||||
value?: string | null;
|
||||
value?: string | null | number;
|
||||
showDot?: boolean;
|
||||
show?: boolean;
|
||||
}> = ({ icon, label, value, showDot = true, show = true }) => {
|
||||
@@ -77,110 +68,122 @@ const MetaItem: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
|
||||
const intl = useIntl();
|
||||
const specData = item.spec || {};
|
||||
function getInstanceDerived(item: InstanceTypeItemModel) {
|
||||
const spec = item.spec || {};
|
||||
const acceleratable = spec.acceleratable;
|
||||
|
||||
// false: CPU; true: GPU
|
||||
const acceleratable = specData.acceleratable;
|
||||
|
||||
const manufacturer = acceleratable ? specData.manufacturer || '' : 'cpu';
|
||||
const manufacturerColor = manufactureColorMap[manufacturer] ?? 'purple';
|
||||
|
||||
// resource once-max-request status
|
||||
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 displayName = specData.acceleratable
|
||||
? specData.product || item.name
|
||||
: 'CPU';
|
||||
return displayName;
|
||||
return {
|
||||
acceleratable,
|
||||
isGPU: acceleratable,
|
||||
manufacturer: acceleratable ? spec.manufacturer || '' : 'cpu',
|
||||
displayName: acceleratable ? spec.product || item.name : 'CPU',
|
||||
ramUnit: spec.unitResourcesParsed?.ram?.value,
|
||||
cpuUnitCores: spec.unitResourcesParsed?.cpu?.cores
|
||||
};
|
||||
}
|
||||
|
||||
export const InstanceMetadataSection: React.FC<MetadataSectionProps> = ({
|
||||
spec
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { ramUnit, cpuUnitCores, isGPU } = getInstanceDerived({
|
||||
spec
|
||||
} as InstanceTypeItemModel);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>
|
||||
<Flex gap={8} align="center">
|
||||
<AutoTooltip ghost minWidth={20} maxWidth={200}>
|
||||
{renderName() || '-'}
|
||||
</AutoTooltip>
|
||||
{acceleratable && manufacturer && (
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--ant-color-text-tertiary)',
|
||||
fontWeight: 400
|
||||
}}
|
||||
>
|
||||
<ThemeTag color={manufacturerColor} disabled={false}>
|
||||
{manufacturer?.toUpperCase()}
|
||||
</ThemeTag>
|
||||
</span>
|
||||
)}
|
||||
</Flex>
|
||||
</Title>
|
||||
<Meta>
|
||||
<span className="meta-row">
|
||||
{acceleratable && (
|
||||
<>
|
||||
<MetaItem
|
||||
showDot={false}
|
||||
icon="icon-gpu1"
|
||||
label={intl.formatMessage({ id: 'gpuservice.instance.memory' })}
|
||||
value={
|
||||
toDisplayUnit(convertKiToGi(specData?.memory ?? undefined)) ??
|
||||
'-'
|
||||
}
|
||||
/>
|
||||
<MetaItem
|
||||
show={!!specData?.sliced}
|
||||
icon="icon-sliced"
|
||||
label={intl.formatMessage({
|
||||
id: 'gpuservice.instance.sliced'
|
||||
})}
|
||||
value={specData?.sliced}
|
||||
/>
|
||||
<MetaItem
|
||||
icon="icon-database"
|
||||
label={intl.formatMessage(
|
||||
{
|
||||
id: 'common.max'
|
||||
},
|
||||
{ count: '' }
|
||||
)}
|
||||
value={`${item.maxAccelerator || '-'}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="meta-row">
|
||||
<Meta>
|
||||
{isGPU && (
|
||||
<>
|
||||
<MetaItem
|
||||
show={isGPU}
|
||||
showDot={false}
|
||||
icon="icon-gpu1"
|
||||
label={intl.formatMessage({ id: 'gpuservice.instance.memory' })}
|
||||
value={formatMemoryDisplay(spec?.memory ?? undefined) ?? '-'}
|
||||
/>
|
||||
<MetaItem
|
||||
icon="icon-database"
|
||||
label={intl.formatMessage(
|
||||
{
|
||||
id: 'common.max'
|
||||
},
|
||||
{ count: '' }
|
||||
)}
|
||||
value={`${spec.maxComputeUnitCount || 0}`}
|
||||
/>
|
||||
<MetaItem
|
||||
showDot={false}
|
||||
icon="icon-ram-02"
|
||||
label={intl.formatMessage({ id: 'gpuservice.instance.ram' })}
|
||||
value={toDisplayUnit(convertKiToGi(ramRaw)) ?? '-'}
|
||||
value={ramUnit ? `${ramUnit} GB` : '-'}
|
||||
/>
|
||||
<MetaItem
|
||||
show={!!cpuRaw}
|
||||
show={isGPU}
|
||||
showDot={true}
|
||||
icon="icon-cpu"
|
||||
label="CPU"
|
||||
value={ceilMilliToCore(cpuRaw) ?? '-'}
|
||||
value={cpuUnitCores || '-'}
|
||||
/>
|
||||
</span>
|
||||
</Meta>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isGPU && (
|
||||
<>
|
||||
<MetaItem
|
||||
showDot={false}
|
||||
icon="icon-ram-02"
|
||||
label={intl.formatMessage({ id: 'gpuservice.instance.ram' })}
|
||||
value={ramUnit ? `${ramUnit} GB` : '-'}
|
||||
/>
|
||||
<MetaItem
|
||||
icon="icon-database"
|
||||
label={intl.formatMessage(
|
||||
{
|
||||
id: 'common.max'
|
||||
},
|
||||
{ count: '' }
|
||||
)}
|
||||
value={`${spec.maxComputeUnitCount || 0}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Meta>
|
||||
);
|
||||
};
|
||||
|
||||
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
|
||||
const specData = item.spec || {};
|
||||
|
||||
const { acceleratable, manufacturer, displayName } = getInstanceDerived(item);
|
||||
|
||||
const manufacturerColor = manufactureColorMap[manufacturer] ?? 'purple';
|
||||
const showManufacturerTag = acceleratable && manufacturer;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
orientation="vertical"
|
||||
justify="space-between"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<Title>
|
||||
<Flex gap={8} align="center">
|
||||
<AutoTooltip ghost minWidth={20} maxWidth={200}>
|
||||
{displayName || '-'}
|
||||
</AutoTooltip>
|
||||
{showManufacturerTag && (
|
||||
<ThemeTag
|
||||
color={manufacturerColor}
|
||||
disabled={false}
|
||||
style={{ fontWeight: 400 }}
|
||||
>
|
||||
{manufacturer?.toUpperCase()}
|
||||
</ThemeTag>
|
||||
)}
|
||||
</Flex>
|
||||
</Title>
|
||||
<InstanceMetadataSection spec={specData}></InstanceMetadataSection>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user