Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c06862705 | ||
|
|
48159171c2 | ||
|
|
d5d0749b72 | ||
|
|
dc4dd81e11 | ||
|
|
c736e2186d | ||
|
|
7a76bda484 | ||
|
|
778ea36a9d | ||
|
|
04e1e1e566 | ||
|
|
01f66085ab | ||
|
|
ddf723525a | ||
|
|
7b18a92388 | ||
|
|
b985dcee63 | ||
|
|
77e92432ed | ||
|
|
a7b33a928b | ||
|
|
1218c6d096 | ||
|
|
6337431ed4 | ||
|
|
2ba5118bc2 | ||
|
|
12649c7ccf | ||
|
|
5d96932870 | ||
|
|
e6cfa6cf77 | ||
|
|
55d16c5681 | ||
|
|
ab75685955 | ||
|
|
bd0bf0bf63 | ||
|
|
fd050b33d9 | ||
|
|
698090b17a | ||
|
|
6f96260993 | ||
|
|
98a6b19298 | ||
|
|
ded270ff25 | ||
|
|
c8860207e8 | ||
|
|
8d045ffd8b | ||
|
|
fd7437e74a | ||
|
|
807ba866d3 | ||
|
|
5e2808d7c4 | ||
|
|
a26a3ff8f9 | ||
|
|
a2e9f69b96 | ||
|
|
ac800a303a | ||
|
|
dd542a9f29 | ||
|
|
26ce456bde | ||
|
|
7ce1faaf6c | ||
|
|
878bdaa5a7 | ||
|
|
68a7e3f66a | ||
|
|
feccc81c5f | ||
|
|
620045d7a3 | ||
|
|
6dc28d61b0 | ||
|
|
754cc29793 | ||
|
|
ed525509ea | ||
|
|
2b6f26e6c8 | ||
|
|
09821aab85 | ||
|
|
636190b8e6 | ||
|
|
e420c22a45 | ||
|
|
993f6edcc4 |
@@ -31,5 +31,6 @@ export default function createProxyTable(target?: string) {
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return proxyTable;
|
||||
}
|
||||
|
||||
@@ -338,6 +338,19 @@ const baseRoutes = [
|
||||
selectedIcon: 'icon-usage-filled',
|
||||
defaultIcon: 'icon-usage-outlined',
|
||||
component: './usage/index'
|
||||
},
|
||||
{
|
||||
name: 'billing',
|
||||
path: '/usage/billing',
|
||||
key: 'billing',
|
||||
icon: 'icon-billing-outlined',
|
||||
selectedIcon: 'icon-billing-filled',
|
||||
defaultIcon: 'icon-billing-outlined',
|
||||
// OSS exposes the menu as a teaser for the enterprise billing
|
||||
// module. The page itself just renders an upsell notice — the real
|
||||
// billing UI lives in the enterprise plugin and shadows this route
|
||||
// via `routes.extensions.ts`.
|
||||
component: './billing'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+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.22",
|
||||
"@gpustack/core-ui": "^1.0.27",
|
||||
"@huggingface/gguf": "^0.1.7",
|
||||
"@huggingface/hub": "^0.15.1",
|
||||
"@huggingface/tasks": "^0.11.6",
|
||||
|
||||
@@ -5,6 +5,8 @@ export default (api: IApi) => {
|
||||
const info = JSON.parse(process.env.VERSION || '{}');
|
||||
const env = process.env.NODE_ENV;
|
||||
|
||||
$('html').attr('lang', 'en');
|
||||
|
||||
$('html').attr('data-env', env);
|
||||
|
||||
$('html').attr(
|
||||
|
||||
Generated
+5
-5
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.2
|
||||
'@gpustack/core-ui':
|
||||
specifier: ^1.0.22
|
||||
version: 1.0.22(czdvzceysqw7iv6pct2ucnb23e)
|
||||
specifier: ^1.0.27
|
||||
version: 1.0.27(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.22':
|
||||
resolution: {integrity: sha512-uFLGalziwuojjMIeXMlCZn3tLRGA6PCa0JxLxa/+kKP+xAsy7Fcxgh4PerbTyk+P+hKnYv8cD+DPpkObDGqJgg==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.22.tgz}
|
||||
'@gpustack/core-ui@1.0.27':
|
||||
resolution: {integrity: sha512-m3ue0EHFKULla0mpnpxZwn9LVaGKS+HnuzQYSBECQa4vaP8MEEQsR7oFBtG8bhWUWYA9VzP6k7fjozDkP6TymA==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.27.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.22(czdvzceysqw7iv6pct2ucnb23e)':
|
||||
'@gpustack/core-ui@1.0.27(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)
|
||||
|
||||
@@ -55,3 +55,10 @@ export const userSettingsHelperAtom = atom(
|
||||
}
|
||||
);
|
||||
export const hideModalTemporarilyAtom = atom<boolean>(false);
|
||||
|
||||
export const collapsedMenuGroupsAtom = atomWithStorage<string[]>(
|
||||
'collapsedMenuGroups',
|
||||
[],
|
||||
undefined,
|
||||
{ getOnInit: true }
|
||||
);
|
||||
|
||||
+1
-4
@@ -32,10 +32,7 @@ export const UpdateCheckAtom = atom<{
|
||||
latest_version: ''
|
||||
});
|
||||
|
||||
export const initialPasswordAtom = atomWithStorage<string>(
|
||||
'initialPassword',
|
||||
''
|
||||
);
|
||||
export const initialPasswordAtom = atom<string>('');
|
||||
|
||||
// Namespace the server creates for an Org's resources on each Kubernetes
|
||||
// cluster. The format must match the backend's ``get_namespace_name``
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
@font-face {
|
||||
font-family: "iconfont"; /* Project id 4613488 */
|
||||
src: url('iconfont.woff2?t=1780035199453') format('woff2'),
|
||||
url('iconfont.woff?t=1780035199453') format('woff'),
|
||||
url('iconfont.ttf?t=1780035199453') format('truetype');
|
||||
src: url('iconfont.woff2?t=1780907696624') format('woff2'),
|
||||
url('iconfont.woff?t=1780907696624') format('woff'),
|
||||
url('iconfont.ttf?t=1780907696624') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
@@ -13,6 +13,18 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-billing-outlined:before {
|
||||
content: "\e70d";
|
||||
}
|
||||
|
||||
.icon-billing-filled:before {
|
||||
content: "\e70e";
|
||||
}
|
||||
|
||||
.icon-fingerprint:before {
|
||||
content: "\e70c";
|
||||
}
|
||||
|
||||
.icon-database-outlined:before {
|
||||
content: "\e70a";
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,6 +5,27 @@
|
||||
"css_prefix_text": "icon-",
|
||||
"description": "",
|
||||
"glyphs": [
|
||||
{
|
||||
"icon_id": "47798379",
|
||||
"name": "request_quote_24dp_1F1F1F_FILL0_wght300_GRAD0_opsz",
|
||||
"font_class": "billing-outlined",
|
||||
"unicode": "e70d",
|
||||
"unicode_decimal": 59149
|
||||
},
|
||||
{
|
||||
"icon_id": "47798378",
|
||||
"name": "request_quote_24dp_1F1F1F_FILL1_wght300_GRAD0_opsz",
|
||||
"font_class": "billing-filled",
|
||||
"unicode": "e70e",
|
||||
"unicode_decimal": 59150
|
||||
},
|
||||
{
|
||||
"icon_id": "47761129",
|
||||
"name": "fingerprint",
|
||||
"font_class": "fingerprint",
|
||||
"unicode": "e70c",
|
||||
"unicode_decimal": 59148
|
||||
},
|
||||
{
|
||||
"icon_id": "47654402",
|
||||
"name": "database_24dp_1F1F1F_FILL0_wght300_GRAD0_opsz24 (1",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,46 @@
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Centralizes the anti-double-submit + anti-deadlock logic shared by the
|
||||
* "add/edit" modals.
|
||||
*
|
||||
* The lock has to straddle antd's async field validation, whose result comes
|
||||
* back through two separate callbacks (`onFinish` / `onFinishFailed`). So the
|
||||
* three wiring points below are intentional and map 1:1 to those anchors:
|
||||
*
|
||||
* - `guard` wrap the submit trigger (button / ModalFooter onOk). Blocks
|
||||
* re-entry while a submit is already in flight, then fires submit.
|
||||
* - `run` wrap the `onFinish` handler. Holds the lock + loading until the
|
||||
* `onOk` request settles (success or error).
|
||||
* - `release` pass as the form's `onFinishFailed`. Releases the lock when
|
||||
* validation fails, otherwise the button would dead-lock.
|
||||
*/
|
||||
export default function useSubmitLock() {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const lockRef = useRef<boolean>(false);
|
||||
|
||||
const release = useMemoizedFn(() => {
|
||||
setLoading(false);
|
||||
lockRef.current = false;
|
||||
});
|
||||
|
||||
const guard = useMemoizedFn((submit: () => void) => {
|
||||
if (lockRef.current) {
|
||||
return;
|
||||
}
|
||||
lockRef.current = true;
|
||||
submit();
|
||||
});
|
||||
|
||||
const run = useMemoizedFn(async (task: () => void | Promise<void>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await task();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
return { loading, guard, run, release };
|
||||
}
|
||||
@@ -89,6 +89,10 @@ export function useUpdateChunkedList(options: {
|
||||
...cacheDataListRef.current
|
||||
].slice(0, limit);
|
||||
|
||||
options.setDataList?.([...cacheDataListRef.current], {
|
||||
createdIds: newDataList.map((item) => item.id)
|
||||
});
|
||||
|
||||
options.onCreate?.(latestCreateList);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,7 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
|
||||
listRequestTokenRef.current?.cancel?.();
|
||||
listRequestTokenRef.current = createAxiosToken();
|
||||
const params = {
|
||||
page: 1,
|
||||
perPage: 100
|
||||
page: -1
|
||||
};
|
||||
const res: any = await queryAllDataList(params, {
|
||||
token: listRequestTokenRef.current.token
|
||||
@@ -86,6 +85,7 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
|
||||
|
||||
return {
|
||||
watchDataList,
|
||||
setWatchDataList,
|
||||
deleteItemFromCache: handleDeleteItemFromCache
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useAtom } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { DEFAULT_ENTER_PAGE } from '../config/settings';
|
||||
import GithubStar from './github-star';
|
||||
|
||||
const NewLabel = styled.span`
|
||||
position: relative;
|
||||
@@ -260,6 +261,7 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
|
||||
<Wrapper>
|
||||
{contextHolder}
|
||||
<PluginExtraField name="OrgSwitcher" isDarkTheme={isDarkTheme} />
|
||||
{process.env.ENABLE_ENTERPRISE !== 'true' && <GithubStar />}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import externalLinks from '@/constants/external-links';
|
||||
import { GithubFilled } from '@ant-design/icons';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Tooltip } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const REPO = 'gpustack/gpustack';
|
||||
const CACHE_KEY = 'gpustack:github-stars';
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
const FETCH_TIMEOUT = 4000;
|
||||
|
||||
const StarLink = styled.a`
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
height: 24px;
|
||||
border-radius: var(--ant-border-radius);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
background-color: var(--ant-color-bg-container);
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ant-color-border);
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.seg + .seg {
|
||||
border-left: 1px solid var(--ant-color-border-secondary);
|
||||
background-color: var(--ant-color-fill-quaternary);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const formatCount = (n: number): string => {
|
||||
if (n >= 1000) {
|
||||
const k = n / 1000;
|
||||
return k >= 10 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
|
||||
}
|
||||
return String(n);
|
||||
};
|
||||
|
||||
type CacheEntry = { value: number; time: number };
|
||||
|
||||
const readCache = (): CacheEntry | null => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed?.value !== 'number' || typeof parsed?.time !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeCache = (value: number) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({ value, time: Date.now() })
|
||||
);
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
};
|
||||
|
||||
const GithubStar = () => {
|
||||
const intl = useIntl();
|
||||
const [count, setCount] = useState<number | null>(
|
||||
() => readCache()?.value ?? null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = readCache();
|
||||
const fresh = cached && Date.now() - cached.time < CACHE_TTL;
|
||||
if (fresh) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
||||
|
||||
fetch(`https://api.github.com/repos/${REPO}`, { signal: controller.signal })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (!data || typeof data.stargazers_count !== 'number') return;
|
||||
setCount(data.stargazers_count);
|
||||
writeCache(data.stargazers_count);
|
||||
})
|
||||
.catch(() => {
|
||||
// offline, blocked, rate-limited — stay hidden if no cache
|
||||
})
|
||||
.finally(() => clearTimeout(timer));
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tooltip title={intl.formatMessage({ id: 'common.github.star.tooltip' })}>
|
||||
<StarLink href={externalLinks.github} target="_blank" rel="noreferrer">
|
||||
<span className="seg">
|
||||
<GithubFilled />
|
||||
</span>
|
||||
<span className="seg">
|
||||
<span className="count">
|
||||
{count != null ? formatCount(count) : 'Star'}
|
||||
</span>
|
||||
</span>
|
||||
</StarLink>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default GithubStar;
|
||||
@@ -327,7 +327,7 @@ export default (props: any) => {
|
||||
config={{
|
||||
apiBaseUrl: GPUSTACK_API_BASE_URL,
|
||||
theme: userSettings.theme,
|
||||
iconUrl: '//at.alicdn.com/t/c/font_4613488_rwchdketjg8.js',
|
||||
iconUrl: '',
|
||||
isDarkTheme: userSettings.isDarkTheme,
|
||||
defaultColorPrimary: COLOR_PRIMARY
|
||||
}}
|
||||
|
||||
+21
-11
@@ -1,9 +1,11 @@
|
||||
import { collapsedMenuGroupsAtom } from '@/atoms/settings';
|
||||
import { CaretDownOutlined } from '@ant-design/icons';
|
||||
import { IconFont, OverlayScroller } from '@gpustack/core-ui';
|
||||
import { Link, useLocation } from '@umijs/max';
|
||||
import { Tooltip } from 'antd';
|
||||
import { createStyles, type FullToken } from 'antd-style';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface MenuItem {
|
||||
icon?: string;
|
||||
@@ -144,8 +146,19 @@ const SiderMenu: React.FC<SiderMenuProps> = (props) => {
|
||||
const { menuData, collapsed } = props;
|
||||
const { styles, cx } = useStyles();
|
||||
const location = useLocation();
|
||||
const [collapseKeys, setCollapseKeys] = useState<Set<string>>(new Set());
|
||||
console.log('SiderMenu', location.pathname);
|
||||
const [storedCollapsedGroups, setCollapsedGroups] = useAtom(
|
||||
collapsedMenuGroupsAtom
|
||||
);
|
||||
// atomWithStorage falls back to the initial value on JSON parse
|
||||
// errors, but not when the stored value is valid JSON of another
|
||||
// shape — normalize so array methods below can't throw.
|
||||
const collapsedGroups = Array.isArray(storedCollapsedGroups)
|
||||
? storedCollapsedGroups
|
||||
: [];
|
||||
const collapseKeys = useMemo(
|
||||
() => new Set(collapsedGroups),
|
||||
[collapsedGroups]
|
||||
);
|
||||
|
||||
const dividerStyles = useMemo(() => {
|
||||
if (collapsed) {
|
||||
@@ -164,14 +177,11 @@ const SiderMenu: React.FC<SiderMenuProps> = (props) => {
|
||||
const handleToggleGroup = (e: any, menuGroup: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
console.log('handleToggleGroup', menuGroup.key);
|
||||
|
||||
if (collapseKeys.has(menuGroup.key)) {
|
||||
collapseKeys.delete(menuGroup.key);
|
||||
} else {
|
||||
collapseKeys.add(menuGroup.key);
|
||||
}
|
||||
setCollapseKeys(new Set(collapseKeys));
|
||||
setCollapsedGroups(
|
||||
collapsedGroups.includes(menuGroup.key)
|
||||
? collapsedGroups.filter((key) => key !== menuGroup.key)
|
||||
: [...collapsedGroups, menuGroup.key]
|
||||
);
|
||||
};
|
||||
|
||||
const menuItemRender = (menuItem: MenuItem, key: string) => {
|
||||
|
||||
@@ -23,11 +23,12 @@ export default {
|
||||
'backend.form.defaultExecuteCommand': 'Default Execution Command',
|
||||
'backend.form.defaultExecuteCommand.tips': `'{{'model_path'}}', '{{'port'}}', '{{'worker_ip'}}' and '{{'model_name'}}' are placeholders that will be substituted with the actual values during deployment.`,
|
||||
'backend.form.defaultBackendParameters': 'Default Backend Parameters',
|
||||
'backend.form.parameterFormat': 'Parameter Format',
|
||||
'backend.form.parameterFormat.default': 'Backend Default',
|
||||
'backend.form.parameterFormat.space': 'Space (--key value)',
|
||||
'backend.form.parameterFormat.equal': 'Equal (--key=value)',
|
||||
'backend.form.commonParameters': 'Common Parameters',
|
||||
'backend.form.flagFormat': 'Flag Format',
|
||||
'backend.form.flagFormat.tips':
|
||||
'The format applied between an option and its value. Leave empty to keep each parameter as entered, without normalizing it.',
|
||||
'backend.form.flagFormat.space': 'Space Separated (--key value)',
|
||||
'backend.form.flagFormat.equal': 'Equal Sign (--key=value)',
|
||||
'backend.form.commonParameters': 'Common Backend Parameters',
|
||||
'backend.form.commonParameters.tips':
|
||||
'Shown as suggestions in the backend parameters input during deployment.',
|
||||
'backend.form.versionConfig': 'Versions Config',
|
||||
|
||||
@@ -37,10 +37,10 @@ export default {
|
||||
'Stress test for long-context handling. Evaluates KV cache behavior, memory usage, and backend stability.',
|
||||
'benchmark.form.profile.heavy.tips':
|
||||
'Decode-heavy generation benchmark. Measures sustained decoding speed and output token throughput.',
|
||||
'benchmark.table.filter.bygpu': 'Filter by GPU',
|
||||
'benchmark.table.filter.bymodel': 'Filter by Model',
|
||||
'benchmark.table.filter.bygpu': 'Search by GPU',
|
||||
'benchmark.table.filter.bymodel': 'Search by model',
|
||||
'benchmark.table.filter.bydataset': 'Filter by Dataset',
|
||||
'benchmark.table.filter.byProfile': 'Filter by Profile',
|
||||
'benchmark.table.filter.byProfile': 'Filter by profile',
|
||||
'benchmark.table.avg': 'Avg',
|
||||
'benchmark.table.columnSettings': 'Column Settings',
|
||||
'benchmark.detail.summary.results': 'Test Results',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
'billing.upsell.title': 'Billing is an Enterprise feature',
|
||||
'billing.upsell.subtitle':
|
||||
'Track spend, generate invoices, and enforce budgets across teams. Upgrade to GPUStack Enterprise to manage billing.',
|
||||
'billing.upsell.featuresTitle': 'What you get in Enterprise',
|
||||
'billing.upsell.feature.usage':
|
||||
'See cost breakdowns by organization, user, and model',
|
||||
'billing.upsell.feature.invoices':
|
||||
'Generate invoices and export billing reports',
|
||||
'billing.upsell.feature.budgets':
|
||||
'Set budgets and spending limits with alerts',
|
||||
'billing.upsell.feature.chargeback':
|
||||
'Attribute and charge back usage to teams and projects',
|
||||
'billing.upsell.cta': 'Learn about Enterprise'
|
||||
};
|
||||
@@ -70,8 +70,13 @@ export default {
|
||||
'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.',
|
||||
'clusters.addworker.selectGPU': 'Select GPU Vendor',
|
||||
'clusters.addworker.selectGPU.multiTag': 'Multi-select',
|
||||
'clusters.addworker.selectGPU.subtitle':
|
||||
'You can select multiple GPU Vendors or none for CPU-only clusters',
|
||||
'clusters.addworker.checkEnv': 'Check Environment',
|
||||
'clusters.addworker.checkEnv.cpuOnlyTips':
|
||||
'Use the following command to verify that the Kubernetes cluster has at least one ready node. You are registering a CPU-only cluster.',
|
||||
'clusters.addworker.specifyArgs': 'Specify Arguments',
|
||||
'clusters.addworker.dtkVersion': 'DTK Version',
|
||||
'clusters.addworker.runCommand': 'Run Command',
|
||||
'clusters.addworker.specifyWorkerIP': 'Worker IP',
|
||||
'clusters.addworker.detectWorkerIP': 'Auto-detect Worker IP',
|
||||
@@ -112,8 +117,6 @@ export default {
|
||||
'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.',
|
||||
|
||||
@@ -169,6 +169,7 @@ export default {
|
||||
'common.time.hour': 'hour',
|
||||
'common.time.minute': 'minutes',
|
||||
'common.issue.report': 'Report an issue',
|
||||
'common.github.star.tooltip': 'Star us on GitHub',
|
||||
'common.social.discord': 'Join Our Discord',
|
||||
'common.table.mark': 'Comment',
|
||||
'common.table.rollback.mark': 'Rollback Comment',
|
||||
@@ -260,6 +261,10 @@ export default {
|
||||
'common.login.auth': 'Authenticating...',
|
||||
'common.login.auth.failed': 'Authentication failed',
|
||||
'common.login.password': 'Log in with Password',
|
||||
'common.login.username.holder': 'Please enter username',
|
||||
'common.login.password.holder': 'Please enter password',
|
||||
'common.login.newpassword.holder': 'Please enter new password',
|
||||
'common.login.confirm.holder': 'Please enter password again',
|
||||
'common.external.login': 'Log in with {type}',
|
||||
'common.sso.noConfig':
|
||||
'Single sign-on is not enabled on this system. Please contact your administrator.',
|
||||
|
||||
@@ -120,6 +120,8 @@ export default {
|
||||
'gpuservice.instance.sliced': 'Sliced',
|
||||
'gpuservice.instance.memory': 'VRAM',
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.os': 'OS',
|
||||
'gpuservice.instance.arch': 'Arch',
|
||||
'gpuservice.instance.disk': 'Disk',
|
||||
'gpuservice.table.count': 'Count',
|
||||
'gpuservice.instance.disk.system': 'System Disk',
|
||||
@@ -172,5 +174,9 @@ export default {
|
||||
'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.",
|
||||
'gpuservice.form.storage.select': 'Select Storage'
|
||||
'gpuservice.form.storage.select': 'Select Storage',
|
||||
'gpuservice.creator': 'Creator',
|
||||
'gpuservice.owner.global': 'Global',
|
||||
'gpuservice.template.group.yours': 'Your Templates',
|
||||
'gpuservice.template.group.global': 'Global Templates'
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ export default {
|
||||
'menu.usage.usage': 'Usage',
|
||||
'menu.billingAndUsage': 'Usage & Billing',
|
||||
'menu.billingAndUsage.usage': 'Usage',
|
||||
'menu.billingAndUsage.billing': 'Billing',
|
||||
'menu.404': '404',
|
||||
'menu.resources.clusters': 'Clusters',
|
||||
'menu.resources.credentials': 'Cloud Credentials',
|
||||
|
||||
@@ -90,6 +90,7 @@ export default {
|
||||
// --- Resource events ---
|
||||
'usage.events.resourceType': 'Resource type',
|
||||
'usage.events.eventType': 'Event type',
|
||||
'usage.events.resourceName': 'Filter by name',
|
||||
'usage.events.col.time': 'Time',
|
||||
'usage.events.col.resource': 'Resource',
|
||||
'usage.events.col.event': 'Event',
|
||||
|
||||
@@ -23,11 +23,12 @@ export default {
|
||||
'backend.form.defaultExecuteCommand': 'Default Execution Command',
|
||||
'backend.form.defaultExecuteCommand.tips': `'{{'model_path'}}', '{{'port'}}', '{{'worker_ip'}}' and '{{'model_name'}}' are placeholders that will be substituted with the actual values during deployment.`,
|
||||
'backend.form.defaultBackendParameters': 'Default Backend Parameters',
|
||||
'backend.form.parameterFormat': 'パラメータ形式',
|
||||
'backend.form.parameterFormat.default': 'バックエンドのデフォルト',
|
||||
'backend.form.parameterFormat.space': 'スペース区切り (--key value)',
|
||||
'backend.form.parameterFormat.equal': 'イコール連結 (--key=value)',
|
||||
'backend.form.commonParameters': 'よく使うパラメータ',
|
||||
'backend.form.flagFormat': 'フラグ形式',
|
||||
'backend.form.flagFormat.tips':
|
||||
'オプションとその値を連結する形式。空欄の場合は各パラメータを入力されたまま保持し、形式を統一しません。',
|
||||
'backend.form.flagFormat.space': 'スペース区切り (--key value)',
|
||||
'backend.form.flagFormat.equal': 'イコール記号 (--key=value)',
|
||||
'backend.form.commonParameters': 'よく使うバックエンドパラメータ',
|
||||
'backend.form.commonParameters.tips':
|
||||
'モデルのデプロイ時にバックエンドパラメータ入力欄の候補として表示されます。',
|
||||
'backend.form.versionConfig': 'Versions Config',
|
||||
|
||||
@@ -37,8 +37,8 @@ export default {
|
||||
'Stress test for long-context handling. Evaluates KV cache behavior, memory usage, and backend stability.',
|
||||
'benchmark.form.profile.heavy.tips':
|
||||
'Decode-heavy generation benchmark. Measures sustained decoding speed and output token throughput.',
|
||||
'benchmark.table.filter.bygpu': 'Filter by GPU',
|
||||
'benchmark.table.filter.bymodel': 'Filter by Model',
|
||||
'benchmark.table.filter.bygpu': 'GPU 検索',
|
||||
'benchmark.table.filter.bymodel': 'モデル検索',
|
||||
'benchmark.table.filter.bydataset': 'Filter by Dataset',
|
||||
'benchmark.table.filter.byProfile': 'Filter by Profile',
|
||||
'benchmark.table.avg': 'Avg',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
'billing.upsell.title': 'Billing is an Enterprise feature',
|
||||
'billing.upsell.subtitle':
|
||||
'Track spend, generate invoices, and enforce budgets across teams. Upgrade to GPUStack Enterprise to manage billing.',
|
||||
'billing.upsell.featuresTitle': 'What you get in Enterprise',
|
||||
'billing.upsell.feature.usage':
|
||||
'See cost breakdowns by organization, user, and model',
|
||||
'billing.upsell.feature.invoices':
|
||||
'Generate invoices and export billing reports',
|
||||
'billing.upsell.feature.budgets':
|
||||
'Set budgets and spending limits with alerts',
|
||||
'billing.upsell.feature.chargeback':
|
||||
'Attribute and charge back usage to teams and projects',
|
||||
'billing.upsell.cta': 'Learn about Enterprise'
|
||||
};
|
||||
@@ -70,8 +70,13 @@ export default {
|
||||
'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.',
|
||||
'clusters.addworker.selectGPU': 'Select GPU Vendor',
|
||||
'clusters.addworker.selectGPU.multiTag': 'Multi-select',
|
||||
'clusters.addworker.selectGPU.subtitle':
|
||||
'複数の GPU ベンダーを選択するか、CPU クラスター専用の場合は選択不要です',
|
||||
'clusters.addworker.checkEnv': 'Check Environment',
|
||||
'clusters.addworker.checkEnv.cpuOnlyTips':
|
||||
'以下のコマンドを使用して、Kubernetes クラスターに少なくとも 1 つのレディーノードがあることを確認してください。CPU クラスターを登録しています。',
|
||||
'clusters.addworker.specifyArgs': 'Specify Arguments',
|
||||
'clusters.addworker.dtkVersion': 'DTK バージョン',
|
||||
'clusters.addworker.runCommand': 'Run Command',
|
||||
'clusters.addworker.specifyWorkerIP': 'Worker IP',
|
||||
'clusters.addworker.detectWorkerIP': 'Worker IP を自動検出',
|
||||
@@ -112,8 +117,6 @@ export default {
|
||||
'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.',
|
||||
|
||||
@@ -170,6 +170,7 @@ export default {
|
||||
'common.time.hour': '時間',
|
||||
'common.time.minute': '分',
|
||||
'common.issue.report': '問題を報告',
|
||||
'common.github.star.tooltip': 'GitHub でスターをつける',
|
||||
'common.social.discord': 'Discordに参加',
|
||||
'common.table.mark': 'コメント',
|
||||
'common.table.rollback.mark': 'ロールバックコメント',
|
||||
@@ -260,6 +261,10 @@ export default {
|
||||
'common.login.auth': 'Authenticating...',
|
||||
'common.login.auth.failed': 'Authentication failed',
|
||||
'common.login.password': 'Log in with Password',
|
||||
'common.login.username.holder': 'Please enter username',
|
||||
'common.login.password.holder': 'Please enter password',
|
||||
'common.login.newpassword.holder': 'Please enter new password',
|
||||
'common.login.confirm.holder': 'Please enter password again',
|
||||
'common.external.login': 'Log in with {type}',
|
||||
'common.sso.noConfig':
|
||||
'Single sign-on is not enabled on this system. Please contact your administrator.',
|
||||
|
||||
@@ -119,6 +119,8 @@ export default {
|
||||
'gpuservice.instance.sliced': '分割',
|
||||
'gpuservice.instance.memory': 'VRAM',
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.os': 'OS',
|
||||
'gpuservice.instance.arch': 'アーキテクチャ',
|
||||
'gpuservice.instance.disk': 'ディスク',
|
||||
'gpuservice.table.count': '数量',
|
||||
'gpuservice.instance.disk.system': 'システムディスク',
|
||||
@@ -172,5 +174,9 @@ export default {
|
||||
'Data is cleared when the instance stops.',
|
||||
'gpuservice.storage.persistentVolume.tips':
|
||||
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
||||
'gpuservice.form.storage.select': 'ストレージを選択'
|
||||
'gpuservice.form.storage.select': 'ストレージを選択',
|
||||
'gpuservice.creator': '作成者',
|
||||
'gpuservice.owner.global': 'グローバル',
|
||||
'gpuservice.template.group.yours': '自分のテンプレート',
|
||||
'gpuservice.template.group.global': 'グローバルテンプレート'
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ export default {
|
||||
'menu.usage.usage': '使用状況',
|
||||
'menu.billingAndUsage': '使用状況と請求',
|
||||
'menu.billingAndUsage.usage': '使用状況',
|
||||
'menu.billingAndUsage.billing': '請求',
|
||||
'menu.404': '404',
|
||||
'menu.settings': 'Settings',
|
||||
'menu.resources.workers': 'Workers',
|
||||
|
||||
@@ -90,6 +90,7 @@ export default {
|
||||
// --- Resource events ---
|
||||
'usage.events.resourceType': 'リソースタイプ',
|
||||
'usage.events.eventType': 'イベントタイプ',
|
||||
'usage.events.resourceName': '名前で絞り込み',
|
||||
'usage.events.col.time': '時刻',
|
||||
'usage.events.col.resource': 'リソース',
|
||||
'usage.events.col.event': 'イベント',
|
||||
|
||||
@@ -23,11 +23,12 @@ export default {
|
||||
'backend.form.defaultExecuteCommand': 'Команда выполнения по умолчанию',
|
||||
'backend.form.defaultExecuteCommand.tips': `'{{'model_path'}}', '{{'port'}}', '{{'worker_ip'}}' и '{{'model_name'}}' заполняются реальными значениями во время запуска`,
|
||||
'backend.form.defaultBackendParameters': 'Параметры бэкенда по умолчанию',
|
||||
'backend.form.parameterFormat': 'Формат параметров',
|
||||
'backend.form.parameterFormat.default': 'По умолчанию бэкенда',
|
||||
'backend.form.parameterFormat.space': 'Пробел (--key value)',
|
||||
'backend.form.parameterFormat.equal': 'Знак равенства (--key=value)',
|
||||
'backend.form.commonParameters': 'Часто используемые параметры',
|
||||
'backend.form.flagFormat': 'Формат флага',
|
||||
'backend.form.flagFormat.tips':
|
||||
'Формат соединения опции и её значения. Оставьте пустым, чтобы сохранить каждый параметр в исходном виде, без приведения к единому формату.',
|
||||
'backend.form.flagFormat.space': 'Разделение пробелом (--key value)',
|
||||
'backend.form.flagFormat.equal': 'Знак равенства (--key=value)',
|
||||
'backend.form.commonParameters': 'Общие параметры бэкенда',
|
||||
'backend.form.commonParameters.tips':
|
||||
'Отображаются как подсказки в поле параметров бэкенда при развёртывании.',
|
||||
'backend.form.versionConfig': 'Конфигурация версий',
|
||||
|
||||
@@ -37,8 +37,8 @@ export default {
|
||||
'Stress test for long-context handling. Evaluates KV cache behavior, memory usage, and backend stability.',
|
||||
'benchmark.form.profile.heavy.tips':
|
||||
'Decode-heavy generation benchmark. Measures sustained decoding speed and output token throughput.',
|
||||
'benchmark.table.filter.bygpu': 'Filter by GPU',
|
||||
'benchmark.table.filter.bymodel': 'Filter by Model',
|
||||
'benchmark.table.filter.bygpu': 'Поиск GPU',
|
||||
'benchmark.table.filter.bymodel': 'Поиск модели',
|
||||
'benchmark.table.filter.bydataset': 'Filter by Dataset',
|
||||
'benchmark.table.filter.byProfile': 'Filter by Profile',
|
||||
'benchmark.table.avg': 'Avg',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
'billing.upsell.title': 'Billing is an Enterprise feature',
|
||||
'billing.upsell.subtitle':
|
||||
'Track spend, generate invoices, and enforce budgets across teams. Upgrade to GPUStack Enterprise to manage billing.',
|
||||
'billing.upsell.featuresTitle': 'What you get in Enterprise',
|
||||
'billing.upsell.feature.usage':
|
||||
'See cost breakdowns by organization, user, and model',
|
||||
'billing.upsell.feature.invoices':
|
||||
'Generate invoices and export billing reports',
|
||||
'billing.upsell.feature.budgets':
|
||||
'Set budgets and spending limits with alerts',
|
||||
'billing.upsell.feature.chargeback':
|
||||
'Attribute and charge back usage to teams and projects',
|
||||
'billing.upsell.cta': 'Learn about Enterprise'
|
||||
};
|
||||
@@ -70,8 +70,13 @@ export default {
|
||||
'Для <span class="bold-text">не-Docker</span> кластеров, пожалуйста, регистрируйте кластеры или управляйте пулами воркеров на странице Кластеры.',
|
||||
'clusters.addworker.selectGPU': 'Выбрать производителя GPU',
|
||||
'clusters.addworker.selectGPU.multiTag': 'Multi-select',
|
||||
'clusters.addworker.selectGPU.subtitle':
|
||||
'Вы можете выбрать несколько производителей GPU или не выбирать для кластера только с CPU',
|
||||
'clusters.addworker.checkEnv': 'Проверить окружение',
|
||||
'clusters.addworker.checkEnv.cpuOnlyTips':
|
||||
'Используйте следующую команду, чтобы убедиться, что в кластере Kubernetes есть хотя бы один готовый узел. Вы регистрируете кластер только с CPU.',
|
||||
'clusters.addworker.specifyArgs': 'Указать аргументы',
|
||||
'clusters.addworker.dtkVersion': 'Версия DTK',
|
||||
'clusters.addworker.runCommand': 'Выполнить команду',
|
||||
'clusters.addworker.specifyWorkerIP': 'Указать IP воркера',
|
||||
'clusters.addworker.detectWorkerIP': 'Автоматически определить IP воркера',
|
||||
@@ -112,8 +117,6 @@ export default {
|
||||
'clusters.create.workerConfig': 'Конфигурация воркера',
|
||||
'clusters.edit.k8sOptions.changed.tip':
|
||||
'Вы изменили параметры Kubernetes. Чтобы изменения вступили в силу, повторно выполните команду регистрации в целевом кластере.',
|
||||
'clusters.edit.workerConfig.tip':
|
||||
'Изменения конфигурации воркера вступают в силу только после перезапуска соответствующих воркеров.',
|
||||
'clusters.addworker.containerName': 'Имя контейнера воркера',
|
||||
'clusters.addworker.containerName.tips':
|
||||
'Укажите имя для контейнера воркера.',
|
||||
|
||||
@@ -167,6 +167,7 @@ export default {
|
||||
'common.time.hour': 'Час',
|
||||
'common.time.minute': 'Минута',
|
||||
'common.issue.report': 'Сообщить о проблеме',
|
||||
'common.github.star.tooltip': 'Поставьте нам звезду на GitHub',
|
||||
'common.social.discord': 'Присоединиться к Discord',
|
||||
'common.table.mark': 'Комментарий',
|
||||
'common.table.rollback.mark': 'Комментарий к откату',
|
||||
@@ -259,6 +260,10 @@ export default {
|
||||
'common.login.auth': 'Аутентификация...',
|
||||
'common.login.auth.failed': 'Ошибка аутентификации',
|
||||
'common.login.password': 'Войти с паролем',
|
||||
'common.login.username.holder': 'Введите имя пользователя',
|
||||
'common.login.password.holder': 'Введите пароль',
|
||||
'common.login.newpassword.holder': 'Введите новый пароль',
|
||||
'common.login.confirm.holder': 'Введите пароль ещё раз',
|
||||
'common.external.login': 'Войти через {type}',
|
||||
'common.sso.noConfig':
|
||||
'Единый вход не настроен в этой системе. Пожалуйста, обратитесь к администратору.',
|
||||
|
||||
@@ -118,6 +118,8 @@ export default {
|
||||
'gpuservice.instance.sliced': 'Разделено',
|
||||
'gpuservice.instance.memory': 'VRAM',
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.os': 'ОС',
|
||||
'gpuservice.instance.arch': 'Архитектура',
|
||||
'gpuservice.instance.disk': 'Диск',
|
||||
'gpuservice.table.count': 'Количество',
|
||||
'gpuservice.instance.disk.system': 'Системный диск',
|
||||
@@ -169,5 +171,9 @@ export default {
|
||||
'Data is cleared when the instance stops.',
|
||||
'gpuservice.storage.persistentVolume.tips':
|
||||
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
||||
'gpuservice.form.storage.select': 'Выберите хранилище'
|
||||
'gpuservice.form.storage.select': 'Выберите хранилище',
|
||||
'gpuservice.creator': 'Создатель',
|
||||
'gpuservice.owner.global': 'Глобальный',
|
||||
'gpuservice.template.group.yours': 'Ваши шаблоны',
|
||||
'gpuservice.template.group.global': 'Глобальные шаблоны'
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ export default {
|
||||
'menu.usage.usage': 'Использование',
|
||||
'menu.billingAndUsage': 'Использование и биллинг',
|
||||
'menu.billingAndUsage.usage': 'Использование',
|
||||
'menu.billingAndUsage.billing': 'Биллинг',
|
||||
'menu.404': 'Ошибка 404',
|
||||
'menu.resources.workers': 'Воркеры',
|
||||
'menu.resources.gpus': 'GPUs',
|
||||
|
||||
@@ -90,6 +90,7 @@ export default {
|
||||
// --- Resource events ---
|
||||
'usage.events.resourceType': 'Тип ресурса',
|
||||
'usage.events.eventType': 'Тип события',
|
||||
'usage.events.resourceName': 'Фильтр по имени',
|
||||
'usage.events.col.time': 'Время',
|
||||
'usage.events.col.resource': 'Ресурс',
|
||||
'usage.events.col.event': 'Событие',
|
||||
|
||||
@@ -23,11 +23,12 @@ export default {
|
||||
'backend.form.defaultExecuteCommand': 'Varsayılan Çalıştırma Komutu',
|
||||
'backend.form.defaultExecuteCommand.tips': `'{{'model_path'}}', '{{'port'}}', '{{'worker_ip'}}' ve '{{'model_name'}}' dağıtım sırasında gerçek değerlerle değiştirilecek yer tutuculardır.`,
|
||||
'backend.form.defaultBackendParameters': 'Varsayılan Altyapı Parametreleri',
|
||||
'backend.form.parameterFormat': 'Parametre Biçimi',
|
||||
'backend.form.parameterFormat.default': 'Varsayılan',
|
||||
'backend.form.parameterFormat.space': 'Boşluk (--key value)',
|
||||
'backend.form.parameterFormat.equal': 'Eşittir (--key=value)',
|
||||
'backend.form.commonParameters': 'Yaygın Parametreler',
|
||||
'backend.form.flagFormat': 'Bayrak Biçimi',
|
||||
'backend.form.flagFormat.tips':
|
||||
'Bir seçenek ile değerinin birleştirilme biçimi. Boş bırakılırsa her parametre girildiği biçimde korunur, biçim birleştirilmez.',
|
||||
'backend.form.flagFormat.space': 'Boşlukla Ayır (--key value)',
|
||||
'backend.form.flagFormat.equal': 'Eşittir İşareti (--key=value)',
|
||||
'backend.form.commonParameters': 'Ortak Altyapı Parametreleri',
|
||||
'backend.form.commonParameters.tips':
|
||||
'Dağıtım sırasında altyapı parametreleri girişinde öneri olarak gösterilir.',
|
||||
'backend.form.versionConfig': 'Sürüm Yapılandırması',
|
||||
|
||||
@@ -37,8 +37,8 @@ export default {
|
||||
'Uzun bağlam işleme için stres testi. KV önbellek davranışını, bellek kullanımını ve altyapı kararlılığını değerlendirir.',
|
||||
'benchmark.form.profile.heavy.tips':
|
||||
'Çözümleme ağırlıklı üretim kıyaslaması. Sürekli çözümleme hızını ve çıkış token verimini ölçer.',
|
||||
'benchmark.table.filter.bygpu': "GPU'ya göre filtrele",
|
||||
'benchmark.table.filter.bymodel': 'Modele göre filtrele',
|
||||
'benchmark.table.filter.bygpu': 'GPU ara',
|
||||
'benchmark.table.filter.bymodel': 'Model ara',
|
||||
'benchmark.table.filter.bydataset': 'Veri Kümesine göre filtrele',
|
||||
'benchmark.table.filter.byProfile': 'Profile göre filtrele',
|
||||
'benchmark.table.avg': 'Ort.',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
'billing.upsell.title': 'Billing is an Enterprise feature',
|
||||
'billing.upsell.subtitle':
|
||||
'Track spend, generate invoices, and enforce budgets across teams. Upgrade to GPUStack Enterprise to manage billing.',
|
||||
'billing.upsell.featuresTitle': 'What you get in Enterprise',
|
||||
'billing.upsell.feature.usage':
|
||||
'See cost breakdowns by organization, user, and model',
|
||||
'billing.upsell.feature.invoices':
|
||||
'Generate invoices and export billing reports',
|
||||
'billing.upsell.feature.budgets':
|
||||
'Set budgets and spending limits with alerts',
|
||||
'billing.upsell.feature.chargeback':
|
||||
'Attribute and charge back usage to teams and projects',
|
||||
'billing.upsell.cta': 'Learn about Enterprise'
|
||||
};
|
||||
@@ -70,8 +70,13 @@ export default {
|
||||
'<span class="bold-text">Docker dışı</span> kümeler için lütfen Kümeler sayfasından küme kaydı oluşturun veya işçi havuzlarını yönetin.',
|
||||
'clusters.addworker.selectGPU': 'GPU Üreticisi Seç',
|
||||
'clusters.addworker.selectGPU.multiTag': 'Multi-select',
|
||||
'clusters.addworker.selectGPU.subtitle':
|
||||
'Birden fazla GPU Üreticisi seçebilir veya yalnızca CPU kümeleri için hiçbirini seçmeyebilirsiniz',
|
||||
'clusters.addworker.checkEnv': 'Ortamı Kontrol Et',
|
||||
'clusters.addworker.checkEnv.cpuOnlyTips':
|
||||
'Kubernetes kümesinde en az bir hazır düğüm olduğunu doğrulamak için aşağıdaki komutu kullanın. Yalnızca CPU kümelerini kaydediyorsunuz.',
|
||||
'clusters.addworker.specifyArgs': 'Argümanları Belirle',
|
||||
'clusters.addworker.dtkVersion': 'DTK Sürümü',
|
||||
'clusters.addworker.runCommand': 'Komutu Çalıştır',
|
||||
'clusters.addworker.specifyWorkerIP': "İşçi Düğüm IP'si",
|
||||
'clusters.addworker.detectWorkerIP': "İşçi Düğüm IP'sini Otomatik Algıla",
|
||||
@@ -112,8 +117,6 @@ export default {
|
||||
'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.',
|
||||
|
||||
@@ -167,6 +167,7 @@ export default {
|
||||
'common.time.hour': 'saat',
|
||||
'common.time.minute': 'dakika',
|
||||
'common.issue.report': 'Sorun bildir',
|
||||
'common.github.star.tooltip': "GitHub'da bize yıldız verin",
|
||||
'common.social.discord': "Discord'umuza Katılın",
|
||||
'common.table.mark': 'Yorum',
|
||||
'common.table.rollback.mark': 'Geri Alma Yorumu',
|
||||
@@ -263,6 +264,10 @@ export default {
|
||||
'common.login.auth': 'Kimlik doğrulanıyor...',
|
||||
'common.login.auth.failed': 'Kimlik doğrulama başarısız',
|
||||
'common.login.password': 'Şifre ile giriş yap',
|
||||
'common.login.username.holder': 'Lütfen kullanıcı adını girin',
|
||||
'common.login.password.holder': 'Lütfen şifreyi girin',
|
||||
'common.login.newpassword.holder': 'Lütfen yeni şifreyi girin',
|
||||
'common.login.confirm.holder': 'Lütfen şifreyi tekrar girin',
|
||||
'common.external.login': '{type} ile giriş yap',
|
||||
'common.sso.noConfig':
|
||||
'Bu sistemde çoklu oturum açma etkinleştirilmemiş. Lütfen yöneticinize başvurun.',
|
||||
|
||||
@@ -114,6 +114,8 @@ export default {
|
||||
'gpuservice.instance.sliced': 'Bölünmüş',
|
||||
'gpuservice.instance.memory': 'VRAM',
|
||||
'gpuservice.instance.ram': 'RAM',
|
||||
'gpuservice.instance.os': 'OS',
|
||||
'gpuservice.instance.arch': 'Mimari',
|
||||
'gpuservice.instance.disk': 'Disk',
|
||||
'gpuservice.table.count': 'Sayı',
|
||||
'gpuservice.instance.disk.system': 'Sistem Diski',
|
||||
@@ -167,5 +169,9 @@ export default {
|
||||
'Data is cleared when the instance stops.',
|
||||
'gpuservice.storage.persistentVolume.tips':
|
||||
'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ç'
|
||||
'gpuservice.form.storage.select': 'Depolamayı Seç',
|
||||
'gpuservice.creator': 'Oluşturan',
|
||||
'gpuservice.owner.global': 'Genel',
|
||||
'gpuservice.template.group.yours': 'Şablonlarınız',
|
||||
'gpuservice.template.group.global': 'Genel Şablonlar'
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ export default {
|
||||
'menu.usage.usage': 'Kullanım',
|
||||
'menu.billingAndUsage': 'Kullanım ve Faturalandırma',
|
||||
'menu.billingAndUsage.usage': 'Kullanım',
|
||||
'menu.billingAndUsage.billing': 'Faturalandırma',
|
||||
'menu.404': '404',
|
||||
'menu.resources.clusters': 'Kümeler',
|
||||
'menu.resources.credentials': 'Bulut Kimlik Bilgileri',
|
||||
|
||||
@@ -90,6 +90,7 @@ export default {
|
||||
// --- Resource events ---
|
||||
'usage.events.resourceType': 'Kaynak türü',
|
||||
'usage.events.eventType': 'Olay türü',
|
||||
'usage.events.resourceName': 'Ada göre filtrele',
|
||||
'usage.events.col.time': 'Zaman',
|
||||
'usage.events.col.resource': 'Kaynak',
|
||||
'usage.events.col.event': 'Olay',
|
||||
|
||||
@@ -23,11 +23,12 @@ export default {
|
||||
'backend.form.defaultExecuteCommand': '默认执行命令',
|
||||
'backend.form.defaultExecuteCommand.tips': `'{{'model_path'}}'、'{{'port'}}'、'{{'worker_ip'}}' 和 '{{'model_name'}}' 都是占位符,在部署过程中会被替换为实际的值。`,
|
||||
'backend.form.defaultBackendParameters': '默认后端参数',
|
||||
'backend.form.parameterFormat': '参数输出格式',
|
||||
'backend.form.parameterFormat.default': '后端默认',
|
||||
'backend.form.parameterFormat.space': '空格分隔 (--key value)',
|
||||
'backend.form.parameterFormat.equal': '等号连接 (--key=value)',
|
||||
'backend.form.commonParameters': '常用参数',
|
||||
'backend.form.flagFormat': '参数格式',
|
||||
'backend.form.flagFormat.tips':
|
||||
'选项名与取值之间使用的连接格式。留空则保留每个参数原本的写法,不做统一转换。',
|
||||
'backend.form.flagFormat.space': '空格分隔 (--key value)',
|
||||
'backend.form.flagFormat.equal': '等号连接 (--key=value)',
|
||||
'backend.form.commonParameters': '常用后端参数',
|
||||
'backend.form.commonParameters.tips': '部署模型时作为后端参数候选项展示。',
|
||||
'backend.form.versionConfig': '版本配置',
|
||||
'backend.form.addParameter': '添加参数',
|
||||
|
||||
@@ -37,8 +37,8 @@ export default {
|
||||
'长上下文压力测试。评估 KV Cache 行为、内存占用及后端稳定性。',
|
||||
'benchmark.form.profile.heavy.tips':
|
||||
'以解码为主的生成测试。衡量持续解码速度与输出 Token 吞吐能力。',
|
||||
'benchmark.table.filter.bygpu': '按 GPU 过滤',
|
||||
'benchmark.table.filter.bymodel': '按模型过滤',
|
||||
'benchmark.table.filter.bygpu': 'GPU 查询',
|
||||
'benchmark.table.filter.bymodel': '模型查询',
|
||||
'benchmark.table.filter.bydataset': '按数据集过滤',
|
||||
'benchmark.table.filter.byProfile': '按模式过滤',
|
||||
'benchmark.table.avg': '均值',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
'billing.upsell.title': '计费是企业版功能',
|
||||
'billing.upsell.subtitle':
|
||||
'在团队间跟踪花费、生成账单并执行预算。升级到 GPUStack 企业版即可管理计费。',
|
||||
'billing.upsell.featuresTitle': '企业版包含的能力',
|
||||
'billing.upsell.feature.usage': '按组织、用户与模型查看成本明细',
|
||||
'billing.upsell.feature.invoices': '生成账单并导出计费报表',
|
||||
'billing.upsell.feature.budgets': '设置预算与花费上限并触发告警',
|
||||
'billing.upsell.feature.chargeback': '将用量归属并分摊到团队与项目',
|
||||
'billing.upsell.cta': '了解企业版'
|
||||
};
|
||||
@@ -68,8 +68,13 @@ export default {
|
||||
'<span class="bold-text">非 Docker</span> 集群请前往集群页面注册集群或管理节点池。',
|
||||
'clusters.addworker.selectGPU': '选择 GPU 厂商',
|
||||
'clusters.addworker.selectGPU.multiTag': '可多选',
|
||||
'clusters.addworker.selectGPU.subtitle':
|
||||
'可选择多个 GPU 厂商,或不选择以用于仅 CPU 的集群',
|
||||
'clusters.addworker.checkEnv': '检查环境',
|
||||
'clusters.addworker.checkEnv.cpuOnlyTips':
|
||||
'使用以下命令验证 Kubernetes 集群中至少有一个就绪节点。你正在注册仅 CPU 的集群。',
|
||||
'clusters.addworker.specifyArgs': '指定参数',
|
||||
'clusters.addworker.dtkVersion': 'DTK 版本',
|
||||
'clusters.addworker.runCommand': '运行指令',
|
||||
'clusters.addworker.specifyWorkerIP': '节点 IP',
|
||||
'clusters.addworker.detectWorkerIP': '自动检测节点 IP',
|
||||
@@ -110,8 +115,6 @@ export default {
|
||||
'clusters.create.workerConfig': '节点配置',
|
||||
'clusters.edit.k8sOptions.changed.tip':
|
||||
'您已修改 Kubernetes 选项,需要在目标集群上重新运行注册命令才会生效。',
|
||||
'clusters.edit.workerConfig.tip':
|
||||
'修改节点配置后,需要重启对应节点才会生效。',
|
||||
'clusters.addworker.containerName': '节点容器名称',
|
||||
'clusters.addworker.containerName.tips': '为节点容器指定一个名称。',
|
||||
'clusters.addworker.dataVolume': 'GPUStack 数据卷',
|
||||
|
||||
@@ -161,6 +161,7 @@ export default {
|
||||
'common.time.hour': '小时',
|
||||
'common.time.minute': '分钟',
|
||||
'common.issue.report': '问题反馈',
|
||||
'common.github.star.tooltip': '在 GitHub 上 Star 我们',
|
||||
'common.social.discord': '加入我们的Discord',
|
||||
'common.table.mark': '备注',
|
||||
'common.table.rollback.mark': '回滚备注',
|
||||
@@ -253,6 +254,10 @@ export default {
|
||||
'common.login.auth': '认证中...',
|
||||
'common.login.auth.failed': '认证失败',
|
||||
'common.login.password': '使用密码登录',
|
||||
'common.login.username.holder': '请输入用户名',
|
||||
'common.login.password.holder': '请输入密码',
|
||||
'common.login.newpassword.holder': '请输入新密码',
|
||||
'common.login.confirm.holder': '请再次输入密码',
|
||||
'common.external.login': '使用 {type} 登录',
|
||||
'common.sso.noConfig': '该系统未启用单点登录,请联系管理员。',
|
||||
'common.button.edit.item': '编辑 {name}',
|
||||
@@ -260,7 +265,7 @@ export default {
|
||||
'common.button.terminal': '终端',
|
||||
'common.button.addItem': '添加项',
|
||||
'common.help.default': '默认: {content}',
|
||||
'common.filter.status': '按状态筛选',
|
||||
'common.filter.status': '按状态过滤',
|
||||
'common.form.rule.selectInput': '请选择或输入{name}',
|
||||
'common.tag.experimental': '实验性',
|
||||
'common.title.example': '示例',
|
||||
|
||||
@@ -109,6 +109,8 @@ export default {
|
||||
'gpuservice.instance.sliced': '切分',
|
||||
'gpuservice.instance.memory': '显存',
|
||||
'gpuservice.instance.ram': '内存',
|
||||
'gpuservice.instance.os': '系统',
|
||||
'gpuservice.instance.arch': '架构',
|
||||
'gpuservice.instance.disk': '磁盘',
|
||||
'gpuservice.table.count': '数量',
|
||||
'gpuservice.instance.disk.system': '系统盘',
|
||||
@@ -156,5 +158,9 @@ export default {
|
||||
'gpuservice.storage.tempCapacity.required': '请输入临时存储容量',
|
||||
'gpuservice.form.rule.name':
|
||||
'由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。',
|
||||
'gpuservice.form.storage.select': '选择存储'
|
||||
'gpuservice.form.storage.select': '选择存储',
|
||||
'gpuservice.creator': '创建者',
|
||||
'gpuservice.owner.global': '全局',
|
||||
'gpuservice.template.group.yours': '我的模板',
|
||||
'gpuservice.template.group.global': '全局模板'
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ export default {
|
||||
'menu.usage.usage': '使用量',
|
||||
'menu.billingAndUsage': '用量与计费',
|
||||
'menu.billingAndUsage.usage': '用量统计',
|
||||
'menu.billingAndUsage.billing': '计费',
|
||||
'menu.404': '404',
|
||||
'menu.resources.workers': '节点',
|
||||
'menu.resources.gpus': 'GPU',
|
||||
|
||||
@@ -99,7 +99,7 @@ export default {
|
||||
'models.form.search.gguftips':
|
||||
'当 macOS 或 Windows 作节点时勾选 GGUF(搜索语音模型时取消勾选)',
|
||||
'models.form.button.addlabel': '添加标签',
|
||||
'models.filter.category': '按类别筛选',
|
||||
'models.filter.category': '按类别过滤',
|
||||
'models.list.more.logs': '查看更多',
|
||||
'models.catalog.release.date': '发布日期',
|
||||
'models.localpath.gguf.tips.title': 'GGUF 格式模型',
|
||||
|
||||
@@ -90,6 +90,7 @@ export default {
|
||||
// --- Resource events ---
|
||||
'usage.events.resourceType': '资源类型',
|
||||
'usage.events.eventType': '事件类型',
|
||||
'usage.events.resourceName': '按名称筛选',
|
||||
'usage.events.col.time': '时间',
|
||||
'usage.events.col.resource': '资源',
|
||||
'usage.events.col.event': '事件',
|
||||
|
||||
@@ -21,6 +21,13 @@ export interface BarChartProps {
|
||||
labelFormatter?: (val: any, index?: number) => string;
|
||||
tooltipValueFormatter?: (val: any) => string;
|
||||
title?: string;
|
||||
grid?: {
|
||||
left?: number | string;
|
||||
right?: number | string;
|
||||
top?: number | string;
|
||||
bottom?: number | string;
|
||||
containLabel?: boolean;
|
||||
};
|
||||
legendIsolate?: boolean;
|
||||
}
|
||||
|
||||
@@ -34,6 +41,7 @@ const BarChart: React.FC<BarChartProps> = (props) => {
|
||||
labelFormatter,
|
||||
tooltipValueFormatter,
|
||||
title,
|
||||
grid,
|
||||
legendIsolate
|
||||
} = props;
|
||||
const { token } = theme.useToken();
|
||||
@@ -126,8 +134,9 @@ const BarChart: React.FC<BarChartProps> = (props) => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: title ? 30 : 10,
|
||||
bottom: 0,
|
||||
containLabel: true
|
||||
bottom: 8,
|
||||
containLabel: true,
|
||||
...grid
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||
import {
|
||||
AlertBlockInfo,
|
||||
Input as CInput,
|
||||
@@ -45,7 +46,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
const intl = useIntl();
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [apikeyValue, setAPIKeyValue] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { loading, guard, run, release } = useSubmitLock();
|
||||
const [isChanged, setIsChanged] = useState(false);
|
||||
const cacheFormRef = useRef<{
|
||||
allowed_type: string;
|
||||
@@ -116,31 +117,31 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
};
|
||||
|
||||
const handleOnOk = async (formdata: FormData) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = {
|
||||
..._.omit(formdata, ['allowed_type']),
|
||||
allowed_model_names:
|
||||
formdata.allowed_type === 'all' ||
|
||||
!formdata.scope?.includes('inference')
|
||||
? []
|
||||
: formdata.allowed_model_names || []
|
||||
};
|
||||
if (action === PageAction.CREATE) {
|
||||
await createAPIKey(data);
|
||||
} else if (action === PageAction.EDIT && currentData?.id) {
|
||||
await updateAPIKey({
|
||||
..._.omit(data, ['expires_in'])
|
||||
});
|
||||
await run(async () => {
|
||||
try {
|
||||
const data = {
|
||||
..._.omit(formdata, ['allowed_type']),
|
||||
allowed_model_names:
|
||||
formdata.allowed_type === 'all' ||
|
||||
!formdata.scope?.includes('inference')
|
||||
? []
|
||||
: formdata.allowed_model_names || []
|
||||
};
|
||||
if (action === PageAction.CREATE) {
|
||||
await createAPIKey(data);
|
||||
} else if (action === PageAction.EDIT && currentData?.id) {
|
||||
await updateAPIKey({
|
||||
..._.omit(data, ['expires_in'])
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// handled in interceptor
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSumit = () => {
|
||||
form.submit();
|
||||
guard(() => form.submit());
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
@@ -278,6 +279,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
name="addAPIKey"
|
||||
form={form}
|
||||
onFinish={handleOnOk}
|
||||
onFinishFailed={release}
|
||||
preserve={false}
|
||||
initialValues={{
|
||||
allowed_type: 'all',
|
||||
|
||||
@@ -3,6 +3,13 @@ export interface ListItem {
|
||||
description: string;
|
||||
id: number;
|
||||
value: string;
|
||||
masked_value?: string;
|
||||
user_id?: number;
|
||||
user_name?: string;
|
||||
// The owning principal — an Org, or a USER principal when the key
|
||||
// was created in someone's Personal Org. Read by the enterprise
|
||||
// plugin's Organization column in the admin All-org view.
|
||||
owner_principal_id?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// columns.ts
|
||||
import { tableSorter } from '@/config/settings';
|
||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||
import { DashboardOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
AutoTooltip,
|
||||
@@ -48,6 +49,7 @@ const useModelsColumns = ({
|
||||
onConfigAction
|
||||
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
||||
const intl = useIntl();
|
||||
const pluginCols = usePluginListColumns('apiKeys');
|
||||
|
||||
const actionList = useMemo<APIKeyAction[]>(() => {
|
||||
// Built-ins use a step-of-10 priority scale so plugins have room
|
||||
@@ -124,6 +126,12 @@ const useModelsColumns = ({
|
||||
}, [intl, configActions, onConfigAction]);
|
||||
|
||||
return useMemo(() => {
|
||||
const pluginRendered = pluginCols.map((c) => ({
|
||||
title: intl.formatMessage({ id: c.titleId }),
|
||||
key: c.key,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (_text: any, record: ListItem) => c.render(record)
|
||||
}));
|
||||
return [
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.name' }),
|
||||
@@ -152,6 +160,7 @@ const useModelsColumns = ({
|
||||
</span>
|
||||
)
|
||||
},
|
||||
...pluginRendered,
|
||||
{
|
||||
title: intl.formatMessage({ id: 'apikeys.table.key' }),
|
||||
dataIndex: 'masked_value',
|
||||
@@ -269,7 +278,7 @@ const useModelsColumns = ({
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [intl, showCreator, handleSelect, actionList]);
|
||||
}, [intl, showCreator, handleSelect, actionList, pluginCols]);
|
||||
};
|
||||
|
||||
export default useModelsColumns;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||
import {
|
||||
AlertBlockInfo,
|
||||
GSDrawer,
|
||||
@@ -63,6 +64,7 @@ const AddModal: React.FC<AddModalProps> = (props) => {
|
||||
const [yamlContent, setYamlContent] = useState<string>('');
|
||||
const [formContent, setFormContent] = useState<FormData>({} as FormData);
|
||||
const alertRef = useRef<HTMLDivElement>(null);
|
||||
const { loading, guard, run, release } = useSubmitLock();
|
||||
|
||||
const showVersionCustomSuffix =
|
||||
currentData?.backend_source === BackendSourceValueMap.BUILTIN ||
|
||||
@@ -92,17 +94,22 @@ const AddModal: React.FC<AddModalProps> = (props) => {
|
||||
};
|
||||
|
||||
const onOk = () => {
|
||||
if (activeKey === 'yaml') {
|
||||
const content = editorRef.current?.getContent();
|
||||
if (content) {
|
||||
onSubmitYaml({ content: content });
|
||||
guard(() => {
|
||||
if (activeKey === 'yaml') {
|
||||
const content = editorRef.current?.getContent();
|
||||
if (!content) {
|
||||
// nothing to submit — drop the lock so the button stays usable
|
||||
release();
|
||||
return;
|
||||
}
|
||||
run(() => onSubmitYaml({ content: content }));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
formRef.current?.submit();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onFinish = (values: FormData) => {
|
||||
const onFinish = async (values: FormData) => {
|
||||
const versionConfigs = values.version_configs?.reduce(
|
||||
(acc: Record<string, any>, curr) => {
|
||||
if (curr.version_no) {
|
||||
@@ -117,16 +124,15 @@ const AddModal: React.FC<AddModalProps> = (props) => {
|
||||
|
||||
const defaultVersion = values.version_configs?.find((v) => v.is_default);
|
||||
|
||||
onSubmit({
|
||||
...values,
|
||||
parameter_format:
|
||||
values.parameter_format === 'auto' || !values.parameter_format
|
||||
? null
|
||||
: values.parameter_format,
|
||||
default_version: defaultVersion?.version_no || '',
|
||||
// @ts-ignore
|
||||
version_configs: versionConfigs
|
||||
});
|
||||
await run(() =>
|
||||
onSubmit({
|
||||
...values,
|
||||
parameter_format: values.parameter_format || null,
|
||||
default_version: defaultVersion?.version_no || '',
|
||||
// @ts-ignore
|
||||
version_configs: versionConfigs
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -159,7 +165,7 @@ const AddModal: React.FC<AddModalProps> = (props) => {
|
||||
return {
|
||||
...values,
|
||||
backend_name: values.backend_name.replace(/-custom$/, ''),
|
||||
parameter_format: values.parameter_format ?? 'auto',
|
||||
parameter_format: values.parameter_format,
|
||||
version_configs: versionConfigs,
|
||||
built_in_version_configs: builtInVersions
|
||||
};
|
||||
@@ -279,6 +285,7 @@ const AddModal: React.FC<AddModalProps> = (props) => {
|
||||
<ModalFooter
|
||||
onCancel={onClose}
|
||||
onOk={onOk}
|
||||
loading={loading}
|
||||
style={ModalFooterStyle}
|
||||
></ModalFooter>
|
||||
</>
|
||||
@@ -295,6 +302,7 @@ const AddModal: React.FC<AddModalProps> = (props) => {
|
||||
children: (
|
||||
<BackendForm
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={release}
|
||||
action={action}
|
||||
currentData={formContent as ListItem}
|
||||
ref={formRef}
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface FormData {
|
||||
enabled?: boolean;
|
||||
backend_source?: string;
|
||||
default_env?: Record<string, any>;
|
||||
parameter_format?: 'space' | 'equal' | 'auto' | null;
|
||||
parameter_format?: 'space' | 'equal' | null;
|
||||
common_parameters?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -151,27 +151,24 @@ const BasicForm = () => {
|
||||
</Form.Item>
|
||||
<Form.Item<FormData>
|
||||
name="parameter_format"
|
||||
initialValue="auto"
|
||||
rules={[{ required: false }]}
|
||||
>
|
||||
<SealSelect
|
||||
label={intl.formatMessage({ id: 'backend.form.parameterFormat' })}
|
||||
allowClear
|
||||
label={intl.formatMessage({ id: 'backend.form.flagFormat' })}
|
||||
description={intl.formatMessage({
|
||||
id: 'backend.form.flagFormat.tips'
|
||||
})}
|
||||
options={[
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'backend.form.parameterFormat.default'
|
||||
}),
|
||||
value: 'auto'
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'backend.form.parameterFormat.space'
|
||||
id: 'backend.form.flagFormat.space'
|
||||
}),
|
||||
value: 'space'
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'backend.form.parameterFormat.equal'
|
||||
id: 'backend.form.flagFormat.equal'
|
||||
}),
|
||||
value: 'equal'
|
||||
}
|
||||
@@ -183,6 +180,9 @@ const BasicForm = () => {
|
||||
rules={[{ required: false }]}
|
||||
>
|
||||
<ListInput
|
||||
description={intl.formatMessage({
|
||||
id: 'backend.form.commonParameters.tips'
|
||||
})}
|
||||
btnText={intl.formatMessage({ id: 'backend.form.addParameter' })}
|
||||
label={intl.formatMessage({
|
||||
id: 'backend.form.commonParameters'
|
||||
|
||||
@@ -13,10 +13,11 @@ type AddModalProps = {
|
||||
action: PageActionType;
|
||||
currentData?: ListItem;
|
||||
onFinish: (values: FormData) => void;
|
||||
onFinishFailed?: (errorInfo: any) => void;
|
||||
ref?: any;
|
||||
};
|
||||
const BackendForm: React.FC<AddModalProps> = forwardRef(
|
||||
({ action, currentData, onFinish }, ref) => {
|
||||
({ action, currentData, onFinish, onFinishFailed }, ref) => {
|
||||
const intl = useIntl();
|
||||
const [form] = Form.useForm();
|
||||
const [activeKey, setActiveKey] = React.useState<string[]>([]);
|
||||
@@ -26,7 +27,7 @@ const BackendForm: React.FC<AddModalProps> = forwardRef(
|
||||
currentData?.backend_source === BackendSourceValueMap.BUILTIN ||
|
||||
currentData?.backend_source === BackendSourceValueMap.COMMUNITY;
|
||||
|
||||
const onFinishFailed = (errorInfo: any) => {
|
||||
const handleOnFinishFailed = (errorInfo: any) => {
|
||||
const errorFields = errorInfo.errorFields || [];
|
||||
if (errorFields.length > 0) {
|
||||
const versionError = errorFields.find((field: any) =>
|
||||
@@ -38,6 +39,7 @@ const BackendForm: React.FC<AddModalProps> = forwardRef(
|
||||
setActiveKey([]);
|
||||
}
|
||||
}
|
||||
onFinishFailed?.(errorInfo);
|
||||
};
|
||||
|
||||
const handleOnFinish = (values: FormData) => {
|
||||
@@ -106,7 +108,7 @@ const BackendForm: React.FC<AddModalProps> = forwardRef(
|
||||
preserve={false}
|
||||
scrollToFirstError={true}
|
||||
initialValues={_.omit(currentData, ['version_configs'])}
|
||||
onFinishFailed={onFinishFailed}
|
||||
onFinishFailed={handleOnFinishFailed}
|
||||
>
|
||||
<BasicForm></BasicForm>
|
||||
<VersionsForm
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PageActionType } from '@/config/types';
|
||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||
import { FormDrawer } from '@gpustack/core-ui';
|
||||
import React, { useRef } from 'react';
|
||||
import { FormData, BenchmarkListItem as ListItem } from '../config/types';
|
||||
@@ -22,21 +23,24 @@ const AddBenchmark: React.FC<AddModalProps> = ({
|
||||
open,
|
||||
currentData,
|
||||
clusterList,
|
||||
profilesOptions,
|
||||
datasetList,
|
||||
profilesOptions = [],
|
||||
datasetList = [],
|
||||
onOk,
|
||||
onCancel
|
||||
}) => {
|
||||
const form = useRef<any>(null);
|
||||
const { loading, guard, run, release } = useSubmitLock();
|
||||
|
||||
const handleSubmit = () => {
|
||||
form.current?.submit();
|
||||
guard(() => form.current?.submit());
|
||||
};
|
||||
|
||||
const handleOk = async (data: FormData) => {
|
||||
onOk({
|
||||
...data
|
||||
});
|
||||
await run(() =>
|
||||
onOk({
|
||||
...data
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -51,6 +55,7 @@ const AddBenchmark: React.FC<AddModalProps> = ({
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
width={600}
|
||||
loading={loading}
|
||||
>
|
||||
<BenchmarkForm
|
||||
ref={form}
|
||||
@@ -61,6 +66,7 @@ const AddBenchmark: React.FC<AddModalProps> = ({
|
||||
profilesOptions={profilesOptions}
|
||||
datasetList={datasetList}
|
||||
onFinish={handleOk}
|
||||
onFinishFailed={release}
|
||||
/>
|
||||
</FormDrawer>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { modelCategoriesMap } from '@/pages/llmodels/config';
|
||||
import { SearchOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { FiltersButton } from '@gpustack/core-ui';
|
||||
import { BaseSelect } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, Input, Space } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import { profileOptions } from '../config';
|
||||
|
||||
export interface RightActionsProps {
|
||||
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -38,22 +38,15 @@ const RightActions: React.FC<RightActionsProps> = ({
|
||||
|
||||
const handleGPUChange = debounceUpdateFilter;
|
||||
|
||||
const modelOptions = modelList
|
||||
?.filter((item) => {
|
||||
return item.categories?.includes(modelCategoriesMap.llm);
|
||||
})
|
||||
.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.label
|
||||
}));
|
||||
const handleSearchByModelDebounce = _.debounce((value: any) => {
|
||||
handleQueryChange({
|
||||
page: 1,
|
||||
model_name: value
|
||||
});
|
||||
}, 350);
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<FiltersButton
|
||||
onClick={toggleFilters}
|
||||
count={count}
|
||||
onClear={onClear}
|
||||
></FiltersButton>
|
||||
<Input
|
||||
prefix={
|
||||
<SearchOutlined
|
||||
@@ -63,11 +56,24 @@ const RightActions: React.FC<RightActionsProps> = ({
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'common.filter.name'
|
||||
})}
|
||||
style={{ width: 300 }}
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
onChange={handleInputChange}
|
||||
></Input>
|
||||
{/* <Input
|
||||
<Input
|
||||
prefix={
|
||||
<SearchOutlined
|
||||
style={{ color: 'var(--ant-color-text-placeholder)' }}
|
||||
></SearchOutlined>
|
||||
}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'benchmark.table.filter.bymodel'
|
||||
})}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
onChange={handleSearchByModelDebounce}
|
||||
></Input>
|
||||
<Input
|
||||
prefix={
|
||||
<SearchOutlined
|
||||
style={{ color: 'var(--ant-color-text-placeholder)' }}
|
||||
@@ -104,20 +110,6 @@ const RightActions: React.FC<RightActionsProps> = ({
|
||||
})
|
||||
}
|
||||
></BaseSelect>
|
||||
<BaseSelect
|
||||
allowClear
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'benchmark.table.filter.bymodel'
|
||||
})}
|
||||
style={{ width: 180 }}
|
||||
options={modelOptions}
|
||||
onChange={(value, option) =>
|
||||
handleQueryChange({
|
||||
model_name: value,
|
||||
page: 1
|
||||
})
|
||||
}
|
||||
></BaseSelect> */}
|
||||
<Button
|
||||
type="text"
|
||||
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
||||
|
||||
@@ -87,6 +87,9 @@ export interface FormData {
|
||||
|
||||
export interface BenchmarkListItem extends FormData {
|
||||
id: number;
|
||||
// Inherited from the parent cluster's owner_principal_id on the
|
||||
// wire so per-row tenant filtering works without joining.
|
||||
owner_principal_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
state: string;
|
||||
|
||||
@@ -48,6 +48,15 @@ const BasicForm: React.FC = () => {
|
||||
);
|
||||
}, [orgScoped, scopedClusterList, clusterList, scopeOrgId]);
|
||||
|
||||
const handleOnOrgChange = (value: any) => {
|
||||
form.setFieldsValue({
|
||||
model_name: undefined,
|
||||
model_id: undefined,
|
||||
model_instance_name: undefined,
|
||||
model_instance: undefined
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (action === PageAction.CREATE && orgScoped) {
|
||||
fetchScopedClusters({ page: -1 });
|
||||
@@ -108,7 +117,10 @@ const BasicForm: React.FC = () => {
|
||||
required
|
||||
></CInput.Input>
|
||||
</Form.Item>
|
||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||
<PluginExtraFields
|
||||
name="CreateOrgScopeField"
|
||||
context={{ action, onChange: handleOnOrgChange }}
|
||||
/>
|
||||
<Form.Item<FormData>
|
||||
name="cluster_id"
|
||||
rules={[
|
||||
|
||||
@@ -29,6 +29,7 @@ interface ProviderFormProps {
|
||||
datasetList: Global.BaseOption<number | string>[];
|
||||
profilesOptions: Global.BaseOption<string>[];
|
||||
onFinish: (values: FormData) => Promise<void>;
|
||||
onFinishFailed?: (errorInfo: any) => void;
|
||||
}
|
||||
|
||||
const TABKeysMap = {
|
||||
@@ -42,6 +43,7 @@ const ProviderForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
|
||||
action,
|
||||
currentData,
|
||||
onFinish,
|
||||
onFinishFailed,
|
||||
open,
|
||||
clusterList,
|
||||
profilesOptions,
|
||||
@@ -122,6 +124,7 @@ const ProviderForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={onFinishFailed}
|
||||
initialValues={{
|
||||
dataset_input_tokens: null,
|
||||
dataset_output_tokens: null,
|
||||
|
||||
@@ -9,8 +9,9 @@ import { useQueryModelInstancesList } from '@/pages/llmodels/services/use-query-
|
||||
import { useQueryModelList } from '@/pages/llmodels/services/use-query-model-list';
|
||||
import { Cascader as SealCascader, useAppUtils } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { Form, Tooltip } from 'antd';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormContext } from '../config/form-context';
|
||||
import { FormData } from '../config/types';
|
||||
|
||||
@@ -45,11 +46,7 @@ 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 clusterId = Form.useWatch('cluster_id', form);
|
||||
const [modelList, setModelList] = React.useState<any[]>([]);
|
||||
const {
|
||||
loading: modelLoading,
|
||||
@@ -84,6 +81,16 @@ const ModelInstanceForm: React.FC = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const clearModelInstance = () => {
|
||||
setModelList([]);
|
||||
form.setFieldsValue({
|
||||
model_name: '',
|
||||
model_id: '',
|
||||
model_instance_name: '',
|
||||
model_instance: ''
|
||||
});
|
||||
};
|
||||
|
||||
const loadInstances = async (selectedOptions: any[]) => {
|
||||
const targetOption = selectedOptions[selectedOptions.length - 1];
|
||||
if (targetOption && targetOption.children.length === 0) {
|
||||
@@ -107,10 +114,12 @@ const ModelInstanceForm: React.FC = () => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const initModelInstance = async () => {
|
||||
const initModelInstance = useMemoizedFn(async () => {
|
||||
if (!clusterId) {
|
||||
return;
|
||||
}
|
||||
// fetch model list when dropdown is opened
|
||||
const list = await fetchModelList({ page: -1 });
|
||||
const list = await fetchModelList({ page: -1, cluster_id: clusterId });
|
||||
const modelOptions = list
|
||||
.filter((model: any) => model.replicas > 0)
|
||||
.map((model: any) => ({
|
||||
@@ -124,6 +133,7 @@ const ModelInstanceForm: React.FC = () => {
|
||||
}));
|
||||
|
||||
if (modelOptions.length === 0) {
|
||||
clearModelInstance();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,6 +143,12 @@ const ModelInstanceForm: React.FC = () => {
|
||||
);
|
||||
if (!selectedllmModel) {
|
||||
setModelList(modelOptions);
|
||||
form.setFieldsValue({
|
||||
model_name: '',
|
||||
model_id: '',
|
||||
model_instance_name: '',
|
||||
model_instance: ''
|
||||
});
|
||||
return;
|
||||
}
|
||||
const instanceList = await fetchInstanceList({ id: selectedllmModel.id });
|
||||
@@ -159,33 +175,18 @@ const ModelInstanceForm: React.FC = () => {
|
||||
}
|
||||
|
||||
setModelList(modelOptions);
|
||||
};
|
||||
});
|
||||
|
||||
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, scopeOrgId]);
|
||||
}, [open, action, clusterId]);
|
||||
|
||||
return (
|
||||
<Form.Item<FormData>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// columns.ts
|
||||
import { tableSorter } from '@/config/settings';
|
||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||
import { AutoTooltip } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Typography } from 'antd';
|
||||
@@ -15,8 +16,15 @@ const useBenchmarkColumns = (params: {
|
||||
}): ColumnsType<ListItem> => {
|
||||
const intl = useIntl();
|
||||
const { onCellClick, handleSelect, columns } = params;
|
||||
const pluginCols = usePluginListColumns('benchmarks');
|
||||
|
||||
return useMemo(() => {
|
||||
const pluginRendered = pluginCols.map((c) => ({
|
||||
title: intl.formatMessage({ id: c.titleId }),
|
||||
key: c.key,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (_text: any, record: ListItem) => c.render(record)
|
||||
}));
|
||||
return [
|
||||
{
|
||||
title: (
|
||||
@@ -34,6 +42,7 @@ const useBenchmarkColumns = (params: {
|
||||
</AutoTooltip>
|
||||
)
|
||||
},
|
||||
...pluginRendered,
|
||||
...columns,
|
||||
{
|
||||
title: (
|
||||
@@ -51,7 +60,7 @@ const useBenchmarkColumns = (params: {
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [intl, onCellClick, handleSelect, columns]);
|
||||
}, [intl, onCellClick, handleSelect, columns, pluginCols]);
|
||||
};
|
||||
|
||||
export default useBenchmarkColumns;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { IconFont } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, Result, Typography } from 'antd';
|
||||
import PageBox from '../_components/page-box';
|
||||
|
||||
// OSS-only upsell page for the billing module. The real Billing UI ships
|
||||
// in the enterprise plugin and shadows this route via
|
||||
// `routes.extensions.ts` — when the enterprise plugin is loaded, the
|
||||
// merger removes this entry by name. So this file is what users see on
|
||||
// the OSS build only.
|
||||
const Billing: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const features = [
|
||||
'billing.upsell.feature.usage',
|
||||
'billing.upsell.feature.invoices',
|
||||
'billing.upsell.feature.budgets',
|
||||
'billing.upsell.feature.chargeback'
|
||||
];
|
||||
return (
|
||||
<PageBox>
|
||||
<div
|
||||
style={{
|
||||
// Use a viewport-relative min-height so flex centering has
|
||||
// something to work against — percentage heights through
|
||||
// PageBox's wrapper chain don't always resolve. The offset
|
||||
// accounts for the global header + the page title bar.
|
||||
minHeight: 'calc(100vh - 180px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
icon={
|
||||
<IconFont
|
||||
type="icon-billing-outlined"
|
||||
style={{ fontSize: 64, color: 'var(--ant-color-primary)' }}
|
||||
/>
|
||||
}
|
||||
title={intl.formatMessage({ id: 'billing.upsell.title' })}
|
||||
subTitle={intl.formatMessage({ id: 'billing.upsell.subtitle' })}
|
||||
extra={
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 520,
|
||||
margin: '0 auto',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={5} style={{ marginBottom: 12 }}>
|
||||
{intl.formatMessage({
|
||||
id: 'billing.upsell.featuresTitle'
|
||||
})}
|
||||
</Typography.Title>
|
||||
<ul style={{ paddingLeft: 20, marginBottom: 24 }}>
|
||||
{features.map((id) => (
|
||||
<li key={id} style={{ marginBottom: 6 }}>
|
||||
{intl.formatMessage({ id })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
href="https://gpustack.ai/enterprise"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{intl.formatMessage({ id: 'billing.upsell.cta' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PageBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default Billing;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { AlertBlockInfo, FormDrawer, ModalFooter } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
@@ -39,20 +40,23 @@ const AddCluster: React.FC<AddModalProps> = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const form = useRef<any>(null);
|
||||
const { loading, guard, run, release } = useSubmitLock();
|
||||
// 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();
|
||||
guard(() => form.current?.submit());
|
||||
};
|
||||
|
||||
const handleOk = async (data: FormData) => {
|
||||
onOk({
|
||||
...data,
|
||||
provider
|
||||
});
|
||||
await run(() =>
|
||||
onOk({
|
||||
...data,
|
||||
provider
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -84,6 +88,7 @@ const AddCluster: React.FC<AddModalProps> = ({
|
||||
<ModalFooter
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
loading={loading}
|
||||
style={ModalFooterStyle}
|
||||
></ModalFooter>
|
||||
</>
|
||||
@@ -96,6 +101,7 @@ const AddCluster: React.FC<AddModalProps> = ({
|
||||
action={action}
|
||||
currentData={currentData}
|
||||
onFinish={handleOk}
|
||||
onFinishFailed={release}
|
||||
onK8sOptionsChange={setK8sOptionsChanged}
|
||||
/>
|
||||
</FormDrawer>
|
||||
|
||||
@@ -10,6 +10,7 @@ type ViewModalProps = {
|
||||
containerName?: string;
|
||||
gpustackDataVolume?: string;
|
||||
advertisAddress?: string;
|
||||
dtkVersion?: string;
|
||||
registrationInfo: {
|
||||
token: string;
|
||||
image: string;
|
||||
@@ -26,7 +27,8 @@ const AddWorkerCommand: React.FC<ViewModalProps> = ({
|
||||
cacheDir,
|
||||
currentGPU,
|
||||
containerName,
|
||||
gpustackDataVolume
|
||||
gpustackDataVolume,
|
||||
dtkVersion
|
||||
}) => {
|
||||
const code = React.useMemo(() => {
|
||||
const commandCode = addWorkerGuide['all'];
|
||||
@@ -42,6 +44,7 @@ const AddWorkerCommand: React.FC<ViewModalProps> = ({
|
||||
cacheDir: cacheDir,
|
||||
containerName: containerName,
|
||||
gpustackDataVolume: gpustackDataVolume,
|
||||
dtkVersion: dtkVersion,
|
||||
image: registrationInfo.image,
|
||||
token: registrationInfo.token || '${token}'
|
||||
})
|
||||
@@ -56,7 +59,8 @@ const AddWorkerCommand: React.FC<ViewModalProps> = ({
|
||||
cacheDir,
|
||||
containerName,
|
||||
gpustackDataVolume,
|
||||
advertisAddress
|
||||
advertisAddress,
|
||||
dtkVersion
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -67,7 +67,6 @@ 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());
|
||||
@@ -82,7 +81,6 @@ 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) {
|
||||
@@ -101,24 +99,30 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
|
||||
// Downstream steps (check env, run command, ...) only make sense after a
|
||||
// GPU vendor has been chosen. If the user toggled off every vendor in
|
||||
// multi-select, gate them shut so the wrong panel can't be opened.
|
||||
// Exception: for K8s clusters, Run Command is always accessible (CPU-only
|
||||
// workers don't need a GPU vendor).
|
||||
const selectedGPUs =
|
||||
(summary.get('selectedGPUs') as string[] | undefined) || [];
|
||||
const currentGPU = (summary.get('currentGPU') as string | undefined) || '';
|
||||
const noVendorSelected = !currentGPU && selectedGPUs.length === 0;
|
||||
const downstreamDisabled = disabled || noVendorSelected;
|
||||
const isK8s = provider === ProviderValueMap.Kubernetes;
|
||||
// For non-K8s providers, all downstream steps are gated by vendor selection.
|
||||
// For K8s, both CheckEnv and RunCommand are always accessible — CheckEnv
|
||||
// shows a ready-nodes check when no vendor is selected (CPU-only workers).
|
||||
const downstreamDisabled = disabled || (noVendorSelected && !isK8s);
|
||||
|
||||
React.useEffect(() => {
|
||||
// If the user just deselected everything, collapse any downstream
|
||||
// panel back to the GPU step so they aren't left looking at a stale
|
||||
// disabled-but-open command. Functional updater so we don't have to
|
||||
// depend on `collapseKey` and re-run the effect on every toggle.
|
||||
if (!noVendorSelected) return;
|
||||
// If the user just deselected everything on a non-K8s provider, collapse
|
||||
// any downstream panel back to the GPU step so they aren't left looking at
|
||||
// a stale disabled-but-open command. For K8s, CheckEnv stays open with a
|
||||
// ready-nodes check, so no collapsing needed.
|
||||
if (!noVendorSelected || isK8s) return;
|
||||
setCollapseKey((prev) =>
|
||||
prev.has(StepNamesMap.SelectGPU)
|
||||
? prev
|
||||
: new Set([StepNamesMap.SelectGPU])
|
||||
);
|
||||
}, [noVendorSelected]);
|
||||
}, [noVendorSelected, isK8s]);
|
||||
|
||||
return (
|
||||
<AddWorkerContext.Provider
|
||||
@@ -147,7 +151,9 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
|
||||
!stepList.includes(StepNamesMap.SelectCluster)) && (
|
||||
<>
|
||||
<SelectVendor disabled={disabled}></SelectVendor>
|
||||
<CheckEnvironment disabled={downstreamDisabled}></CheckEnvironment>
|
||||
{(isK8s || !noVendorSelected) && (
|
||||
<CheckEnvironment disabled={disabled}></CheckEnvironment>
|
||||
)}
|
||||
|
||||
{provider === ProviderValueMap.Kubernetes && (
|
||||
<K8sRunCommand disabled={downstreamDisabled}></K8sRunCommand>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BulbOutlined } from '@ant-design/icons';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Alert, Typography } from 'antd';
|
||||
import { ProviderValueMap } from '../../config';
|
||||
import CheckEnvCommand from '../check-env-command';
|
||||
import { useAddWorkerContext } from './add-worker-context';
|
||||
import { AddWorkerStepProps, StepNamesMap } from './config';
|
||||
@@ -10,8 +11,8 @@ import StepCollapse from './step-collapse';
|
||||
const CheckEnvironment: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
const { stepList, summary, provider } = useAddWorkerContext();
|
||||
const intl = useIntl();
|
||||
const currentGPU = summary.get('currentGPU');
|
||||
const currentGPUs: string[] = summary.get('selectedGPUs') || [];
|
||||
const currentGPU = summary.get('currentGPU') as string;
|
||||
const currentGPUs: string[] = (summary.get('selectedGPUs') as string[]) || [];
|
||||
const workerCommand = summary.get('workerCommand') || {
|
||||
label: '',
|
||||
link: '',
|
||||
@@ -19,6 +20,8 @@ const CheckEnvironment: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
};
|
||||
|
||||
const stepIndex = stepList.indexOf(StepNamesMap.CheckEnv) + 1;
|
||||
const isK8s = provider === ProviderValueMap.Kubernetes;
|
||||
const isCPUOnly = isK8s && !currentGPU && currentGPUs.length === 0;
|
||||
|
||||
return (
|
||||
<StepCollapse
|
||||
@@ -31,27 +34,37 @@ const CheckEnvironment: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
</Title>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<BulbOutlined />}
|
||||
style={{
|
||||
marginBottom: 8
|
||||
}}
|
||||
title={
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: intl.formatMessage(
|
||||
{ id: 'clusters.create.addworker.tips' },
|
||||
{ label: workerCommand.label, link: workerCommand.link }
|
||||
)
|
||||
{isCPUOnly ? (
|
||||
<Typography.Paragraph style={{ marginBottom: 8 }}>
|
||||
{intl.formatMessage({
|
||||
id: 'clusters.addworker.checkEnv.cpuOnlyTips'
|
||||
})}
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<BulbOutlined />}
|
||||
style={{
|
||||
marginBottom: 8
|
||||
}}
|
||||
></span>
|
||||
}
|
||||
></Alert>
|
||||
<Typography.Paragraph style={{ marginBottom: 8 }}>
|
||||
{intl.formatMessage({ id: 'cluster.create.checkEnv.tips' })}
|
||||
</Typography.Paragraph>
|
||||
title={
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: intl.formatMessage(
|
||||
{ id: 'clusters.create.addworker.tips' },
|
||||
{ label: workerCommand.label, link: workerCommand.link }
|
||||
)
|
||||
}}
|
||||
></span>
|
||||
}
|
||||
></Alert>
|
||||
<Typography.Paragraph style={{ marginBottom: 8 }}>
|
||||
{intl.formatMessage({ id: 'cluster.create.checkEnv.tips' })}
|
||||
</Typography.Paragraph>
|
||||
</>
|
||||
)}
|
||||
<CheckEnvCommand
|
||||
provider={provider}
|
||||
currentGPU={currentGPU}
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface SummaryDataKeys {
|
||||
selectedGPUs: string[];
|
||||
cluster_id: number;
|
||||
clusterName: string;
|
||||
dtkVersion: string;
|
||||
workerCommand: {
|
||||
label: string;
|
||||
link: string;
|
||||
|
||||
@@ -41,6 +41,8 @@ const DockerRunCommand: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
|
||||
const currentGPU = summary.get('currentGPU') || '';
|
||||
|
||||
const dtkVersion = summary.get('dtkVersion') || '25.04';
|
||||
|
||||
const stepIndex = stepList.indexOf(StepNamesMap.RunCommand) + 1;
|
||||
|
||||
return (
|
||||
@@ -80,6 +82,7 @@ const DockerRunCommand: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
gpustackDataVolumeConfig.enable ? gpustackDataVolumeConfig.path : ''
|
||||
}
|
||||
currentGPU={currentGPU}
|
||||
dtkVersion={dtkVersion}
|
||||
/>
|
||||
</StepCollapse>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
GPUsConfigs
|
||||
} from '@/pages/resources/config/gpu-driver';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Tag } from 'antd';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ProviderValueMap } from '../../config';
|
||||
import SupportedGPUs from '../support-gpus';
|
||||
@@ -94,7 +93,8 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// init a default selection
|
||||
// Default to NVIDIA for every provider, including K8s. Users can still
|
||||
// deselect it (e.g. for CPU-only workers) or add more vendors on K8s.
|
||||
handleSelect(GPUDriverMap.NVIDIA, {
|
||||
label: 'NVIDIA',
|
||||
hiddenTitle: true,
|
||||
@@ -116,18 +116,18 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
{stepIndex}.{' '}
|
||||
{intl.formatMessage({ id: 'clusters.addworker.selectGPU' })}
|
||||
{multiCapable && (
|
||||
<Tag
|
||||
color="blue"
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 400,
|
||||
borderRadius: 4
|
||||
fontSize: 13,
|
||||
color: 'var(--ant-color-text-secondary)'
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'clusters.addworker.selectGPU.multiTag'
|
||||
id: 'clusters.addworker.selectGPU.subtitle'
|
||||
})}
|
||||
</Tag>
|
||||
</span>
|
||||
)}
|
||||
</Title>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { GPUDriverMap } from '@/pages/resources/config/gpu-driver';
|
||||
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { AlertBlockInfo as AlertInfoBlock } from '@gpustack/core-ui';
|
||||
import {
|
||||
AlertBlockInfo as AlertInfoBlock,
|
||||
SwitchCard
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Input, Switch, Typography } from 'antd';
|
||||
import { Input, Radio, Switch, Typography } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useAddWorkerContext } from './add-worker-context';
|
||||
@@ -111,6 +115,14 @@ const SpecifyArguments: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
path: 'gpustack-data'
|
||||
};
|
||||
|
||||
const selectedGPUs = summary.get('selectedGPUs') || [];
|
||||
|
||||
const dtkVersion = summary.get('dtkVersion') || '25.04';
|
||||
|
||||
// The DTK version selector only applies to Hygon DCUs, so only surface it
|
||||
// when Hygon ('dtk') is among the selected vendors.
|
||||
const showDtkVersion = selectedGPUs.includes(GPUDriverMap.HYGON);
|
||||
|
||||
const setWorkerIPConfig = (config: {
|
||||
enable: boolean;
|
||||
ip?: string;
|
||||
@@ -166,12 +178,14 @@ const SpecifyArguments: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
const unregisterGpustackDataVolume = registerField(
|
||||
'gpustackDataVolumeConfig'
|
||||
);
|
||||
const unregisterVersion = registerField('dtkVersion');
|
||||
return () => {
|
||||
unregisterWorkerIP();
|
||||
unregisterModelDir();
|
||||
unregisterCacheDir();
|
||||
unregisterContainerName();
|
||||
unregisterGpustackDataVolume();
|
||||
unregisterVersion();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -207,6 +221,7 @@ const SpecifyArguments: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
ip: '',
|
||||
required: false
|
||||
});
|
||||
updateField('dtkVersion', '25.04');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -229,6 +244,30 @@ const SpecifyArguments: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
marginBottom: 8
|
||||
}}
|
||||
>
|
||||
{showDtkVersion && (
|
||||
<SwitchCard
|
||||
label={intl.formatMessage({
|
||||
id: 'clusters.addworker.dtkVersion'
|
||||
})}
|
||||
defaultValue={true}
|
||||
showSwitch={false}
|
||||
styles={{
|
||||
wrapper: {
|
||||
borderRadius: 4
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Radio.Group
|
||||
defaultValue={dtkVersion}
|
||||
value={dtkVersion}
|
||||
onChange={(e) => updateField('dtkVersion', e.target.value)}
|
||||
style={{ marginTop: 8, display: 'flex', gap: 12 }}
|
||||
>
|
||||
<Radio value="25.04">25.04</Radio>
|
||||
<Radio value="26.04">26.04</Radio>
|
||||
</Radio.Group>
|
||||
</SwitchCard>
|
||||
)}
|
||||
{/* worker IP config */}
|
||||
<SwitchSetting
|
||||
label={
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { addWorkerGuide } from '@/pages/resources/config';
|
||||
import { HighlightCode } from '@gpustack/core-ui';
|
||||
import React from 'react';
|
||||
import { ProviderType } from '../config';
|
||||
import { ProviderType, ProviderValueMap } from '../config';
|
||||
|
||||
// CPU-only (no GPU vendor selected) check for Kubernetes: verify at least one
|
||||
// ready node exists in the target cluster.
|
||||
const K8S_CPU_ONLY_CHECK =
|
||||
'kubectl get nodes -o jsonpath=\'{.items[*].status.conditions[?(@.type=="Ready")].status}\' 2>/dev/null | grep -q "True" && echo "Ready nodes found. You are registering a CPU-only cluster." || (echo "No ready nodes found"; exit 1)';
|
||||
|
||||
type ViewModalProps = {
|
||||
provider: ProviderType;
|
||||
@@ -25,6 +30,12 @@ const AddWorkerCommand: React.FC<ViewModalProps> = ({
|
||||
: currentGPU
|
||||
? [currentGPU]
|
||||
: [];
|
||||
|
||||
// CPU-only K8s flow: no GPU vendor selected, check for ready nodes instead.
|
||||
if (!keys.length && provider === ProviderValueMap.Kubernetes) {
|
||||
return K8S_CPU_ONLY_CHECK;
|
||||
}
|
||||
|
||||
if (!keys.length) return '';
|
||||
const lines = keys
|
||||
.map((k) => configs.checkEnvCommand(k)?.[provider || ''])
|
||||
|
||||
@@ -36,6 +36,7 @@ type AddModalProps = {
|
||||
provider: ProviderType;
|
||||
credentialList: Global.BaseOption<number>[];
|
||||
onFinish: (values: FormData) => void;
|
||||
onFinishFailed?: (errorInfo: any) => 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;
|
||||
@@ -49,6 +50,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
currentData,
|
||||
credentialList,
|
||||
onFinish,
|
||||
onFinishFailed,
|
||||
onK8sOptionsChange
|
||||
},
|
||||
ref
|
||||
@@ -212,8 +214,9 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
}
|
||||
}));
|
||||
|
||||
const handleOnFinishFailed = () => {
|
||||
const handleOnFinishFailed = (errorInfo: any) => {
|
||||
setSubmitAttempted(true);
|
||||
onFinishFailed?.(errorInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -260,10 +263,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
<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
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Form } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import React, { useEffect, useId } from 'react';
|
||||
import React, { useEffect, useId, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { ClusterListItem as ListItem } from '../config/types';
|
||||
import ImageCredential from './image-credential';
|
||||
@@ -56,7 +56,6 @@ export const OperatorImageForm: React.FC = () => {
|
||||
<SectionWrap>
|
||||
<Form.Item
|
||||
name={['k8s_options', 'operatorImage']}
|
||||
style={{ marginBottom: 0 }}
|
||||
normalize={(value) => value || null}
|
||||
>
|
||||
<CInput.Input
|
||||
@@ -289,10 +288,9 @@ export const GpuInstancesStaticAddressForm: 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.
|
||||
// Strip UI-only 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).
|
||||
const cleanK8sOptions = (opts: any) => {
|
||||
const cloned = _.cloneDeep(opts || {});
|
||||
if (Array.isArray(cloned.volumeMounts)) {
|
||||
@@ -300,24 +298,33 @@ const cleanK8sOptions = (opts: any) => {
|
||||
({ 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));
|
||||
return _.pickBy(cloned, (value: any) => !_.isNil(value));
|
||||
};
|
||||
|
||||
// Custom comparator for isEqualWith: treats null, undefined, empty strings,
|
||||
// empty arrays, and empty objects as equivalent. Form normalize converts empty
|
||||
// inputs to null while the API may omit absent keys (read as undefined when
|
||||
// accessed on the object). This lets "user clears field back to original"
|
||||
// compare as unchanged without needing to recursively strip nullish values.
|
||||
const nullishCustomizer = (val1: any, val2: any) => {
|
||||
if (
|
||||
(val1 == null && val2 === '') ||
|
||||
(val1 === '' && val2 == null) ||
|
||||
(_.isEmpty(val1) && val2 == null) ||
|
||||
(val1 == null && _.isEmpty(val2)) ||
|
||||
(_.isNil(val1) && _.isNil(val2))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 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.
|
||||
// changed any k8s_options field or the top-level system_default_container_registry
|
||||
// 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;
|
||||
currentData?: ListItem;
|
||||
@@ -327,13 +334,31 @@ export const K8sOptionsChangeWatcher: React.FC<{
|
||||
// gpuInstanceOptions which is toggled via setFieldValue without a mounted
|
||||
// Form.Item (mirrors ClusterTypeSelector).
|
||||
const k8sOptions = Form.useWatch(['k8s_options'], { preserve: true });
|
||||
const containerRegistry = Form.useWatch('system_default_container_registry', {
|
||||
preserve: true
|
||||
});
|
||||
|
||||
// currentData?.k8s_options is static for the form's lifetime — memoize the
|
||||
// cleaned version to avoid redundant deep-clone on every render
|
||||
// (Form.useWatch triggers re-render on each keystroke).
|
||||
const currentK8sOptionsCleaned = useMemo(
|
||||
() => cleanK8sOptions(currentData?.k8s_options),
|
||||
[currentData?.k8s_options]
|
||||
);
|
||||
|
||||
const k8sOptionsChanged = !_.isEqualWith(
|
||||
currentK8sOptionsCleaned,
|
||||
cleanK8sOptions(k8sOptions),
|
||||
nullishCustomizer
|
||||
);
|
||||
const registryChanged = !_.isEqualWith(
|
||||
currentData?.system_default_container_registry,
|
||||
containerRegistry,
|
||||
nullishCustomizer
|
||||
);
|
||||
|
||||
const changed =
|
||||
action === PageAction.EDIT &&
|
||||
!_.isEqual(
|
||||
cleanK8sOptions(currentData?.k8s_options),
|
||||
cleanK8sOptions(k8sOptions)
|
||||
);
|
||||
action === PageAction.EDIT && (k8sOptionsChanged || registryChanged);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(changed);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CheckOutlined } from '@ant-design/icons';
|
||||
import { IconFont, TemplateCard } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Tooltip } from 'antd';
|
||||
@@ -62,6 +63,29 @@ const Header = styled.div`
|
||||
const CardBox = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const ActiveBadge = styled.div`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: var(--ant-color-primary);
|
||||
clip-path: path('M 20 0 L 20 15 Q 20 20 15 20 L 0 20 Z');
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
|
||||
.anticon {
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
margin-right: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface ProviderCatalogProps {
|
||||
@@ -160,6 +184,13 @@ const ProviderCatalog: React.FC<ProviderCatalogProps> = ({
|
||||
header={renderTitle(action)}
|
||||
icon={action.icon}
|
||||
></TemplateCard>
|
||||
{(Array.isArray(current)
|
||||
? current.includes(action.key)
|
||||
: current === action.key) && (
|
||||
<ActiveBadge>
|
||||
<CheckOutlined />
|
||||
</ActiveBadge>
|
||||
)}
|
||||
</CardBox>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
@@ -24,6 +24,7 @@ const Box = styled.div`
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
&.active {
|
||||
background-color: var(--ant-color-fill-tertiary);
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
.template-card-icon {
|
||||
margin-right: 0;
|
||||
@@ -57,6 +58,7 @@ const Box = styled.div`
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--ant-color-bg-solid);
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface CredentialListItem {
|
||||
access_key: string;
|
||||
secret_key: string;
|
||||
description?: string;
|
||||
owner_principal_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { systemConfigAtom } from '@/atoms/system';
|
||||
import { tableSorter } from '@/config/settings';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||
import { StarFilled } from '@ant-design/icons';
|
||||
import {
|
||||
AutoTooltip,
|
||||
@@ -80,6 +81,7 @@ const useClusterColumns = (
|
||||
): SealColumnProps[] => {
|
||||
const intl = useIntl();
|
||||
const systemConfig = useAtomValue(systemConfigAtom);
|
||||
const pluginCols = usePluginListColumns('clusters');
|
||||
// The cluster-detail page is shipped in OSS source, but OSS keeps
|
||||
// it unreachable from the cluster list — the link is only
|
||||
// surfaced when a plugin opts in via
|
||||
@@ -102,6 +104,40 @@ const useClusterColumns = (
|
||||
};
|
||||
|
||||
return useMemo(() => {
|
||||
// Two prebuilt span maps for the 24-unit SealTable grid: one for
|
||||
// the default layout, one for when a plugin contributes an extra
|
||||
// column (currently always the 3-span Organization cell — wider
|
||||
// would visually dominate this row of 2/3-span built-ins). Width
|
||||
// absorbed comes from the columns whose content underfills its
|
||||
// slot (single-digit `provider` / `models` / `status` tag), not
|
||||
// from `created_at` (a full date string) or `workers` (the
|
||||
// `x / y` digit pair reads better with breathing room). Picking
|
||||
// by map rather than a `pluginSpan`-driven formula trades the
|
||||
// formula's built-in handling of multi-column / variable-width
|
||||
// plugins for readability and easier tweaks — the sole consumer
|
||||
// today is one fixed-width column, so the formula's generality
|
||||
// was unused.
|
||||
const SPANS_DEFAULT = {
|
||||
provider: 3,
|
||||
deployments: 3,
|
||||
workers: 3,
|
||||
status: 3,
|
||||
createTime: 4
|
||||
};
|
||||
const SPANS_WITH_PLUGIN = {
|
||||
provider: 2,
|
||||
deployments: 2,
|
||||
workers: 3,
|
||||
status: 2,
|
||||
createTime: 4
|
||||
};
|
||||
const spans = pluginCols.length > 0 ? SPANS_WITH_PLUGIN : SPANS_DEFAULT;
|
||||
const pluginRendered = pluginCols.map((c) => ({
|
||||
title: intl.formatMessage({ id: c.titleId }),
|
||||
dataIndex: c.key,
|
||||
span: c.span ?? 4,
|
||||
render: (_value: any, record: ClusterListItem) => c.render(record)
|
||||
}));
|
||||
return [
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.name' }),
|
||||
@@ -133,11 +169,12 @@ const useClusterColumns = (
|
||||
</>
|
||||
)
|
||||
},
|
||||
...pluginRendered,
|
||||
{
|
||||
title: intl.formatMessage({ id: 'clusters.table.provider' }),
|
||||
dataIndex: 'provider',
|
||||
sorter: tableSorter(2),
|
||||
span: 3,
|
||||
span: spans.provider,
|
||||
render: (value: string) => (
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
{ProviderLabelMap[value]}
|
||||
@@ -145,7 +182,7 @@ const useClusterColumns = (
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'GPUs',
|
||||
title: intl.formatMessage({ id: 'menu.resources.gpus' }),
|
||||
dataIndex: 'gpus',
|
||||
span: 2,
|
||||
sorter: tableSorter(3),
|
||||
@@ -155,14 +192,14 @@ const useClusterColumns = (
|
||||
title: intl.formatMessage({ id: 'clusters.table.deployments' }),
|
||||
dataIndex: 'models',
|
||||
sorter: tableSorter(4),
|
||||
span: 3,
|
||||
span: spans.deployments,
|
||||
render: (value: number) => <span>{value}</span>
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'resources.nodes' }),
|
||||
dataIndex: 'workers',
|
||||
sorter: tableSorter(5),
|
||||
span: 3,
|
||||
span: spans.workers,
|
||||
render: (value: number, record: ClusterListItem) => (
|
||||
<span>
|
||||
{record.ready_workers} / {record.workers}
|
||||
@@ -172,7 +209,7 @@ const useClusterColumns = (
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.status' }),
|
||||
dataIndex: 'state',
|
||||
span: 3,
|
||||
span: spans.status,
|
||||
render: (value: number, record: ClusterListItem) => (
|
||||
<StatusTag
|
||||
statusValue={{
|
||||
@@ -187,7 +224,7 @@ const useClusterColumns = (
|
||||
title: intl.formatMessage({ id: 'common.table.createTime' }),
|
||||
dataIndex: 'created_at',
|
||||
sorter: tableSorter(6),
|
||||
span: 4,
|
||||
span: spans.createTime,
|
||||
render: (value: string) => (
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
{dayjs(value).format('YYYY-MM-DD HH:mm:ss')}
|
||||
@@ -206,7 +243,7 @@ const useClusterColumns = (
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [handleSelect, onCellClick]);
|
||||
}, [handleSelect, onCellClick, intl, pluginCols]);
|
||||
};
|
||||
|
||||
export default useClusterColumns;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// columns.ts
|
||||
import { tableSorter } from '@/config/settings';
|
||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
@@ -12,8 +13,15 @@ const useCredentialColumns = (
|
||||
handleSelect: (val: string, record: ListItem) => void
|
||||
): ColumnsType<ListItem> => {
|
||||
const intl = useIntl();
|
||||
const pluginCols = usePluginListColumns('cloudCredentials');
|
||||
|
||||
return useMemo(() => {
|
||||
const pluginRendered = pluginCols.map((c) => ({
|
||||
title: intl.formatMessage({ id: c.titleId }),
|
||||
key: c.key,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (_text: any, record: ListItem) => c.render(record)
|
||||
}));
|
||||
return [
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.name' }),
|
||||
@@ -25,6 +33,7 @@ const useCredentialColumns = (
|
||||
</AutoTooltip>
|
||||
)
|
||||
},
|
||||
...pluginRendered,
|
||||
{
|
||||
title: intl.formatMessage({ id: 'clusters.table.provider' }),
|
||||
dataIndex: 'provider',
|
||||
@@ -68,7 +77,7 @@ const useCredentialColumns = (
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [intl, handleSelect]);
|
||||
}, [intl, handleSelect, pluginCols]);
|
||||
};
|
||||
|
||||
export default useCredentialColumns;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import useUserSettings from '@/hooks/use-user-settings';
|
||||
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { AlertBlockInfo, Input as CInput, IconFont } from '@gpustack/core-ui';
|
||||
import { 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';
|
||||
@@ -98,7 +97,6 @@ const ClusterAdvanceConfig: React.FC<{
|
||||
this column). */}
|
||||
<Form.Item<FormData>
|
||||
name="system_default_container_registry"
|
||||
style={{ marginBottom: 16 }}
|
||||
normalize={(value) => value?.trim?.() || null}
|
||||
>
|
||||
<CInput.Input
|
||||
@@ -120,14 +118,6 @@ const ClusterAdvanceConfig: React.FC<{
|
||||
<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}
|
||||
|
||||
@@ -163,10 +163,6 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
||||
[apiData, pickedNames, othersLabel]
|
||||
);
|
||||
|
||||
// Build a shared name->color map from the projected data so legend toggles
|
||||
// both donuts together and matching slices read the same color. Others
|
||||
// sticks to the end and uses a muted token so it visually reads as "the
|
||||
// long tail" rather than a real route.
|
||||
const { legendNames, colorMap } = useMemo(() => {
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -231,15 +227,6 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
// Anchor the legend column's LEFT edge at 85% of the chart canvas
|
||||
// (right edge fills to canvas right). ECharts' legend.align only
|
||||
// controls intra-item marker/text ordering — not item-in-container
|
||||
// alignment — so a right-anchored + fixed-width legend always
|
||||
// packs items flush right. Anchoring from the left instead makes
|
||||
// items naturally start at the container's left edge, so a short
|
||||
// label like `qwen3-0.6b` stays close to the donut instead of
|
||||
// floating in a void at the canvas edge.
|
||||
left: '85%',
|
||||
right: 0,
|
||||
top: 18,
|
||||
bottom: 18,
|
||||
@@ -249,12 +236,16 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
||||
textStyle: {
|
||||
color: token.colorTextTertiary,
|
||||
overflow: 'truncate',
|
||||
// 15% of typical xl card canvas (~680-900px) ≈ 102-135px column;
|
||||
// marker(8) + gap(5) + 90 = 103 fits even at the narrow end.
|
||||
width: 90
|
||||
width: 180
|
||||
},
|
||||
data: legendNames,
|
||||
show: legendNames.length > 0
|
||||
show: legendNames.length > 0,
|
||||
tooltip: {
|
||||
show: true,
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderColor: 'transparent',
|
||||
textStyle: { color: token.colorText, fontSize: 12 }
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -295,12 +286,6 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
||||
apiLabel
|
||||
]);
|
||||
|
||||
// Association: hovering a slice in one donut highlights the same route in
|
||||
// the other donut. The Chart wrapper's imperative handle is committed
|
||||
// before its own init effect runs, so chartRef can be { chart: undefined }
|
||||
// on the first useEffect tick — poll via rAF until the instance shows up,
|
||||
// then bind once. ECharts' clear() preserves user listeners, so we don't
|
||||
// need to re-bind on every options change.
|
||||
useEffect(() => {
|
||||
let raf: number | undefined;
|
||||
let detach: (() => void) | undefined;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { ThemeTag } from '@gpustack/core-ui';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
import useUserDirectory from '../hooks/use-user-directory';
|
||||
|
||||
/**
|
||||
* Owner tag for template cards, disambiguating same-name templates in
|
||||
* the admin's cross-tenant view. Renders nothing for non-admin callers
|
||||
* (the management page is `mine`-scoped for them) and when a plugin
|
||||
* provides its own `OwnerScopeTag` slot.
|
||||
*/
|
||||
const OwnerTag: React.FC<{ ownerId?: number | null }> = ({ ownerId }) => {
|
||||
const intl = useIntl();
|
||||
const access = useAccess();
|
||||
const pluginOwnsTag = !!getGPUStackPlugin()?.components?.OwnerScopeTag;
|
||||
const show = !!access.canSeeAdmin && !pluginOwnsTag;
|
||||
const users = useUserDirectory(show);
|
||||
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ownerId == null) {
|
||||
return (
|
||||
<ThemeTag color="gold" style={{ fontWeight: 400 }}>
|
||||
{intl.formatMessage({ id: 'gpuservice.owner.global' })}
|
||||
</ThemeTag>
|
||||
);
|
||||
}
|
||||
|
||||
const username = users.get(ownerId);
|
||||
if (!username) {
|
||||
// Directory still loading, or a non-USER principal (built-in
|
||||
// platform principal) — nothing meaningful to show on a card.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeTag color="blue" style={{ fontWeight: 400 }}>
|
||||
{username}
|
||||
</ThemeTag>
|
||||
);
|
||||
};
|
||||
|
||||
export default OwnerTag;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { AutoTooltip } from '@gpustack/core-ui';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
import { useMemo } from 'react';
|
||||
import useUserDirectory from './use-user-directory';
|
||||
|
||||
type CreatorAware = {
|
||||
creator_id?: number | null;
|
||||
owner_principal_id?: number | null;
|
||||
};
|
||||
|
||||
// Concrete column shape rather than antd's `ColumnsType` union:
|
||||
// spreading a union-typed array into a page's column literal breaks
|
||||
// contextual typing for the sibling inline columns (their render
|
||||
// params degrade to implicit `any`), while a concrete object type
|
||||
// composes cleanly — same contract the plugin columns use.
|
||||
type CreatorColumn<T> = {
|
||||
title: string;
|
||||
key: string;
|
||||
ellipsis: { showTitle: false };
|
||||
render: (text: any, record: T) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const CreatorLabel: React.FC<{
|
||||
record: CreatorAware;
|
||||
users: Map<number, string>;
|
||||
}> = ({ record, users }) => {
|
||||
// Legacy rows predate `creator_id`. A personal-scope row's creator
|
||||
// IS its owner, so the owner id doubles as the fallback — the same
|
||||
// derivation the backend migration backfills with; keeping it here
|
||||
// covers servers that haven't run the migration yet.
|
||||
const id = record.creator_id ?? record.owner_principal_id;
|
||||
const username = id != null ? users.get(id) : undefined;
|
||||
if (username) {
|
||||
return (
|
||||
<AutoTooltip ghost maxWidth={240}>
|
||||
{username}
|
||||
</AutoTooltip>
|
||||
);
|
||||
}
|
||||
// Unresolvable: the directory is still loading, or a legacy row
|
||||
// whose only attribution is a non-USER principal (platform-owned).
|
||||
return <span>-</span>;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Creator" column for the GPU-service list pages, attributing each
|
||||
* row to the user who created it — disambiguates same-name rows from
|
||||
* different users in the admin's cross-tenant view, and reads as a
|
||||
* plain username for admin-created rows too. Returns `[]` for
|
||||
* non-admin callers (their lists only ever contain their own rows)
|
||||
* and when a plugin registers list columns for `pageKey` — the
|
||||
* plugin's own attribution column covers the same ground.
|
||||
*/
|
||||
const useCreatorColumn = <T extends CreatorAware>(
|
||||
pageKey: string
|
||||
): CreatorColumn<T>[] => {
|
||||
const intl = useIntl();
|
||||
const access = useAccess();
|
||||
const pluginOwnsSlot = !!(
|
||||
getGPUStackPlugin()?.listExtraColumns as Record<string, unknown> | undefined
|
||||
)?.[pageKey];
|
||||
const show = !!access.canSeeAdmin && !pluginOwnsSlot;
|
||||
const users = useUserDirectory(show);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!show) return [];
|
||||
return [
|
||||
{
|
||||
title: intl.formatMessage({ id: 'gpuservice.creator' }),
|
||||
key: 'creator',
|
||||
ellipsis: { showTitle: false as const },
|
||||
render: (_text: any, record: T) => (
|
||||
<CreatorLabel record={record} users={users} />
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [show, users, intl]);
|
||||
};
|
||||
|
||||
export default useCreatorColumn;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { queryUserDirectory } from '@/pages/users/apis';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Module-level cache: the directory is fetched once per app session and
|
||||
// shared by every consumer (owner columns on the Instances / Public Keys /
|
||||
// Storage tables, the template owner tags, the create-instance template
|
||||
// picker). A failed fetch clears the cache so the next consumer retries.
|
||||
let directoryPromise: Promise<Map<number, string>> | null = null;
|
||||
|
||||
const fetchDirectory = (): Promise<Map<number, string>> => {
|
||||
directoryPromise ??= queryUserDirectory({ page: -1 })
|
||||
.then(
|
||||
(res) =>
|
||||
new Map(
|
||||
(res.items || [])
|
||||
.filter((u) => u.id != null)
|
||||
.map((u) => [u.id as number, u.username])
|
||||
)
|
||||
)
|
||||
.catch((error) => {
|
||||
directoryPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return directoryPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* id → username map of all users, for resolving `owner_principal_id`
|
||||
* to a display name (a USER principal's id IS the user's id).
|
||||
*
|
||||
* Only fetches when `enabled` — callers gate on platform admin, since
|
||||
* `/user-directory` 403s for regular users (who only ever see their
|
||||
* own resources anyway). Returns an empty map until loaded.
|
||||
*/
|
||||
export default function useUserDirectory(enabled: boolean) {
|
||||
const [users, setUsers] = useState<Map<number, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
let alive = true;
|
||||
fetchDirectory()
|
||||
.then((map) => {
|
||||
if (alive) setUsers(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return users;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
|
||||
import useUserDirectory from '@/pages/gpu-service/hooks/use-user-directory';
|
||||
import Separator from '@/pages/llmodels/components/separator';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
AlertBlockInfo,
|
||||
@@ -9,14 +12,15 @@ import {
|
||||
GSDrawer,
|
||||
ModalFooter
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { useIntl, useModel } from '@umijs/max';
|
||||
import { Empty, Input, Typography } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||
import useQueryTemplates from '../../templates/services/use-query-templates';
|
||||
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
||||
import GPUServiceInstanceForm from '../forms';
|
||||
import TemplateSelector from '../forms/template-selector';
|
||||
import TemplateSelector, { TemplateGroup } from '../forms/template-selector';
|
||||
import useQueryInstanceTypes from '../services/use-query-instance-types';
|
||||
import styles from '../styles/instances.module.less';
|
||||
import InstanceTypeList from './instance-type-list';
|
||||
@@ -77,6 +81,12 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
realAction
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { initialState } = useModel('@@initialState') || {};
|
||||
const currentUser = initialState?.currentUser;
|
||||
const pluginActive = !!getGPUStackPlugin();
|
||||
const userDirectory = useUserDirectory(
|
||||
!!currentUser?.is_admin && !pluginActive
|
||||
);
|
||||
const form = useRef<any>(null);
|
||||
const sessionRef = useRef(0);
|
||||
const [instanceTypeSelection, setInstanceTypeSelection] = useState<{
|
||||
@@ -89,7 +99,8 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
const [templateId, setTemplateId] = useState<number | undefined>();
|
||||
const [instanceKeyword, setInstanceKeyword] = useState('');
|
||||
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { loading, guard, run, release } = useSubmitLock();
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
const {
|
||||
detailData: instanceTypeList,
|
||||
@@ -169,17 +180,27 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
return JSON.stringify({
|
||||
name: instanceType.name,
|
||||
spec: {
|
||||
...instanceType.spec
|
||||
..._.omit(instanceType.spec, ['cache', 'cpu']),
|
||||
cpu: _.pick(instanceType.spec?.cpu, [
|
||||
'manufacturer',
|
||||
'product',
|
||||
'family'
|
||||
])
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// GPU types carry their accelerator vendor; non-acceleratable (CPU) types
|
||||
// all map to the single 'cpu' bucket used to match templates.
|
||||
const manufacturerOf = (instanceType: InstanceTypeItem) =>
|
||||
instanceType.spec.acceleratable ? instanceType.spec?.manufacturer : 'cpu';
|
||||
|
||||
// apply the selection of instance type and template
|
||||
const applySelection = (
|
||||
instanceType: InstanceTypeItem,
|
||||
template: TemplateItem | undefined
|
||||
) => {
|
||||
const manufacturer = instanceType.spec?.manufacturer;
|
||||
const manufacturer = manufacturerOf(instanceType);
|
||||
|
||||
setInstanceTypeSelection({
|
||||
instanceType: instanceType.name,
|
||||
@@ -210,6 +231,35 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
form.current?.applyInstanceType?.(instanceType);
|
||||
};
|
||||
|
||||
// Drop the instance-type-derived selection + form state. Used when no
|
||||
// candidate is available (empty segment / org with no clusters) so a stale
|
||||
// type / cluster never survives a switch or reload.
|
||||
const clearSelection = () => {
|
||||
setInstanceTypeSelection({
|
||||
instanceType: undefined,
|
||||
manufacturer: undefined
|
||||
});
|
||||
setTemplateId(undefined);
|
||||
form.current?.applyInstanceType?.(undefined);
|
||||
form.current?.setFieldValue?.('clusterId', null);
|
||||
form.current?.setFieldValue?.(['spec', 'type'], undefined);
|
||||
};
|
||||
|
||||
const autoSelectFirst = (
|
||||
types: InstanceTypeItem[],
|
||||
templates: TemplateItem[]
|
||||
) => {
|
||||
const first = types.find((item) => !item.disabled);
|
||||
if (!first) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
applySelection(
|
||||
first,
|
||||
findTemplateByManufacturer(manufacturerOf(first), templates)
|
||||
);
|
||||
};
|
||||
|
||||
const findAggregateOf = (
|
||||
candidateName: string | undefined,
|
||||
clusterId: number | null | undefined,
|
||||
@@ -246,7 +296,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
if (aggregate) {
|
||||
setInstanceTypeSelection({
|
||||
instanceType: aggregate.name,
|
||||
manufacturer: aggregate.spec?.manufacturer
|
||||
manufacturer: manufacturerOf(aggregate)
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -254,31 +304,10 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
|
||||
// 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) {
|
||||
// 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
|
||||
|
||||
const template = findTemplateByManufacturer(
|
||||
first.spec?.manufacturer,
|
||||
templates
|
||||
);
|
||||
|
||||
applySelection(first, template);
|
||||
// On create, auto-select the first available instance type (clears the
|
||||
// selection when the chosen org has none).
|
||||
autoSelectFirst(owned, templates);
|
||||
};
|
||||
|
||||
// Fetch the (tenant-scoped) instance types + templates and auto-select.
|
||||
@@ -287,19 +316,25 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
// 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
|
||||
);
|
||||
});
|
||||
try {
|
||||
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
|
||||
);
|
||||
initializedRef.current = true;
|
||||
});
|
||||
} catch (error) {
|
||||
} finally {
|
||||
initializedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Platform admin retargeted the create to another org (or Global). The
|
||||
@@ -309,26 +344,21 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
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
|
||||
// Drop the instance-type-derived selection + 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);
|
||||
clearSelection();
|
||||
initializedRef.current = false;
|
||||
loadCreateResources(orgId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
initializedRef.current = false;
|
||||
sessionRef.current += 1;
|
||||
setInstanceTypeSelection({
|
||||
instanceType: undefined,
|
||||
@@ -373,8 +403,91 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
);
|
||||
});
|
||||
|
||||
// Group the picker by owning scope so same-name templates stay
|
||||
// distinguishable: the caller's own templates first, then the
|
||||
// admin-curated Global presets, then — platform admin's cross-tenant
|
||||
// view only — other users' templates, one group per owner.
|
||||
//
|
||||
// The default buckets below assume every non-Global owner is a USER
|
||||
// principal. A plugin's principal model may scope templates to other
|
||||
// owner kinds (no user-directory entry, not the caller's user id),
|
||||
// which these buckets would mislabel — so a plugin can take over
|
||||
// grouping via `hooks.useTemplateOwnerGroups`. The registry is wired
|
||||
// at boot, so the conditional hook call is render-stable — same
|
||||
// contract as `usePluginListColumns`' function entries.
|
||||
const usePluginTemplateGroups = getGPUStackPlugin()?.hooks
|
||||
?.useTemplateOwnerGroups as
|
||||
| ((items: TemplateItem[]) => TemplateGroup[])
|
||||
| undefined;
|
||||
const pluginTemplateGroups = usePluginTemplateGroups?.(filteredTemplates);
|
||||
|
||||
const templateGroups: TemplateGroup[] = useMemo(() => {
|
||||
if (pluginTemplateGroups) {
|
||||
return pluginTemplateGroups;
|
||||
}
|
||||
if (pluginActive) {
|
||||
// Plugin present but without the grouping hook (older plugin
|
||||
// build): keep the flat list rather than mislabeling owners
|
||||
// outside the USER-principal model.
|
||||
return filteredTemplates.length
|
||||
? [{ key: 'all', label: null, items: filteredTemplates }]
|
||||
: [];
|
||||
}
|
||||
const yours: TemplateItem[] = [];
|
||||
const globals: TemplateItem[] = [];
|
||||
const byOwner = new Map<number, TemplateItem[]>();
|
||||
|
||||
filteredTemplates.forEach((item) => {
|
||||
if (item.owner_principal_id == null) {
|
||||
globals.push(item);
|
||||
} else if (item.owner_principal_id === currentUser?.id) {
|
||||
yours.push(item);
|
||||
} else {
|
||||
const list = byOwner.get(item.owner_principal_id) || [];
|
||||
list.push(item);
|
||||
byOwner.set(item.owner_principal_id, list);
|
||||
}
|
||||
});
|
||||
|
||||
const groups: TemplateGroup[] = [];
|
||||
if (yours.length) {
|
||||
groups.push({
|
||||
key: 'yours',
|
||||
label: intl.formatMessage({ id: 'gpuservice.template.group.yours' }),
|
||||
items: yours
|
||||
});
|
||||
}
|
||||
if (globals.length) {
|
||||
groups.push({
|
||||
key: 'global',
|
||||
label: intl.formatMessage({ id: 'gpuservice.template.group.global' }),
|
||||
items: globals
|
||||
});
|
||||
}
|
||||
groups.push(
|
||||
...[...byOwner.entries()]
|
||||
.map(([ownerId, items]) => ({
|
||||
key: `owner-${ownerId}`,
|
||||
// `#id` is a placeholder for the moment before the user
|
||||
// directory resolves (the memo recomputes once it lands)
|
||||
// and for the API-only case of a non-USER owner.
|
||||
label: userDirectory.get(ownerId) || `#${ownerId}`,
|
||||
items
|
||||
}))
|
||||
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
|
||||
);
|
||||
return groups;
|
||||
}, [
|
||||
filteredTemplates,
|
||||
currentUser?.id,
|
||||
userDirectory,
|
||||
intl,
|
||||
pluginActive,
|
||||
pluginTemplateGroups
|
||||
]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
form.current?.submit();
|
||||
guard(() => form.current?.submit());
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -383,20 +496,17 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
};
|
||||
|
||||
const onFinish = async (values: FormData) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await run(async () => {
|
||||
await onOk({
|
||||
...values
|
||||
});
|
||||
console.log('submit form values', values);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleInstanceTypeChange = (item: InstanceTypeItem) => {
|
||||
const template = findTemplateByManufacturer(
|
||||
item.spec?.manufacturer,
|
||||
manufacturerOf(item),
|
||||
templateList
|
||||
);
|
||||
applySelection(item, template);
|
||||
@@ -456,7 +566,9 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<ColTitle style={{ paddingBottom: 0 }}>
|
||||
{intl.formatMessage({ id: 'gpuservice.instance.types' })}
|
||||
{intl.formatMessage({
|
||||
id: 'gpuservice.instance.types'
|
||||
})}
|
||||
</ColTitle>
|
||||
<Input
|
||||
allowClear
|
||||
@@ -507,10 +619,10 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
onChange={(e) => setTemplateKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filteredTemplates.length > 0 ? (
|
||||
{filteredTemplates.length > 0 && initializedRef.current ? (
|
||||
<TemplateSelector
|
||||
value={templateId}
|
||||
dataList={filteredTemplates}
|
||||
groups={templateGroups}
|
||||
onChange={handleTemplateChange}
|
||||
/>
|
||||
) : (
|
||||
@@ -565,6 +677,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
currentData={data}
|
||||
disabled={readonly}
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={release}
|
||||
onScopeChange={handleScopeChange}
|
||||
open={open}
|
||||
instanceTypeList={ownedInstanceTypes}
|
||||
|
||||
@@ -24,7 +24,6 @@ const InstanceTypeCell: React.FC<{
|
||||
}> = ({ 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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user