Compare commits

...
51 Commits
Author SHA1 Message Date
jialinandjialin 8c06862705 style: login placeholder 2026-06-12 22:20:31 +08:00
jialinandjialin 48159171c2 fix: keep uniform for instance type render content 2026-06-12 22:20:31 +08:00
jialinandjialin d5d0749b72 chore(deps): update ui 1.0.27 2026-06-12 21:16:07 +08:00
jialinandjialin dc4dd81e11 refactor(login): layout style 2026-06-12 21:16:07 +08:00
jialinandjialin c736e2186d revert: display the max count cpu instance type 2026-06-12 20:49:47 +08:00
gitlawr 7a76bda484 feat: distinguish same-name GPU service resources across users
Resource names are unique per owning principal, so different users
can legally create same-name GPU instances, templates, SSH public
keys, storage volumes and storage types — but the admin's
cross-tenant lists and the instance-create template picker rendered
them indistinguishably.

- Group the template picker by owning scope: Your Templates, Global
  Templates, then — admin cross-tenant view only — one group per
  owner. A single group renders flat without headers. A plugin can
  take over grouping via hooks.useTemplateOwnerGroups when its
  principal model scopes templates beyond USER owners; a plugin
  without the hook keeps the flat list.
- Add an admin-only Creator column to the Instances, SSH Public
  Keys, Storage and Storage Types tables, sitting right before
  Created (mirroring the API Keys page convention). Resolves
  creator_id to a username via the user directory, falling back to
  owner_principal_id for legacy rows — a personal-scope row's
  creator IS its owner.
- Tag template cards with their owner scope (Global / username) for
  admins.

The Creator column and the card tag defer to a plugin that registers
the page's list columns / the OwnerScopeTag slot. Requires the
gpustack server change that records creator_id on templates, SSH
public keys and storage types.
2026-06-12 20:22:56 +08:00
yxfandjialin 778ea36a9d refactor(parameter): rename parameter_format to Flag Format and update related logic 2026-06-12 17:12:10 +08:00
jialinandjialin 04e1e1e566 chore: remove remember me 2026-06-12 16:02:22 +08:00
jialinandjialin 01f66085ab fix: only keep the max count cpu instance type 2026-06-12 16:02:22 +08:00
jialinandjialin ddf723525a fix: instance types switch between gpu and cpu 2026-06-12 12:10:14 +08:00
Yuxing Deng 7b18a92388 fix: register k8s cluster should selected nvidia vendor by default 2026-06-11 23:55:40 +08:00
jialinandjialin b985dcee63 chore(deps): update ui 1.0.26 2026-06-11 20:31:36 +08:00
jialinandjialin 77e92432ed fix: changed notice always display 2026-06-11 20:02:27 +08:00
jialinandjialin a7b33a928b fix: anti double submit in form 2026-06-11 20:02:27 +08:00
jialinandjialin 1218c6d096 fix: prevent duplicate submit and unlock on validation failure in deploy/update modals 2026-06-11 20:02:27 +08:00
jialinandjialin 6337431ed4 chore(deps): update ui 1.0.25 2026-06-11 14:13:08 +08:00
jialinandjialin 2ba5118bc2 fix: filter modelInstance by cluster in benchmark form 2026-06-11 14:13:08 +08:00
jialinandjialin 12649c7ccf chore(deps): update ui 1.0.24 2026-06-10 20:29:50 +08:00
jialinandjialin 5d96932870 fix: logs viewer scroll to bottom aumatically 2026-06-10 20:29:50 +08:00
jialinandjialin e6cfa6cf77 fix: template display after initialized 2026-06-10 20:29:50 +08:00
gitlawrandjialin 55d16c5681 feat: persist sider menu group collapse state across refresh 2026-06-10 20:24:53 +08:00
micheliaandjialin ab75685955 fix: extend usage query limit 2026-06-10 16:17:54 +08:00
micheliaandjialin bd0bf0bf63 fix(usage): render resource timestamps in their rollup tz, not browser-local 2026-06-10 16:17:54 +08:00
jialinandjialin fd050b33d9 fix: dedupe backend params when switching backend 2026-06-10 15:37:27 +08:00
jialinandjialin 698090b17a fix: watch list do not update when creating 2026-06-10 15:37:27 +08:00
jialinandjialin 6f96260993 fix(style): pie legend text overflow 2026-06-10 15:37:27 +08:00
jialinandjialin 98a6b19298 fix: flatten all filters in table list 2026-06-10 15:37:27 +08:00
Yuxing Dengandjialin ded270ff25 feat: support deploying cpu workers for k8s cluster 2026-06-10 15:26:13 +08:00
gitlawrandjialin c8860207e8 feat(apikeys): wire Organization plugin column on API Keys list
Splice the per-page extra-columns slot ('apiKeys') after the Name
column so plugin-contributed columns (e.g. Organization in the admin
All-org view) render on the API Keys list, matching the other resource
list pages. Declare owner_principal_id (and the already-consumed
masked_value / user_name / user_id) on ListItem.
2026-06-10 14:46:12 +08:00
gitlawrandjialin 8d045ffd8b feat(header): add GitHub star button to top bar
Compact pill (GitHub icon | star count) placed left of the version
display. Star count is fetched from the GitHub API once per 24h and
cached in localStorage; the button hides entirely when no count is
available (e.g. air-gapped deployments), matching the existing update-
check graceful-degradation pattern. Fetch is bounded by a 4s abort
timeout to avoid hanging requests offline.
2026-06-10 12:00:35 +08:00
jialinandjialin fd7437e74a fix: show start only on stopped 2026-06-10 10:03:43 +08:00
jialinandjialin 807ba866d3 fix: only ready allow deleting 2026-06-09 21:00:17 +08:00
jialinandjialin 5e2808d7c4 fix(style): usage model name overflow 2026-06-09 20:01:11 +08:00
jialinandjialin a26a3ff8f9 fix: safe mergeBackendParameters 2026-06-09 20:01:11 +08:00
jialinandjialin a2e9f69b96 fix: gpu instance always show delete, dedupe params 2026-06-09 19:01:32 +08:00
Yuxing Dengandjialin ac800a303a fix: remove the pined worker config changed notice 2026-06-09 17:54:23 +08:00
yxfandjialin dd542a9f29 feat(params): add initialization attributes to lock task_type field 2026-06-09 16:30:35 +08:00
gitlawrandjialin 26ce456bde feat: per-page extra-columns slot for list tables
Add `usePluginListColumns(pageKey)` so a registered plugin can splice
extra columns into a list page's columns hook keyed by page id. The
slot is wired into 11 list pages (Models, Model Routes, Clusters,
Cloud Credentials, MaaS Providers, Model Files, Model Instances,
GPU Service Instances / Public Keys / Storage / Storage Types) so any
consumer of the seam reaches every org-scoped list without per-page
plumbing.

The slot accepts either a static array or a hook function — the hook
form lets the registrant use React hooks to decide visibility without
the host having to evaluate it. Columns carry a `placement`:
`after-name` is the default (sits next to the row's identifying
column); `before-time` / `before-operation` are kept for back-compat
with the existing per-page `modelRoutes.extraColumns` slot.

SealTable-grid pages (Models / Model Routes / Clusters) absorb the
plugin column's span by shrinking the widest right-side columns so
the 24-unit grid stays balanced. Model Routes' `CREATE_TIME_MIN_SPAN`
drops from 3 to 2 because with two plugin columns active the grid
was 1 unit over and the action dropdown wrapped onto a new row; the
date string ellipsizes cleanly at the lower min.

ListItem types for Model Instances, MaaS Providers, Cloud Credentials,
and Model Files gain an optional `owner_principal_id` field — the
backend already emits it (denormalized from the parent resource), the
TS types just hadn't declared it.

The GPU Instances page splices the plugin column right before the
Cluster column (rather than after Name); the two read together since
Cluster narrows down to one Org.
2026-06-09 16:28:59 +08:00
micheliaandmichela feng 7ce1faaf6c fix(usage): format Last Active as local time on GPU & Storage tabs
The Last Active column had no renderer, so it dumped the raw ISO timestamp
(gpustack/gpustack#5523). It's a UTC instant (max bucket hour), so format it
to the user's local time with dayjs — matching the Resource Events time
column and the rest of the product. (Token Last Active is a daily date, not
an instant, so it stays a plain date with no timezone shift.)
2026-06-09 11:54:28 +08:00
micheliaandmichela feng 878bdaa5a7 fix(usage): keep the grouped-trend legend off the x-axis labels
The legend renders at the chart bottom (bottom: 0); with the default grid
(bottom: 8) it overlapped the date labels once a group-by split the trend
into multiple series. Reserve grid bottom space (matching the Tokens trend)
whenever a legend is shown.
2026-06-09 11:54:28 +08:00
micheliaandmichela feng 68a7e3f66a feat(usage): group-by on the GPU Instances & Storage trend charts
Mirror the Tokens trend: add a clearable "Group by" select to the
MetricChartCard so the GPU Instances chart can split by instance type /
instance / user, and Storage by storage / user. When grouped, the chart
fetches group_by=["date", "<dim>"] (the same list style as the token usage
API) and pivots into one stacked series per group (shared buildTrendSeries
util), with a legend; ungrouped stays a single series. group_by is now a
list across the resource breakdown client; group-by options reuse the
bottom-table dimensions (Users only when org-wide).
2026-06-09 11:54:28 +08:00
micheliaandmichela feng feccc81c5f feat(usage): fix Resource Events filters — real event types, name search, no user filter
- Event type: trim the dropdown to the four types that are actually emitted
  (Created / Started / Stopped / Deleted) — updated/attached/detached were
  never recorded — and actually send the selection (event_types) to the API,
  which previously dropped it so the filter did nothing.
- Add a resource-name text filter (debounced, case-insensitive substring).
- Remove the "filter by user" select: the events list has no User column.
  Scope still applies (members see only their own events).
2026-06-09 11:54:28 +08:00
jialinandjialin 620045d7a3 fix: show the default backend params configured in backend 2026-06-08 18:25:22 +08:00
jialinandjialin 6dc28d61b0 fix: view event in starting 2026-06-08 18:25:22 +08:00
jialinandjialin 754cc29793 chore: update iconfont files 2026-06-08 16:38:27 +08:00
jialinandjialin ed525509ea chore(deps): update 1.0.23 2026-06-08 16:26:25 +08:00
jialinandjialin 2b6f26e6c8 fix: update addworker command for dtk 2026-06-08 15:43:21 +08:00
jialinandjialin 09821aab85 feat: add a billing holder page 2026-06-08 15:43:21 +08:00
jialinandjialin 636190b8e6 chore: show the backend info in instance view 2026-06-08 15:43:21 +08:00
jialinandjialin e420c22a45 fix(style): bar chart legend gap 2026-06-08 11:42:20 +08:00
jialinandjialin 993f6edcc4 fix: error message do not show in tts 2026-06-08 11:42:20 +08:00
173 changed files with 3178 additions and 1270 deletions
+1
View File
@@ -31,5 +31,6 @@ export default function createProxyTable(target?: string) {
}, },
{} {}
); );
return proxyTable; return proxyTable;
} }
+13
View File
@@ -338,6 +338,19 @@ const baseRoutes = [
selectedIcon: 'icon-usage-filled', selectedIcon: 'icon-usage-filled',
defaultIcon: 'icon-usage-outlined', defaultIcon: 'icon-usage-outlined',
component: './usage/index' 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
View File
@@ -17,7 +17,7 @@
"@ant-design/pro-components": "3.1.0-0", "@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51", "@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1", "@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.22", "@gpustack/core-ui": "^1.0.27",
"@huggingface/gguf": "^0.1.7", "@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1", "@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6", "@huggingface/tasks": "^0.11.6",
+2
View File
@@ -5,6 +5,8 @@ export default (api: IApi) => {
const info = JSON.parse(process.env.VERSION || '{}'); const info = JSON.parse(process.env.VERSION || '{}');
const env = process.env.NODE_ENV; const env = process.env.NODE_ENV;
$('html').attr('lang', 'en');
$('html').attr('data-env', env); $('html').attr('data-env', env);
$('html').attr( $('html').attr(
+5 -5
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1 specifier: ^7.1.1
version: 7.1.2 version: 7.1.2
'@gpustack/core-ui': '@gpustack/core-ui':
specifier: ^1.0.22 specifier: ^1.0.27
version: 1.0.22(czdvzceysqw7iv6pct2ucnb23e) version: 1.0.27(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf': '@huggingface/gguf':
specifier: ^0.1.7 specifier: ^0.1.7
version: 0.1.18 version: 0.1.18
@@ -1484,8 +1484,8 @@ packages:
resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz} resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz}
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
'@gpustack/core-ui@1.0.22': '@gpustack/core-ui@1.0.27':
resolution: {integrity: sha512-uFLGalziwuojjMIeXMlCZn3tLRGA6PCa0JxLxa/+kKP+xAsy7Fcxgh4PerbTyk+P+hKnYv8cD+DPpkObDGqJgg==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.22.tgz} resolution: {integrity: sha512-m3ue0EHFKULla0mpnpxZwn9LVaGKS+HnuzQYSBECQa4vaP8MEEQsR7oFBtG8bhWUWYA9VzP6k7fjozDkP6TymA==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.27.tgz}
peerDependencies: peerDependencies:
'@ant-design/icons': '>=6.0.0' '@ant-design/icons': '>=6.0.0'
'@ant-design/pro-components': 3.1.0-0 '@ant-design/pro-components': 3.1.0-0
@@ -10808,7 +10808,7 @@ snapshots:
'@formatjs/intl-utils@2.3.0': {} '@formatjs/intl-utils@2.3.0': {}
'@gpustack/core-ui@1.0.22(czdvzceysqw7iv6pct2ucnb23e)': '@gpustack/core-ui@1.0.27(czdvzceysqw7iv6pct2ucnb23e)':
dependencies: dependencies:
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+7
View File
@@ -55,3 +55,10 @@ export const userSettingsHelperAtom = atom(
} }
); );
export const hideModalTemporarilyAtom = atom<boolean>(false); export const hideModalTemporarilyAtom = atom<boolean>(false);
export const collapsedMenuGroupsAtom = atomWithStorage<string[]>(
'collapsedMenuGroups',
[],
undefined,
{ getOnInit: true }
);
+1 -4
View File
@@ -32,10 +32,7 @@ export const UpdateCheckAtom = atom<{
latest_version: '' latest_version: ''
}); });
export const initialPasswordAtom = atomWithStorage<string>( export const initialPasswordAtom = atom<string>('');
'initialPassword',
''
);
// Namespace the server creates for an Org's resources on each Kubernetes // Namespace the server creates for an Org's resources on each Kubernetes
// cluster. The format must match the backend's ``get_namespace_name`` // cluster. The format must match the backend's ``get_namespace_name``
+15 -3
View File
@@ -1,8 +1,8 @@
@font-face { @font-face {
font-family: "iconfont"; /* Project id 4613488 */ font-family: "iconfont"; /* Project id 4613488 */
src: url('iconfont.woff2?t=1780035199453') format('woff2'), src: url('iconfont.woff2?t=1780907696624') format('woff2'),
url('iconfont.woff?t=1780035199453') format('woff'), url('iconfont.woff?t=1780907696624') format('woff'),
url('iconfont.ttf?t=1780035199453') format('truetype'); url('iconfont.ttf?t=1780907696624') format('truetype');
} }
.iconfont { .iconfont {
@@ -13,6 +13,18 @@
-moz-osx-font-smoothing: grayscale; -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 { .icon-database-outlined:before {
content: "\e70a"; content: "\e70a";
} }
File diff suppressed because one or more lines are too long
+21
View File
@@ -5,6 +5,27 @@
"css_prefix_text": "icon-", "css_prefix_text": "icon-",
"description": "", "description": "",
"glyphs": [ "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", "icon_id": "47654402",
"name": "database_24dp_1F1F1F_FILL0_wght300_GRAD0_opsz24 (1", "name": "database_24dp_1F1F1F_FILL0_wght300_GRAD0_opsz24 (1",
Binary file not shown.
Binary file not shown.
Binary file not shown.
+46
View File
@@ -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 };
}
+4
View File
@@ -89,6 +89,10 @@ export function useUpdateChunkedList(options: {
...cacheDataListRef.current ...cacheDataListRef.current
].slice(0, limit); ].slice(0, limit);
options.setDataList?.([...cacheDataListRef.current], {
createdIds: newDataList.map((item) => item.id)
});
options.onCreate?.(latestCreateList); options.onCreate?.(latestCreateList);
} }
+2 -2
View File
@@ -63,8 +63,7 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
listRequestTokenRef.current?.cancel?.(); listRequestTokenRef.current?.cancel?.();
listRequestTokenRef.current = createAxiosToken(); listRequestTokenRef.current = createAxiosToken();
const params = { const params = {
page: 1, page: -1
perPage: 100
}; };
const res: any = await queryAllDataList(params, { const res: any = await queryAllDataList(params, {
token: listRequestTokenRef.current.token token: listRequestTokenRef.current.token
@@ -86,6 +85,7 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
return { return {
watchDataList, watchDataList,
setWatchDataList,
deleteItemFromCache: handleDeleteItemFromCache deleteItemFromCache: handleDeleteItemFromCache
}; };
} }
+2
View File
@@ -18,6 +18,7 @@ import { useAtom } from 'jotai';
import { useMemo } from 'react'; import { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { DEFAULT_ENTER_PAGE } from '../config/settings'; import { DEFAULT_ENTER_PAGE } from '../config/settings';
import GithubStar from './github-star';
const NewLabel = styled.span` const NewLabel = styled.span`
position: relative; position: relative;
@@ -260,6 +261,7 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
<Wrapper> <Wrapper>
{contextHolder} {contextHolder}
<PluginExtraField name="OrgSwitcher" isDarkTheme={isDarkTheme} /> <PluginExtraField name="OrgSwitcher" isDarkTheme={isDarkTheme} />
{process.env.ENABLE_ENTERPRISE !== 'true' && <GithubStar />}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
+140
View File
@@ -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;
+1 -1
View File
@@ -327,7 +327,7 @@ export default (props: any) => {
config={{ config={{
apiBaseUrl: GPUSTACK_API_BASE_URL, apiBaseUrl: GPUSTACK_API_BASE_URL,
theme: userSettings.theme, theme: userSettings.theme,
iconUrl: '//at.alicdn.com/t/c/font_4613488_rwchdketjg8.js', iconUrl: '',
isDarkTheme: userSettings.isDarkTheme, isDarkTheme: userSettings.isDarkTheme,
defaultColorPrimary: COLOR_PRIMARY defaultColorPrimary: COLOR_PRIMARY
}} }}
+21 -11
View File
@@ -1,9 +1,11 @@
import { collapsedMenuGroupsAtom } from '@/atoms/settings';
import { CaretDownOutlined } from '@ant-design/icons'; import { CaretDownOutlined } from '@ant-design/icons';
import { IconFont, OverlayScroller } from '@gpustack/core-ui'; import { IconFont, OverlayScroller } from '@gpustack/core-ui';
import { Link, useLocation } from '@umijs/max'; import { Link, useLocation } from '@umijs/max';
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
import { createStyles, type FullToken } from 'antd-style'; import { createStyles, type FullToken } from 'antd-style';
import React, { useMemo, useState } from 'react'; import { useAtom } from 'jotai';
import React, { useMemo } from 'react';
interface MenuItem { interface MenuItem {
icon?: string; icon?: string;
@@ -144,8 +146,19 @@ const SiderMenu: React.FC<SiderMenuProps> = (props) => {
const { menuData, collapsed } = props; const { menuData, collapsed } = props;
const { styles, cx } = useStyles(); const { styles, cx } = useStyles();
const location = useLocation(); const location = useLocation();
const [collapseKeys, setCollapseKeys] = useState<Set<string>>(new Set()); const [storedCollapsedGroups, setCollapsedGroups] = useAtom(
console.log('SiderMenu', location.pathname); 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(() => { const dividerStyles = useMemo(() => {
if (collapsed) { if (collapsed) {
@@ -164,14 +177,11 @@ const SiderMenu: React.FC<SiderMenuProps> = (props) => {
const handleToggleGroup = (e: any, menuGroup: any) => { const handleToggleGroup = (e: any, menuGroup: any) => {
e.stopPropagation(); e.stopPropagation();
console.log('handleToggleGroup', menuGroup.key); setCollapsedGroups(
collapsedGroups.includes(menuGroup.key)
if (collapseKeys.has(menuGroup.key)) { ? collapsedGroups.filter((key) => key !== menuGroup.key)
collapseKeys.delete(menuGroup.key); : [...collapsedGroups, menuGroup.key]
} else { );
collapseKeys.add(menuGroup.key);
}
setCollapseKeys(new Set(collapseKeys));
}; };
const menuItemRender = (menuItem: MenuItem, key: string) => { const menuItemRender = (menuItem: MenuItem, key: string) => {
+6 -5
View File
@@ -23,11 +23,12 @@ export default {
'backend.form.defaultExecuteCommand': 'Default Execution Command', '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.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.defaultBackendParameters': 'Default Backend Parameters',
'backend.form.parameterFormat': 'Parameter Format', 'backend.form.flagFormat': 'Flag Format',
'backend.form.parameterFormat.default': 'Backend Default', 'backend.form.flagFormat.tips':
'backend.form.parameterFormat.space': 'Space (--key value)', 'The format applied between an option and its value. Leave empty to keep each parameter as entered, without normalizing it.',
'backend.form.parameterFormat.equal': 'Equal (--key=value)', 'backend.form.flagFormat.space': 'Space Separated (--key value)',
'backend.form.commonParameters': 'Common Parameters', 'backend.form.flagFormat.equal': 'Equal Sign (--key=value)',
'backend.form.commonParameters': 'Common Backend Parameters',
'backend.form.commonParameters.tips': 'backend.form.commonParameters.tips':
'Shown as suggestions in the backend parameters input during deployment.', 'Shown as suggestions in the backend parameters input during deployment.',
'backend.form.versionConfig': 'Versions Config', 'backend.form.versionConfig': 'Versions Config',
+3 -3
View File
@@ -37,10 +37,10 @@ export default {
'Stress test for long-context handling. Evaluates KV cache behavior, memory usage, and backend stability.', 'Stress test for long-context handling. Evaluates KV cache behavior, memory usage, and backend stability.',
'benchmark.form.profile.heavy.tips': 'benchmark.form.profile.heavy.tips':
'Decode-heavy generation benchmark. Measures sustained decoding speed and output token throughput.', 'Decode-heavy generation benchmark. Measures sustained decoding speed and output token throughput.',
'benchmark.table.filter.bygpu': 'Filter by GPU', 'benchmark.table.filter.bygpu': 'Search by GPU',
'benchmark.table.filter.bymodel': 'Filter by Model', 'benchmark.table.filter.bymodel': 'Search by model',
'benchmark.table.filter.bydataset': 'Filter by Dataset', '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.avg': 'Avg',
'benchmark.table.columnSettings': 'Column Settings', 'benchmark.table.columnSettings': 'Column Settings',
'benchmark.detail.summary.results': 'Test Results', 'benchmark.detail.summary.results': 'Test Results',
+15
View File
@@ -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'
};
+5 -2
View File
@@ -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.', 'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.',
'clusters.addworker.selectGPU': 'Select GPU Vendor', 'clusters.addworker.selectGPU': 'Select GPU Vendor',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.subtitle':
'You can select multiple GPU Vendors or none for CPU-only clusters',
'clusters.addworker.checkEnv': 'Check Environment', '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.specifyArgs': 'Specify Arguments',
'clusters.addworker.dtkVersion': 'DTK Version',
'clusters.addworker.runCommand': 'Run Command', 'clusters.addworker.runCommand': 'Run Command',
'clusters.addworker.specifyWorkerIP': 'Worker IP', 'clusters.addworker.specifyWorkerIP': 'Worker IP',
'clusters.addworker.detectWorkerIP': 'Auto-detect Worker IP', 'clusters.addworker.detectWorkerIP': 'Auto-detect Worker IP',
@@ -112,8 +117,6 @@ export default {
'clusters.create.workerConfig': 'Worker Configuration', 'clusters.create.workerConfig': 'Worker Configuration',
'clusters.edit.k8sOptions.changed.tip': '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.', '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': 'Worker Container Name',
'clusters.addworker.containerName.tips': 'clusters.addworker.containerName.tips':
'Specify a name for the worker container.', 'Specify a name for the worker container.',
+5
View File
@@ -169,6 +169,7 @@ export default {
'common.time.hour': 'hour', 'common.time.hour': 'hour',
'common.time.minute': 'minutes', 'common.time.minute': 'minutes',
'common.issue.report': 'Report an issue', 'common.issue.report': 'Report an issue',
'common.github.star.tooltip': 'Star us on GitHub',
'common.social.discord': 'Join Our Discord', 'common.social.discord': 'Join Our Discord',
'common.table.mark': 'Comment', 'common.table.mark': 'Comment',
'common.table.rollback.mark': 'Rollback Comment', 'common.table.rollback.mark': 'Rollback Comment',
@@ -260,6 +261,10 @@ export default {
'common.login.auth': 'Authenticating...', 'common.login.auth': 'Authenticating...',
'common.login.auth.failed': 'Authentication failed', 'common.login.auth.failed': 'Authentication failed',
'common.login.password': 'Log in with Password', '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.external.login': 'Log in with {type}',
'common.sso.noConfig': 'common.sso.noConfig':
'Single sign-on is not enabled on this system. Please contact your administrator.', 'Single sign-on is not enabled on this system. Please contact your administrator.',
+7 -1
View File
@@ -120,6 +120,8 @@ export default {
'gpuservice.instance.sliced': 'Sliced', 'gpuservice.instance.sliced': 'Sliced',
'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'OS',
'gpuservice.instance.arch': 'Arch',
'gpuservice.instance.disk': 'Disk', 'gpuservice.instance.disk': 'Disk',
'gpuservice.table.count': 'Count', 'gpuservice.table.count': 'Count',
'gpuservice.instance.disk.system': 'System Disk', 'gpuservice.instance.disk.system': 'System Disk',
@@ -172,5 +174,9 @@ export default {
'Please enter the temporary storage capacity', 'Please enter the temporary storage capacity',
'gpuservice.form.rule.name': 'gpuservice.form.rule.name':
"Lowercase letters, numbers, and '-'. Start and end with a letter or number, no consecutive '-', max 63 characters.", "Lowercase letters, numbers, and '-'. Start and end with a letter or number, no consecutive '-', max 63 characters.",
'gpuservice.form.storage.select': 'Select Storage' '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'
}; };
+1
View File
@@ -38,6 +38,7 @@ export default {
'menu.usage.usage': 'Usage', 'menu.usage.usage': 'Usage',
'menu.billingAndUsage': 'Usage & Billing', 'menu.billingAndUsage': 'Usage & Billing',
'menu.billingAndUsage.usage': 'Usage', 'menu.billingAndUsage.usage': 'Usage',
'menu.billingAndUsage.billing': 'Billing',
'menu.404': '404', 'menu.404': '404',
'menu.resources.clusters': 'Clusters', 'menu.resources.clusters': 'Clusters',
'menu.resources.credentials': 'Cloud Credentials', 'menu.resources.credentials': 'Cloud Credentials',
+1
View File
@@ -90,6 +90,7 @@ export default {
// --- Resource events --- // --- Resource events ---
'usage.events.resourceType': 'Resource type', 'usage.events.resourceType': 'Resource type',
'usage.events.eventType': 'Event type', 'usage.events.eventType': 'Event type',
'usage.events.resourceName': 'Filter by name',
'usage.events.col.time': 'Time', 'usage.events.col.time': 'Time',
'usage.events.col.resource': 'Resource', 'usage.events.col.resource': 'Resource',
'usage.events.col.event': 'Event', 'usage.events.col.event': 'Event',
+6 -5
View File
@@ -23,11 +23,12 @@ export default {
'backend.form.defaultExecuteCommand': 'Default Execution Command', '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.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.defaultBackendParameters': 'Default Backend Parameters',
'backend.form.parameterFormat': 'パラメータ形式', 'backend.form.flagFormat': 'フラグ形式',
'backend.form.parameterFormat.default': 'バックエンドのデフォルト', 'backend.form.flagFormat.tips':
'backend.form.parameterFormat.space': 'スペース区切り (--key value)', 'オプションとその値を連結する形式。空欄の場合は各パラメータを入力されたまま保持し、形式を統一しません。',
'backend.form.parameterFormat.equal': 'イコール連結 (--key=value)', 'backend.form.flagFormat.space': 'スペース区切り (--key value)',
'backend.form.commonParameters': 'よく使うパラメータ', 'backend.form.flagFormat.equal': 'イコール記号 (--key=value)',
'backend.form.commonParameters': 'よく使うバックエンドパラメータ',
'backend.form.commonParameters.tips': 'backend.form.commonParameters.tips':
'モデルのデプロイ時にバックエンドパラメータ入力欄の候補として表示されます。', 'モデルのデプロイ時にバックエンドパラメータ入力欄の候補として表示されます。',
'backend.form.versionConfig': 'Versions Config', 'backend.form.versionConfig': 'Versions Config',
+2 -2
View File
@@ -37,8 +37,8 @@ export default {
'Stress test for long-context handling. Evaluates KV cache behavior, memory usage, and backend stability.', 'Stress test for long-context handling. Evaluates KV cache behavior, memory usage, and backend stability.',
'benchmark.form.profile.heavy.tips': 'benchmark.form.profile.heavy.tips':
'Decode-heavy generation benchmark. Measures sustained decoding speed and output token throughput.', 'Decode-heavy generation benchmark. Measures sustained decoding speed and output token throughput.',
'benchmark.table.filter.bygpu': 'Filter by GPU', 'benchmark.table.filter.bygpu': 'GPU 検索',
'benchmark.table.filter.bymodel': 'Filter by Model', 'benchmark.table.filter.bymodel': 'モデル検索',
'benchmark.table.filter.bydataset': 'Filter by Dataset', '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.avg': 'Avg',
+15
View File
@@ -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'
};
+5 -2
View File
@@ -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.', 'For <span class="bold-text">non-Docker</span> clusters, please register clusters or manage worker pools from the Clusters page.',
'clusters.addworker.selectGPU': 'Select GPU Vendor', 'clusters.addworker.selectGPU': 'Select GPU Vendor',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.subtitle':
'複数の GPU ベンダーを選択するか、CPU クラスター専用の場合は選択不要です',
'clusters.addworker.checkEnv': 'Check Environment', 'clusters.addworker.checkEnv': 'Check Environment',
'clusters.addworker.checkEnv.cpuOnlyTips':
'以下のコマンドを使用して、Kubernetes クラスターに少なくとも 1 つのレディーノードがあることを確認してください。CPU クラスターを登録しています。',
'clusters.addworker.specifyArgs': 'Specify Arguments', 'clusters.addworker.specifyArgs': 'Specify Arguments',
'clusters.addworker.dtkVersion': 'DTK バージョン',
'clusters.addworker.runCommand': 'Run Command', 'clusters.addworker.runCommand': 'Run Command',
'clusters.addworker.specifyWorkerIP': 'Worker IP', 'clusters.addworker.specifyWorkerIP': 'Worker IP',
'clusters.addworker.detectWorkerIP': 'Worker IP を自動検出', 'clusters.addworker.detectWorkerIP': 'Worker IP を自動検出',
@@ -112,8 +117,6 @@ export default {
'clusters.create.workerConfig': 'Worker Configuration', 'clusters.create.workerConfig': 'Worker Configuration',
'clusters.edit.k8sOptions.changed.tip': 'clusters.edit.k8sOptions.changed.tip':
'Kubernetes オプションを変更しました。変更を有効にするには、対象クラスターで登録コマンドを再実行してください。', 'Kubernetes オプションを変更しました。変更を有効にするには、対象クラスターで登録コマンドを再実行してください。',
'clusters.edit.workerConfig.tip':
'ワーカー設定の変更は、対象のワーカーを再起動した後に有効になります。',
'clusters.addworker.containerName': 'Worker Container Name', 'clusters.addworker.containerName': 'Worker Container Name',
'clusters.addworker.containerName.tips': 'clusters.addworker.containerName.tips':
'Specify a name for the worker container.', 'Specify a name for the worker container.',
+5
View File
@@ -170,6 +170,7 @@ export default {
'common.time.hour': '時間', 'common.time.hour': '時間',
'common.time.minute': '分', 'common.time.minute': '分',
'common.issue.report': '問題を報告', 'common.issue.report': '問題を報告',
'common.github.star.tooltip': 'GitHub でスターをつける',
'common.social.discord': 'Discordに参加', 'common.social.discord': 'Discordに参加',
'common.table.mark': 'コメント', 'common.table.mark': 'コメント',
'common.table.rollback.mark': 'ロールバックコメント', 'common.table.rollback.mark': 'ロールバックコメント',
@@ -260,6 +261,10 @@ export default {
'common.login.auth': 'Authenticating...', 'common.login.auth': 'Authenticating...',
'common.login.auth.failed': 'Authentication failed', 'common.login.auth.failed': 'Authentication failed',
'common.login.password': 'Log in with Password', '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.external.login': 'Log in with {type}',
'common.sso.noConfig': 'common.sso.noConfig':
'Single sign-on is not enabled on this system. Please contact your administrator.', 'Single sign-on is not enabled on this system. Please contact your administrator.',
+7 -1
View File
@@ -119,6 +119,8 @@ export default {
'gpuservice.instance.sliced': '分割', 'gpuservice.instance.sliced': '分割',
'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'OS',
'gpuservice.instance.arch': 'アーキテクチャ',
'gpuservice.instance.disk': 'ディスク', 'gpuservice.instance.disk': 'ディスク',
'gpuservice.table.count': '数量', 'gpuservice.table.count': '数量',
'gpuservice.instance.disk.system': 'システムディスク', 'gpuservice.instance.disk.system': 'システムディスク',
@@ -172,5 +174,9 @@ export default {
'Data is cleared when the instance stops.', 'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips': 'gpuservice.storage.persistentVolume.tips':
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.', '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': 'グローバルテンプレート'
}; };
+1
View File
@@ -29,6 +29,7 @@ export default {
'menu.usage.usage': '使用状況', 'menu.usage.usage': '使用状況',
'menu.billingAndUsage': '使用状況と請求', 'menu.billingAndUsage': '使用状況と請求',
'menu.billingAndUsage.usage': '使用状況', 'menu.billingAndUsage.usage': '使用状況',
'menu.billingAndUsage.billing': '請求',
'menu.404': '404', 'menu.404': '404',
'menu.settings': 'Settings', 'menu.settings': 'Settings',
'menu.resources.workers': 'Workers', 'menu.resources.workers': 'Workers',
+1
View File
@@ -90,6 +90,7 @@ export default {
// --- Resource events --- // --- Resource events ---
'usage.events.resourceType': 'リソースタイプ', 'usage.events.resourceType': 'リソースタイプ',
'usage.events.eventType': 'イベントタイプ', 'usage.events.eventType': 'イベントタイプ',
'usage.events.resourceName': '名前で絞り込み',
'usage.events.col.time': '時刻', 'usage.events.col.time': '時刻',
'usage.events.col.resource': 'リソース', 'usage.events.col.resource': 'リソース',
'usage.events.col.event': 'イベント', 'usage.events.col.event': 'イベント',
+6 -5
View File
@@ -23,11 +23,12 @@ export default {
'backend.form.defaultExecuteCommand': 'Команда выполнения по умолчанию', 'backend.form.defaultExecuteCommand': 'Команда выполнения по умолчанию',
'backend.form.defaultExecuteCommand.tips': `'{{'model_path'}}', '{{'port'}}', '{{'worker_ip'}}' и '{{'model_name'}}' заполняются реальными значениями во время запуска`, 'backend.form.defaultExecuteCommand.tips': `'{{'model_path'}}', '{{'port'}}', '{{'worker_ip'}}' и '{{'model_name'}}' заполняются реальными значениями во время запуска`,
'backend.form.defaultBackendParameters': 'Параметры бэкенда по умолчанию', 'backend.form.defaultBackendParameters': 'Параметры бэкенда по умолчанию',
'backend.form.parameterFormat': 'Формат параметров', 'backend.form.flagFormat': 'Формат флага',
'backend.form.parameterFormat.default': 'По умолчанию бэкенда', 'backend.form.flagFormat.tips':
'backend.form.parameterFormat.space': 'Пробел (--key value)', 'Формат соединения опции и её значения. Оставьте пустым, чтобы сохранить каждый параметр в исходном виде, без приведения к единому формату.',
'backend.form.parameterFormat.equal': 'Знак равенства (--key=value)', 'backend.form.flagFormat.space': 'Разделение пробелом (--key value)',
'backend.form.commonParameters': 'Часто используемые параметры', 'backend.form.flagFormat.equal': 'Знак равенства (--key=value)',
'backend.form.commonParameters': 'Общие параметры бэкенда',
'backend.form.commonParameters.tips': 'backend.form.commonParameters.tips':
'Отображаются как подсказки в поле параметров бэкенда при развёртывании.', 'Отображаются как подсказки в поле параметров бэкенда при развёртывании.',
'backend.form.versionConfig': 'Конфигурация версий', 'backend.form.versionConfig': 'Конфигурация версий',
+2 -2
View File
@@ -37,8 +37,8 @@ export default {
'Stress test for long-context handling. Evaluates KV cache behavior, memory usage, and backend stability.', 'Stress test for long-context handling. Evaluates KV cache behavior, memory usage, and backend stability.',
'benchmark.form.profile.heavy.tips': 'benchmark.form.profile.heavy.tips':
'Decode-heavy generation benchmark. Measures sustained decoding speed and output token throughput.', 'Decode-heavy generation benchmark. Measures sustained decoding speed and output token throughput.',
'benchmark.table.filter.bygpu': 'Filter by GPU', 'benchmark.table.filter.bygpu': 'Поиск GPU',
'benchmark.table.filter.bymodel': 'Filter by Model', 'benchmark.table.filter.bymodel': 'Поиск модели',
'benchmark.table.filter.bydataset': 'Filter by Dataset', '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.avg': 'Avg',
+15
View File
@@ -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'
};
+5 -2
View File
@@ -70,8 +70,13 @@ export default {
'Для <span class="bold-text">не-Docker</span> кластеров, пожалуйста, регистрируйте кластеры или управляйте пулами воркеров на странице Кластеры.', 'Для <span class="bold-text">не-Docker</span> кластеров, пожалуйста, регистрируйте кластеры или управляйте пулами воркеров на странице Кластеры.',
'clusters.addworker.selectGPU': 'Выбрать производителя GPU', 'clusters.addworker.selectGPU': 'Выбрать производителя GPU',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.subtitle':
'Вы можете выбрать несколько производителей GPU или не выбирать для кластера только с CPU',
'clusters.addworker.checkEnv': 'Проверить окружение', 'clusters.addworker.checkEnv': 'Проверить окружение',
'clusters.addworker.checkEnv.cpuOnlyTips':
'Используйте следующую команду, чтобы убедиться, что в кластере Kubernetes есть хотя бы один готовый узел. Вы регистрируете кластер только с CPU.',
'clusters.addworker.specifyArgs': 'Указать аргументы', 'clusters.addworker.specifyArgs': 'Указать аргументы',
'clusters.addworker.dtkVersion': 'Версия DTK',
'clusters.addworker.runCommand': 'Выполнить команду', 'clusters.addworker.runCommand': 'Выполнить команду',
'clusters.addworker.specifyWorkerIP': 'Указать IP воркера', 'clusters.addworker.specifyWorkerIP': 'Указать IP воркера',
'clusters.addworker.detectWorkerIP': 'Автоматически определить IP воркера', 'clusters.addworker.detectWorkerIP': 'Автоматически определить IP воркера',
@@ -112,8 +117,6 @@ export default {
'clusters.create.workerConfig': 'Конфигурация воркера', 'clusters.create.workerConfig': 'Конфигурация воркера',
'clusters.edit.k8sOptions.changed.tip': 'clusters.edit.k8sOptions.changed.tip':
'Вы изменили параметры Kubernetes. Чтобы изменения вступили в силу, повторно выполните команду регистрации в целевом кластере.', 'Вы изменили параметры Kubernetes. Чтобы изменения вступили в силу, повторно выполните команду регистрации в целевом кластере.',
'clusters.edit.workerConfig.tip':
'Изменения конфигурации воркера вступают в силу только после перезапуска соответствующих воркеров.',
'clusters.addworker.containerName': 'Имя контейнера воркера', 'clusters.addworker.containerName': 'Имя контейнера воркера',
'clusters.addworker.containerName.tips': 'clusters.addworker.containerName.tips':
'Укажите имя для контейнера воркера.', 'Укажите имя для контейнера воркера.',
+5
View File
@@ -167,6 +167,7 @@ export default {
'common.time.hour': 'Час', 'common.time.hour': 'Час',
'common.time.minute': 'Минута', 'common.time.minute': 'Минута',
'common.issue.report': 'Сообщить о проблеме', 'common.issue.report': 'Сообщить о проблеме',
'common.github.star.tooltip': 'Поставьте нам звезду на GitHub',
'common.social.discord': 'Присоединиться к Discord', 'common.social.discord': 'Присоединиться к Discord',
'common.table.mark': 'Комментарий', 'common.table.mark': 'Комментарий',
'common.table.rollback.mark': 'Комментарий к откату', 'common.table.rollback.mark': 'Комментарий к откату',
@@ -259,6 +260,10 @@ export default {
'common.login.auth': 'Аутентификация...', 'common.login.auth': 'Аутентификация...',
'common.login.auth.failed': 'Ошибка аутентификации', 'common.login.auth.failed': 'Ошибка аутентификации',
'common.login.password': 'Войти с паролем', 'common.login.password': 'Войти с паролем',
'common.login.username.holder': 'Введите имя пользователя',
'common.login.password.holder': 'Введите пароль',
'common.login.newpassword.holder': 'Введите новый пароль',
'common.login.confirm.holder': 'Введите пароль ещё раз',
'common.external.login': 'Войти через {type}', 'common.external.login': 'Войти через {type}',
'common.sso.noConfig': 'common.sso.noConfig':
'Единый вход не настроен в этой системе. Пожалуйста, обратитесь к администратору.', 'Единый вход не настроен в этой системе. Пожалуйста, обратитесь к администратору.',
+7 -1
View File
@@ -118,6 +118,8 @@ export default {
'gpuservice.instance.sliced': 'Разделено', 'gpuservice.instance.sliced': 'Разделено',
'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'ОС',
'gpuservice.instance.arch': 'Архитектура',
'gpuservice.instance.disk': 'Диск', 'gpuservice.instance.disk': 'Диск',
'gpuservice.table.count': 'Количество', 'gpuservice.table.count': 'Количество',
'gpuservice.instance.disk.system': 'Системный диск', 'gpuservice.instance.disk.system': 'Системный диск',
@@ -169,5 +171,9 @@ export default {
'Data is cleared when the instance stops.', 'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips': 'gpuservice.storage.persistentVolume.tips':
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.', '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': 'Глобальные шаблоны'
}; };
+1
View File
@@ -29,6 +29,7 @@ export default {
'menu.usage.usage': 'Использование', 'menu.usage.usage': 'Использование',
'menu.billingAndUsage': 'Использование и биллинг', 'menu.billingAndUsage': 'Использование и биллинг',
'menu.billingAndUsage.usage': 'Использование', 'menu.billingAndUsage.usage': 'Использование',
'menu.billingAndUsage.billing': 'Биллинг',
'menu.404': 'Ошибка 404', 'menu.404': 'Ошибка 404',
'menu.resources.workers': 'Воркеры', 'menu.resources.workers': 'Воркеры',
'menu.resources.gpus': 'GPUs', 'menu.resources.gpus': 'GPUs',
+1
View File
@@ -90,6 +90,7 @@ export default {
// --- Resource events --- // --- Resource events ---
'usage.events.resourceType': 'Тип ресурса', 'usage.events.resourceType': 'Тип ресурса',
'usage.events.eventType': 'Тип события', 'usage.events.eventType': 'Тип события',
'usage.events.resourceName': 'Фильтр по имени',
'usage.events.col.time': 'Время', 'usage.events.col.time': 'Время',
'usage.events.col.resource': 'Ресурс', 'usage.events.col.resource': 'Ресурс',
'usage.events.col.event': 'Событие', 'usage.events.col.event': 'Событие',
+6 -5
View File
@@ -23,11 +23,12 @@ export default {
'backend.form.defaultExecuteCommand': 'Varsayılan Çalıştırma Komutu', '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.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.defaultBackendParameters': 'Varsayılan Altyapı Parametreleri',
'backend.form.parameterFormat': 'Parametre Biçimi', 'backend.form.flagFormat': 'Bayrak Biçimi',
'backend.form.parameterFormat.default': 'Varsayılan', 'backend.form.flagFormat.tips':
'backend.form.parameterFormat.space': 'Boşluk (--key value)', '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.parameterFormat.equal': 'Eşittir (--key=value)', 'backend.form.flagFormat.space': 'Boşlukla Ayır (--key value)',
'backend.form.commonParameters': 'Yaygın Parametreler', 'backend.form.flagFormat.equal': 'Eşittir İşareti (--key=value)',
'backend.form.commonParameters': 'Ortak Altyapı Parametreleri',
'backend.form.commonParameters.tips': 'backend.form.commonParameters.tips':
'Dağıtım sırasında altyapı parametreleri girişinde öneri olarak gösterilir.', 'Dağıtım sırasında altyapı parametreleri girişinde öneri olarak gösterilir.',
'backend.form.versionConfig': 'Sürüm Yapılandırması', 'backend.form.versionConfig': 'Sürüm Yapılandırması',
+2 -2
View File
@@ -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.', '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': '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.', 'Çö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.bygpu': 'GPU ara',
'benchmark.table.filter.bymodel': 'Modele göre filtrele', 'benchmark.table.filter.bymodel': 'Model ara',
'benchmark.table.filter.bydataset': 'Veri Kümesine göre filtrele', 'benchmark.table.filter.bydataset': 'Veri Kümesine göre filtrele',
'benchmark.table.filter.byProfile': 'Profile göre filtrele', 'benchmark.table.filter.byProfile': 'Profile göre filtrele',
'benchmark.table.avg': 'Ort.', 'benchmark.table.avg': 'Ort.',
+15
View File
@@ -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'
};
+5 -2
View File
@@ -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.', '<span class="bold-text">Docker dışı</span> kümeler için lütfen Kümeler sayfasından küme kaydı oluşturun veya işçi havuzlarını yönetin.',
'clusters.addworker.selectGPU': 'GPU Üreticisi Seç', 'clusters.addworker.selectGPU': 'GPU Üreticisi Seç',
'clusters.addworker.selectGPU.multiTag': 'Multi-select', 'clusters.addworker.selectGPU.multiTag': 'Multi-select',
'clusters.addworker.selectGPU.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': '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.specifyArgs': 'Argümanları Belirle',
'clusters.addworker.dtkVersion': 'DTK Sürümü',
'clusters.addworker.runCommand': 'Komutu Çalıştır', 'clusters.addworker.runCommand': 'Komutu Çalıştır',
'clusters.addworker.specifyWorkerIP': "İşçi Düğüm IP'si", 'clusters.addworker.specifyWorkerIP': "İşçi Düğüm IP'si",
'clusters.addworker.detectWorkerIP': "İşçi Düğüm IP'sini Otomatik Algıla", '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.create.workerConfig': 'İşçi Düğüm Yapılandırması',
'clusters.edit.k8sOptions.changed.tip': '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.', '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': 'İşçi Düğüm Konteyner Adı',
'clusters.addworker.containerName.tips': 'clusters.addworker.containerName.tips':
'İşçi düğüm konteyneri için bir ad belirtin.', 'İşçi düğüm konteyneri için bir ad belirtin.',
+5
View File
@@ -167,6 +167,7 @@ export default {
'common.time.hour': 'saat', 'common.time.hour': 'saat',
'common.time.minute': 'dakika', 'common.time.minute': 'dakika',
'common.issue.report': 'Sorun bildir', '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.social.discord': "Discord'umuza Katılın",
'common.table.mark': 'Yorum', 'common.table.mark': 'Yorum',
'common.table.rollback.mark': 'Geri Alma Yorumu', 'common.table.rollback.mark': 'Geri Alma Yorumu',
@@ -263,6 +264,10 @@ export default {
'common.login.auth': 'Kimlik doğrulanıyor...', 'common.login.auth': 'Kimlik doğrulanıyor...',
'common.login.auth.failed': 'Kimlik doğrulama başarısız', 'common.login.auth.failed': 'Kimlik doğrulama başarısız',
'common.login.password': 'Şifre ile giriş yap', '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.external.login': '{type} ile giriş yap',
'common.sso.noConfig': 'common.sso.noConfig':
'Bu sistemde çoklu oturum açma etkinleştirilmemiş. Lütfen yöneticinize başvurun.', 'Bu sistemde çoklu oturum açma etkinleştirilmemiş. Lütfen yöneticinize başvurun.',
+7 -1
View File
@@ -114,6 +114,8 @@ export default {
'gpuservice.instance.sliced': 'Bölünmüş', 'gpuservice.instance.sliced': 'Bölünmüş',
'gpuservice.instance.memory': 'VRAM', 'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'OS',
'gpuservice.instance.arch': 'Mimari',
'gpuservice.instance.disk': 'Disk', 'gpuservice.instance.disk': 'Disk',
'gpuservice.table.count': 'Sayı', 'gpuservice.table.count': 'Sayı',
'gpuservice.instance.disk.system': 'Sistem Diski', 'gpuservice.instance.disk.system': 'Sistem Diski',
@@ -167,5 +169,9 @@ export default {
'Data is cleared when the instance stops.', 'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips': 'gpuservice.storage.persistentVolume.tips':
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.', 'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
'gpuservice.form.storage.select': 'Depolamayı Seç' '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'
}; };
+1
View File
@@ -36,6 +36,7 @@ export default {
'menu.usage.usage': 'Kullanım', 'menu.usage.usage': 'Kullanım',
'menu.billingAndUsage': 'Kullanım ve Faturalandırma', 'menu.billingAndUsage': 'Kullanım ve Faturalandırma',
'menu.billingAndUsage.usage': 'Kullanım', 'menu.billingAndUsage.usage': 'Kullanım',
'menu.billingAndUsage.billing': 'Faturalandırma',
'menu.404': '404', 'menu.404': '404',
'menu.resources.clusters': 'Kümeler', 'menu.resources.clusters': 'Kümeler',
'menu.resources.credentials': 'Bulut Kimlik Bilgileri', 'menu.resources.credentials': 'Bulut Kimlik Bilgileri',
+1
View File
@@ -90,6 +90,7 @@ export default {
// --- Resource events --- // --- Resource events ---
'usage.events.resourceType': 'Kaynak türü', 'usage.events.resourceType': 'Kaynak türü',
'usage.events.eventType': 'Olay türü', 'usage.events.eventType': 'Olay türü',
'usage.events.resourceName': 'Ada göre filtrele',
'usage.events.col.time': 'Zaman', 'usage.events.col.time': 'Zaman',
'usage.events.col.resource': 'Kaynak', 'usage.events.col.resource': 'Kaynak',
'usage.events.col.event': 'Olay', 'usage.events.col.event': 'Olay',
+6 -5
View File
@@ -23,11 +23,12 @@ export default {
'backend.form.defaultExecuteCommand': '默认执行命令', 'backend.form.defaultExecuteCommand': '默认执行命令',
'backend.form.defaultExecuteCommand.tips': `'{{'model_path'}}'、'{{'port'}}'、'{{'worker_ip'}}' 和 '{{'model_name'}}' 都是占位符,在部署过程中会被替换为实际的值。`, 'backend.form.defaultExecuteCommand.tips': `'{{'model_path'}}'、'{{'port'}}'、'{{'worker_ip'}}' 和 '{{'model_name'}}' 都是占位符,在部署过程中会被替换为实际的值。`,
'backend.form.defaultBackendParameters': '默认后端参数', 'backend.form.defaultBackendParameters': '默认后端参数',
'backend.form.parameterFormat': '参数输出格式', 'backend.form.flagFormat': '参数格式',
'backend.form.parameterFormat.default': '后端默认', 'backend.form.flagFormat.tips':
'backend.form.parameterFormat.space': '空格分隔 (--key value)', '选项名与取值之间使用的连接格式。留空则保留每个参数原本的写法,不做统一转换。',
'backend.form.parameterFormat.equal': '等号连接 (--key=value)', 'backend.form.flagFormat.space': '空格分隔 (--key value)',
'backend.form.commonParameters': '常用参数', 'backend.form.flagFormat.equal': '等号连接 (--key=value)',
'backend.form.commonParameters': '常用后端参数',
'backend.form.commonParameters.tips': '部署模型时作为后端参数候选项展示。', 'backend.form.commonParameters.tips': '部署模型时作为后端参数候选项展示。',
'backend.form.versionConfig': '版本配置', 'backend.form.versionConfig': '版本配置',
'backend.form.addParameter': '添加参数', 'backend.form.addParameter': '添加参数',
+2 -2
View File
@@ -37,8 +37,8 @@ export default {
'长上下文压力测试。评估 KV Cache 行为、内存占用及后端稳定性。', '长上下文压力测试。评估 KV Cache 行为、内存占用及后端稳定性。',
'benchmark.form.profile.heavy.tips': 'benchmark.form.profile.heavy.tips':
'以解码为主的生成测试。衡量持续解码速度与输出 Token 吞吐能力。', '以解码为主的生成测试。衡量持续解码速度与输出 Token 吞吐能力。',
'benchmark.table.filter.bygpu': 'GPU 过滤', 'benchmark.table.filter.bygpu': 'GPU 查询',
'benchmark.table.filter.bymodel': '模型过滤', 'benchmark.table.filter.bymodel': '模型查询',
'benchmark.table.filter.bydataset': '按数据集过滤', 'benchmark.table.filter.bydataset': '按数据集过滤',
'benchmark.table.filter.byProfile': '按模式过滤', 'benchmark.table.filter.byProfile': '按模式过滤',
'benchmark.table.avg': '均值', 'benchmark.table.avg': '均值',
+11
View File
@@ -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': '了解企业版'
};
+5 -2
View File
@@ -68,8 +68,13 @@ export default {
'<span class="bold-text">非 Docker</span> 集群请前往集群页面注册集群或管理节点池。', '<span class="bold-text">非 Docker</span> 集群请前往集群页面注册集群或管理节点池。',
'clusters.addworker.selectGPU': '选择 GPU 厂商', 'clusters.addworker.selectGPU': '选择 GPU 厂商',
'clusters.addworker.selectGPU.multiTag': '可多选', 'clusters.addworker.selectGPU.multiTag': '可多选',
'clusters.addworker.selectGPU.subtitle':
'可选择多个 GPU 厂商,或不选择以用于仅 CPU 的集群',
'clusters.addworker.checkEnv': '检查环境', 'clusters.addworker.checkEnv': '检查环境',
'clusters.addworker.checkEnv.cpuOnlyTips':
'使用以下命令验证 Kubernetes 集群中至少有一个就绪节点。你正在注册仅 CPU 的集群。',
'clusters.addworker.specifyArgs': '指定参数', 'clusters.addworker.specifyArgs': '指定参数',
'clusters.addworker.dtkVersion': 'DTK 版本',
'clusters.addworker.runCommand': '运行指令', 'clusters.addworker.runCommand': '运行指令',
'clusters.addworker.specifyWorkerIP': '节点 IP', 'clusters.addworker.specifyWorkerIP': '节点 IP',
'clusters.addworker.detectWorkerIP': '自动检测节点 IP', 'clusters.addworker.detectWorkerIP': '自动检测节点 IP',
@@ -110,8 +115,6 @@ export default {
'clusters.create.workerConfig': '节点配置', 'clusters.create.workerConfig': '节点配置',
'clusters.edit.k8sOptions.changed.tip': 'clusters.edit.k8sOptions.changed.tip':
'您已修改 Kubernetes 选项,需要在目标集群上重新运行注册命令才会生效。', '您已修改 Kubernetes 选项,需要在目标集群上重新运行注册命令才会生效。',
'clusters.edit.workerConfig.tip':
'修改节点配置后,需要重启对应节点才会生效。',
'clusters.addworker.containerName': '节点容器名称', 'clusters.addworker.containerName': '节点容器名称',
'clusters.addworker.containerName.tips': '为节点容器指定一个名称。', 'clusters.addworker.containerName.tips': '为节点容器指定一个名称。',
'clusters.addworker.dataVolume': 'GPUStack 数据卷', 'clusters.addworker.dataVolume': 'GPUStack 数据卷',
+6 -1
View File
@@ -161,6 +161,7 @@ export default {
'common.time.hour': '小时', 'common.time.hour': '小时',
'common.time.minute': '分钟', 'common.time.minute': '分钟',
'common.issue.report': '问题反馈', 'common.issue.report': '问题反馈',
'common.github.star.tooltip': '在 GitHub 上 Star 我们',
'common.social.discord': '加入我们的Discord', 'common.social.discord': '加入我们的Discord',
'common.table.mark': '备注', 'common.table.mark': '备注',
'common.table.rollback.mark': '回滚备注', 'common.table.rollback.mark': '回滚备注',
@@ -253,6 +254,10 @@ export default {
'common.login.auth': '认证中...', 'common.login.auth': '认证中...',
'common.login.auth.failed': '认证失败', 'common.login.auth.failed': '认证失败',
'common.login.password': '使用密码登录', 'common.login.password': '使用密码登录',
'common.login.username.holder': '请输入用户名',
'common.login.password.holder': '请输入密码',
'common.login.newpassword.holder': '请输入新密码',
'common.login.confirm.holder': '请再次输入密码',
'common.external.login': '使用 {type} 登录', 'common.external.login': '使用 {type} 登录',
'common.sso.noConfig': '该系统未启用单点登录,请联系管理员。', 'common.sso.noConfig': '该系统未启用单点登录,请联系管理员。',
'common.button.edit.item': '编辑 {name}', 'common.button.edit.item': '编辑 {name}',
@@ -260,7 +265,7 @@ export default {
'common.button.terminal': '终端', 'common.button.terminal': '终端',
'common.button.addItem': '添加项', 'common.button.addItem': '添加项',
'common.help.default': '默认: {content}', 'common.help.default': '默认: {content}',
'common.filter.status': '按状态筛选', 'common.filter.status': '按状态过滤',
'common.form.rule.selectInput': '请选择或输入{name}', 'common.form.rule.selectInput': '请选择或输入{name}',
'common.tag.experimental': '实验性', 'common.tag.experimental': '实验性',
'common.title.example': '示例', 'common.title.example': '示例',
+7 -1
View File
@@ -109,6 +109,8 @@ export default {
'gpuservice.instance.sliced': '切分', 'gpuservice.instance.sliced': '切分',
'gpuservice.instance.memory': '显存', 'gpuservice.instance.memory': '显存',
'gpuservice.instance.ram': '内存', 'gpuservice.instance.ram': '内存',
'gpuservice.instance.os': '系统',
'gpuservice.instance.arch': '架构',
'gpuservice.instance.disk': '磁盘', 'gpuservice.instance.disk': '磁盘',
'gpuservice.table.count': '数量', 'gpuservice.table.count': '数量',
'gpuservice.instance.disk.system': '系统盘', 'gpuservice.instance.disk.system': '系统盘',
@@ -156,5 +158,9 @@ export default {
'gpuservice.storage.tempCapacity.required': '请输入临时存储容量', 'gpuservice.storage.tempCapacity.required': '请输入临时存储容量',
'gpuservice.form.rule.name': 'gpuservice.form.rule.name':
'由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。', '由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。',
'gpuservice.form.storage.select': '选择存储' 'gpuservice.form.storage.select': '选择存储',
'gpuservice.creator': '创建者',
'gpuservice.owner.global': '全局',
'gpuservice.template.group.yours': '我的模板',
'gpuservice.template.group.global': '全局模板'
}; };
+1
View File
@@ -31,6 +31,7 @@ export default {
'menu.usage.usage': '使用量', 'menu.usage.usage': '使用量',
'menu.billingAndUsage': '用量与计费', 'menu.billingAndUsage': '用量与计费',
'menu.billingAndUsage.usage': '用量统计', 'menu.billingAndUsage.usage': '用量统计',
'menu.billingAndUsage.billing': '计费',
'menu.404': '404', 'menu.404': '404',
'menu.resources.workers': '节点', 'menu.resources.workers': '节点',
'menu.resources.gpus': 'GPU', 'menu.resources.gpus': 'GPU',
+1 -1
View File
@@ -99,7 +99,7 @@ export default {
'models.form.search.gguftips': 'models.form.search.gguftips':
'当 macOS 或 Windows 作节点时勾选 GGUF(搜索语音模型时取消勾选)', '当 macOS 或 Windows 作节点时勾选 GGUF(搜索语音模型时取消勾选)',
'models.form.button.addlabel': '添加标签', 'models.form.button.addlabel': '添加标签',
'models.filter.category': '按类别筛选', 'models.filter.category': '按类别过滤',
'models.list.more.logs': '查看更多', 'models.list.more.logs': '查看更多',
'models.catalog.release.date': '发布日期', 'models.catalog.release.date': '发布日期',
'models.localpath.gguf.tips.title': 'GGUF 格式模型', 'models.localpath.gguf.tips.title': 'GGUF 格式模型',
+1
View File
@@ -90,6 +90,7 @@ export default {
// --- Resource events --- // --- Resource events ---
'usage.events.resourceType': '资源类型', 'usage.events.resourceType': '资源类型',
'usage.events.eventType': '事件类型', 'usage.events.eventType': '事件类型',
'usage.events.resourceName': '按名称筛选',
'usage.events.col.time': '时间', 'usage.events.col.time': '时间',
'usage.events.col.resource': '资源', 'usage.events.col.resource': '资源',
'usage.events.col.event': '事件', 'usage.events.col.event': '事件',
+11 -2
View File
@@ -21,6 +21,13 @@ export interface BarChartProps {
labelFormatter?: (val: any, index?: number) => string; labelFormatter?: (val: any, index?: number) => string;
tooltipValueFormatter?: (val: any) => string; tooltipValueFormatter?: (val: any) => string;
title?: string; title?: string;
grid?: {
left?: number | string;
right?: number | string;
top?: number | string;
bottom?: number | string;
containLabel?: boolean;
};
legendIsolate?: boolean; legendIsolate?: boolean;
} }
@@ -34,6 +41,7 @@ const BarChart: React.FC<BarChartProps> = (props) => {
labelFormatter, labelFormatter,
tooltipValueFormatter, tooltipValueFormatter,
title, title,
grid,
legendIsolate legendIsolate
} = props; } = props;
const { token } = theme.useToken(); const { token } = theme.useToken();
@@ -126,8 +134,9 @@ const BarChart: React.FC<BarChartProps> = (props) => {
left: 0, left: 0,
right: 0, right: 0,
top: title ? 30 : 10, top: title ? 30 : 10,
bottom: 0, bottom: 8,
containLabel: true containLabel: true,
...grid
}, },
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
@@ -1,5 +1,6 @@
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock';
import { import {
AlertBlockInfo, AlertBlockInfo,
Input as CInput, Input as CInput,
@@ -45,7 +46,7 @@ const AddModal: React.FC<AddModalProps> = ({
const intl = useIntl(); const intl = useIntl();
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [apikeyValue, setAPIKeyValue] = useState(''); const [apikeyValue, setAPIKeyValue] = useState('');
const [loading, setLoading] = useState(false); const { loading, guard, run, release } = useSubmitLock();
const [isChanged, setIsChanged] = useState(false); const [isChanged, setIsChanged] = useState(false);
const cacheFormRef = useRef<{ const cacheFormRef = useRef<{
allowed_type: string; allowed_type: string;
@@ -116,31 +117,31 @@ const AddModal: React.FC<AddModalProps> = ({
}; };
const handleOnOk = async (formdata: FormData) => { const handleOnOk = async (formdata: FormData) => {
try { await run(async () => {
setLoading(true); try {
const data = { const data = {
..._.omit(formdata, ['allowed_type']), ..._.omit(formdata, ['allowed_type']),
allowed_model_names: allowed_model_names:
formdata.allowed_type === 'all' || formdata.allowed_type === 'all' ||
!formdata.scope?.includes('inference') !formdata.scope?.includes('inference')
? [] ? []
: formdata.allowed_model_names || [] : formdata.allowed_model_names || []
}; };
if (action === PageAction.CREATE) { if (action === PageAction.CREATE) {
await createAPIKey(data); await createAPIKey(data);
} else if (action === PageAction.EDIT && currentData?.id) { } else if (action === PageAction.EDIT && currentData?.id) {
await updateAPIKey({ await updateAPIKey({
..._.omit(data, ['expires_in']) ..._.omit(data, ['expires_in'])
}); });
}
} catch (error) {
// handled in interceptor
} }
setLoading(false); });
} catch (error) {
setLoading(false);
}
}; };
const handleSumit = () => { const handleSumit = () => {
form.submit(); guard(() => form.submit());
}; };
const handleDone = () => { const handleDone = () => {
@@ -278,6 +279,7 @@ const AddModal: React.FC<AddModalProps> = ({
name="addAPIKey" name="addAPIKey"
form={form} form={form}
onFinish={handleOnOk} onFinish={handleOnOk}
onFinishFailed={release}
preserve={false} preserve={false}
initialValues={{ initialValues={{
allowed_type: 'all', allowed_type: 'all',
+7
View File
@@ -3,6 +3,13 @@ export interface ListItem {
description: string; description: string;
id: number; id: number;
value: string; 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; created_at: string;
updated_at: string; updated_at: string;
expires_at: string; expires_at: string;
+10 -1
View File
@@ -1,5 +1,6 @@
// columns.ts // columns.ts
import { tableSorter } from '@/config/settings'; import { tableSorter } from '@/config/settings';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { DashboardOutlined } from '@ant-design/icons'; import { DashboardOutlined } from '@ant-design/icons';
import { import {
AutoTooltip, AutoTooltip,
@@ -48,6 +49,7 @@ const useModelsColumns = ({
onConfigAction onConfigAction
}: ColumnsHookProps): ColumnsType<ListItem> => { }: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl(); const intl = useIntl();
const pluginCols = usePluginListColumns('apiKeys');
const actionList = useMemo<APIKeyAction[]>(() => { const actionList = useMemo<APIKeyAction[]>(() => {
// Built-ins use a step-of-10 priority scale so plugins have room // Built-ins use a step-of-10 priority scale so plugins have room
@@ -124,6 +126,12 @@ const useModelsColumns = ({
}, [intl, configActions, onConfigAction]); }, [intl, configActions, onConfigAction]);
return useMemo(() => { 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 [ return [
{ {
title: intl.formatMessage({ id: 'common.table.name' }), title: intl.formatMessage({ id: 'common.table.name' }),
@@ -152,6 +160,7 @@ const useModelsColumns = ({
</span> </span>
) )
}, },
...pluginRendered,
{ {
title: intl.formatMessage({ id: 'apikeys.table.key' }), title: intl.formatMessage({ id: 'apikeys.table.key' }),
dataIndex: 'masked_value', dataIndex: 'masked_value',
@@ -269,7 +278,7 @@ const useModelsColumns = ({
) )
} }
]; ];
}, [intl, showCreator, handleSelect, actionList]); }, [intl, showCreator, handleSelect, actionList, pluginCols]);
}; };
export default useModelsColumns; export default useModelsColumns;
+26 -18
View File
@@ -1,5 +1,6 @@
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock';
import { import {
AlertBlockInfo, AlertBlockInfo,
GSDrawer, GSDrawer,
@@ -63,6 +64,7 @@ const AddModal: React.FC<AddModalProps> = (props) => {
const [yamlContent, setYamlContent] = useState<string>(''); const [yamlContent, setYamlContent] = useState<string>('');
const [formContent, setFormContent] = useState<FormData>({} as FormData); const [formContent, setFormContent] = useState<FormData>({} as FormData);
const alertRef = useRef<HTMLDivElement>(null); const alertRef = useRef<HTMLDivElement>(null);
const { loading, guard, run, release } = useSubmitLock();
const showVersionCustomSuffix = const showVersionCustomSuffix =
currentData?.backend_source === BackendSourceValueMap.BUILTIN || currentData?.backend_source === BackendSourceValueMap.BUILTIN ||
@@ -92,17 +94,22 @@ const AddModal: React.FC<AddModalProps> = (props) => {
}; };
const onOk = () => { const onOk = () => {
if (activeKey === 'yaml') { guard(() => {
const content = editorRef.current?.getContent(); if (activeKey === 'yaml') {
if (content) { const content = editorRef.current?.getContent();
onSubmitYaml({ content: content }); if (!content) {
// nothing to submit — drop the lock so the button stays usable
release();
return;
}
run(() => onSubmitYaml({ content: content }));
return;
} }
} else {
formRef.current?.submit(); formRef.current?.submit();
} });
}; };
const onFinish = (values: FormData) => { const onFinish = async (values: FormData) => {
const versionConfigs = values.version_configs?.reduce( const versionConfigs = values.version_configs?.reduce(
(acc: Record<string, any>, curr) => { (acc: Record<string, any>, curr) => {
if (curr.version_no) { if (curr.version_no) {
@@ -117,16 +124,15 @@ const AddModal: React.FC<AddModalProps> = (props) => {
const defaultVersion = values.version_configs?.find((v) => v.is_default); const defaultVersion = values.version_configs?.find((v) => v.is_default);
onSubmit({ await run(() =>
...values, onSubmit({
parameter_format: ...values,
values.parameter_format === 'auto' || !values.parameter_format parameter_format: values.parameter_format || null,
? null default_version: defaultVersion?.version_no || '',
: values.parameter_format, // @ts-ignore
default_version: defaultVersion?.version_no || '', version_configs: versionConfigs
// @ts-ignore })
version_configs: versionConfigs );
});
}; };
useEffect(() => { useEffect(() => {
@@ -159,7 +165,7 @@ const AddModal: React.FC<AddModalProps> = (props) => {
return { return {
...values, ...values,
backend_name: values.backend_name.replace(/-custom$/, ''), backend_name: values.backend_name.replace(/-custom$/, ''),
parameter_format: values.parameter_format ?? 'auto', parameter_format: values.parameter_format,
version_configs: versionConfigs, version_configs: versionConfigs,
built_in_version_configs: builtInVersions built_in_version_configs: builtInVersions
}; };
@@ -279,6 +285,7 @@ const AddModal: React.FC<AddModalProps> = (props) => {
<ModalFooter <ModalFooter
onCancel={onClose} onCancel={onClose}
onOk={onOk} onOk={onOk}
loading={loading}
style={ModalFooterStyle} style={ModalFooterStyle}
></ModalFooter> ></ModalFooter>
</> </>
@@ -295,6 +302,7 @@ const AddModal: React.FC<AddModalProps> = (props) => {
children: ( children: (
<BackendForm <BackendForm
onFinish={onFinish} onFinish={onFinish}
onFinishFailed={release}
action={action} action={action}
currentData={formContent as ListItem} currentData={formContent as ListItem}
ref={formRef} ref={formRef}
+1 -1
View File
@@ -28,7 +28,7 @@ export interface FormData {
enabled?: boolean; enabled?: boolean;
backend_source?: string; backend_source?: string;
default_env?: Record<string, any>; default_env?: Record<string, any>;
parameter_format?: 'space' | 'equal' | 'auto' | null; parameter_format?: 'space' | 'equal' | null;
common_parameters?: string[]; common_parameters?: string[];
} }
+10 -10
View File
@@ -151,27 +151,24 @@ const BasicForm = () => {
</Form.Item> </Form.Item>
<Form.Item<FormData> <Form.Item<FormData>
name="parameter_format" name="parameter_format"
initialValue="auto"
rules={[{ required: false }]} rules={[{ required: false }]}
> >
<SealSelect <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={[ options={[
{ {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'backend.form.parameterFormat.default' id: 'backend.form.flagFormat.space'
}),
value: 'auto'
},
{
label: intl.formatMessage({
id: 'backend.form.parameterFormat.space'
}), }),
value: 'space' value: 'space'
}, },
{ {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'backend.form.parameterFormat.equal' id: 'backend.form.flagFormat.equal'
}), }),
value: 'equal' value: 'equal'
} }
@@ -183,6 +180,9 @@ const BasicForm = () => {
rules={[{ required: false }]} rules={[{ required: false }]}
> >
<ListInput <ListInput
description={intl.formatMessage({
id: 'backend.form.commonParameters.tips'
})}
btnText={intl.formatMessage({ id: 'backend.form.addParameter' })} btnText={intl.formatMessage({ id: 'backend.form.addParameter' })}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'backend.form.commonParameters' id: 'backend.form.commonParameters'
+5 -3
View File
@@ -13,10 +13,11 @@ type AddModalProps = {
action: PageActionType; action: PageActionType;
currentData?: ListItem; currentData?: ListItem;
onFinish: (values: FormData) => void; onFinish: (values: FormData) => void;
onFinishFailed?: (errorInfo: any) => void;
ref?: any; ref?: any;
}; };
const BackendForm: React.FC<AddModalProps> = forwardRef( const BackendForm: React.FC<AddModalProps> = forwardRef(
({ action, currentData, onFinish }, ref) => { ({ action, currentData, onFinish, onFinishFailed }, ref) => {
const intl = useIntl(); const intl = useIntl();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [activeKey, setActiveKey] = React.useState<string[]>([]); 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.BUILTIN ||
currentData?.backend_source === BackendSourceValueMap.COMMUNITY; currentData?.backend_source === BackendSourceValueMap.COMMUNITY;
const onFinishFailed = (errorInfo: any) => { const handleOnFinishFailed = (errorInfo: any) => {
const errorFields = errorInfo.errorFields || []; const errorFields = errorInfo.errorFields || [];
if (errorFields.length > 0) { if (errorFields.length > 0) {
const versionError = errorFields.find((field: any) => const versionError = errorFields.find((field: any) =>
@@ -38,6 +39,7 @@ const BackendForm: React.FC<AddModalProps> = forwardRef(
setActiveKey([]); setActiveKey([]);
} }
} }
onFinishFailed?.(errorInfo);
}; };
const handleOnFinish = (values: FormData) => { const handleOnFinish = (values: FormData) => {
@@ -106,7 +108,7 @@ const BackendForm: React.FC<AddModalProps> = forwardRef(
preserve={false} preserve={false}
scrollToFirstError={true} scrollToFirstError={true}
initialValues={_.omit(currentData, ['version_configs'])} initialValues={_.omit(currentData, ['version_configs'])}
onFinishFailed={onFinishFailed} onFinishFailed={handleOnFinishFailed}
> >
<BasicForm></BasicForm> <BasicForm></BasicForm>
<VersionsForm <VersionsForm
@@ -1,4 +1,5 @@
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock';
import { FormDrawer } from '@gpustack/core-ui'; import { FormDrawer } from '@gpustack/core-ui';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { FormData, BenchmarkListItem as ListItem } from '../config/types'; import { FormData, BenchmarkListItem as ListItem } from '../config/types';
@@ -22,21 +23,24 @@ const AddBenchmark: React.FC<AddModalProps> = ({
open, open,
currentData, currentData,
clusterList, clusterList,
profilesOptions, profilesOptions = [],
datasetList, datasetList = [],
onOk, onOk,
onCancel onCancel
}) => { }) => {
const form = useRef<any>(null); const form = useRef<any>(null);
const { loading, guard, run, release } = useSubmitLock();
const handleSubmit = () => { const handleSubmit = () => {
form.current?.submit(); guard(() => form.current?.submit());
}; };
const handleOk = async (data: FormData) => { const handleOk = async (data: FormData) => {
onOk({ await run(() =>
...data onOk({
}); ...data
})
);
}; };
const handleCancel = () => { const handleCancel = () => {
@@ -51,6 +55,7 @@ const AddBenchmark: React.FC<AddModalProps> = ({
onCancel={handleCancel} onCancel={handleCancel}
onSubmit={handleSubmit} onSubmit={handleSubmit}
width={600} width={600}
loading={loading}
> >
<BenchmarkForm <BenchmarkForm
ref={form} ref={form}
@@ -61,6 +66,7 @@ const AddBenchmark: React.FC<AddModalProps> = ({
profilesOptions={profilesOptions} profilesOptions={profilesOptions}
datasetList={datasetList} datasetList={datasetList}
onFinish={handleOk} onFinish={handleOk}
onFinishFailed={release}
/> />
</FormDrawer> </FormDrawer>
); );
+23 -31
View File
@@ -1,10 +1,10 @@
import { modelCategoriesMap } from '@/pages/llmodels/config';
import { SearchOutlined, SyncOutlined } from '@ant-design/icons'; 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 { useIntl } from '@umijs/max';
import { Button, Input, Space } from 'antd'; import { Button, Input, Space } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React from 'react'; import React from 'react';
import { profileOptions } from '../config';
export interface RightActionsProps { export interface RightActionsProps {
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void; handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
@@ -38,22 +38,15 @@ const RightActions: React.FC<RightActionsProps> = ({
const handleGPUChange = debounceUpdateFilter; const handleGPUChange = debounceUpdateFilter;
const modelOptions = modelList const handleSearchByModelDebounce = _.debounce((value: any) => {
?.filter((item) => { handleQueryChange({
return item.categories?.includes(modelCategoriesMap.llm); page: 1,
}) model_name: value
.map((item) => ({ });
label: item.label, }, 350);
value: item.label
}));
return ( return (
<Space> <Space>
<FiltersButton
onClick={toggleFilters}
count={count}
onClear={onClear}
></FiltersButton>
<Input <Input
prefix={ prefix={
<SearchOutlined <SearchOutlined
@@ -63,11 +56,24 @@ const RightActions: React.FC<RightActionsProps> = ({
placeholder={intl.formatMessage({ placeholder={intl.formatMessage({
id: 'common.filter.name' id: 'common.filter.name'
})} })}
style={{ width: 300 }} style={{ width: 200 }}
allowClear allowClear
onChange={handleInputChange} onChange={handleInputChange}
></Input> ></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={ prefix={
<SearchOutlined <SearchOutlined
style={{ color: 'var(--ant-color-text-placeholder)' }} style={{ color: 'var(--ant-color-text-placeholder)' }}
@@ -104,20 +110,6 @@ const RightActions: React.FC<RightActionsProps> = ({
}) })
} }
></BaseSelect> ></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 <Button
type="text" type="text"
style={{ color: 'var(--ant-color-text-tertiary)' }} style={{ color: 'var(--ant-color-text-tertiary)' }}
+3
View File
@@ -87,6 +87,9 @@ export interface FormData {
export interface BenchmarkListItem extends FormData { export interface BenchmarkListItem extends FormData {
id: number; 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; created_at: string;
updated_at: string; updated_at: string;
state: string; state: string;
+13 -1
View File
@@ -48,6 +48,15 @@ const BasicForm: React.FC = () => {
); );
}, [orgScoped, scopedClusterList, clusterList, scopeOrgId]); }, [orgScoped, scopedClusterList, clusterList, scopeOrgId]);
const handleOnOrgChange = (value: any) => {
form.setFieldsValue({
model_name: undefined,
model_id: undefined,
model_instance_name: undefined,
model_instance: undefined
});
};
useEffect(() => { useEffect(() => {
if (action === PageAction.CREATE && orgScoped) { if (action === PageAction.CREATE && orgScoped) {
fetchScopedClusters({ page: -1 }); fetchScopedClusters({ page: -1 });
@@ -108,7 +117,10 @@ const BasicForm: React.FC = () => {
required required
></CInput.Input> ></CInput.Input>
</Form.Item> </Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} /> <PluginExtraFields
name="CreateOrgScopeField"
context={{ action, onChange: handleOnOrgChange }}
/>
<Form.Item<FormData> <Form.Item<FormData>
name="cluster_id" name="cluster_id"
rules={[ rules={[
+3
View File
@@ -29,6 +29,7 @@ interface ProviderFormProps {
datasetList: Global.BaseOption<number | string>[]; datasetList: Global.BaseOption<number | string>[];
profilesOptions: Global.BaseOption<string>[]; profilesOptions: Global.BaseOption<string>[];
onFinish: (values: FormData) => Promise<void>; onFinish: (values: FormData) => Promise<void>;
onFinishFailed?: (errorInfo: any) => void;
} }
const TABKeysMap = { const TABKeysMap = {
@@ -42,6 +43,7 @@ const ProviderForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
action, action,
currentData, currentData,
onFinish, onFinish,
onFinishFailed,
open, open,
clusterList, clusterList,
profilesOptions, profilesOptions,
@@ -122,6 +124,7 @@ const ProviderForm: React.FC<ProviderFormProps> = forwardRef((props, ref) => {
<Form <Form
form={form} form={form}
onFinish={onFinish} onFinish={onFinish}
onFinishFailed={onFinishFailed}
initialValues={{ initialValues={{
dataset_input_tokens: null, dataset_input_tokens: null,
dataset_output_tokens: null, dataset_output_tokens: null,
+27 -26
View File
@@ -9,8 +9,9 @@ import { useQueryModelInstancesList } from '@/pages/llmodels/services/use-query-
import { useQueryModelList } from '@/pages/llmodels/services/use-query-model-list'; import { useQueryModelList } from '@/pages/llmodels/services/use-query-model-list';
import { Cascader as SealCascader, useAppUtils } from '@gpustack/core-ui'; import { Cascader as SealCascader, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { Form, Tooltip } from 'antd'; import { Form, Tooltip } from 'antd';
import React, { useEffect, useRef } from 'react'; import React, { useEffect } from 'react';
import { useFormContext } from '../config/form-context'; import { useFormContext } from '../config/form-context';
import { FormData } from '../config/types'; import { FormData } from '../config/types';
@@ -45,11 +46,7 @@ const ModelInstanceForm: React.FC = () => {
const form = Form.useFormInstance(); const form = Form.useFormInstance();
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const { action, open } = useFormContext(); const { action, open } = useFormContext();
// Owned by the create-scope picker slot (admin "All" view). The model list const clusterId = Form.useWatch('cluster_id', form);
// is tenant-scoped by the request header, so refetch it when the org
// changes so only the chosen org's models/instances are offered.
const scopeOrgId = Form.useWatch('organization_id', form);
const prevScopeRef = useRef<number | null | undefined>(undefined);
const [modelList, setModelList] = React.useState<any[]>([]); const [modelList, setModelList] = React.useState<any[]>([]);
const { const {
loading: modelLoading, 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 loadInstances = async (selectedOptions: any[]) => {
const targetOption = selectedOptions[selectedOptions.length - 1]; const targetOption = selectedOptions[selectedOptions.length - 1];
if (targetOption && targetOption.children.length === 0) { if (targetOption && targetOption.children.length === 0) {
@@ -107,10 +114,12 @@ const ModelInstanceForm: React.FC = () => {
}); });
} }
}; };
const initModelInstance = useMemoizedFn(async () => {
const initModelInstance = async () => { if (!clusterId) {
return;
}
// fetch model list when dropdown is opened // 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 const modelOptions = list
.filter((model: any) => model.replicas > 0) .filter((model: any) => model.replicas > 0)
.map((model: any) => ({ .map((model: any) => ({
@@ -124,6 +133,7 @@ const ModelInstanceForm: React.FC = () => {
})); }));
if (modelOptions.length === 0) { if (modelOptions.length === 0) {
clearModelInstance();
return; return;
} }
@@ -133,6 +143,12 @@ const ModelInstanceForm: React.FC = () => {
); );
if (!selectedllmModel) { if (!selectedllmModel) {
setModelList(modelOptions); setModelList(modelOptions);
form.setFieldsValue({
model_name: '',
model_id: '',
model_instance_name: '',
model_instance: ''
});
return; return;
} }
const instanceList = await fetchInstanceList({ id: selectedllmModel.id }); const instanceList = await fetchInstanceList({ id: selectedllmModel.id });
@@ -159,33 +175,18 @@ const ModelInstanceForm: React.FC = () => {
} }
setModelList(modelOptions); setModelList(modelOptions);
}; });
useEffect(() => { useEffect(() => {
if (open && action === PageAction.CREATE) { 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(); initModelInstance();
} }
if (!open) { if (!open) {
prevScopeRef.current = undefined;
cancelModelRequest(); cancelModelRequest();
cancelInstanceRequest(); cancelInstanceRequest();
clearBenchmarkTargetInstance(); clearBenchmarkTargetInstance();
} }
}, [open, benchmarkTargetInstance, action, scopeOrgId]); }, [open, action, clusterId]);
return ( return (
<Form.Item<FormData> <Form.Item<FormData>
@@ -1,5 +1,6 @@
// columns.ts // columns.ts
import { tableSorter } from '@/config/settings'; import { tableSorter } from '@/config/settings';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip } from '@gpustack/core-ui'; import { AutoTooltip } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Typography } from 'antd'; import { Typography } from 'antd';
@@ -15,8 +16,15 @@ const useBenchmarkColumns = (params: {
}): ColumnsType<ListItem> => { }): ColumnsType<ListItem> => {
const intl = useIntl(); const intl = useIntl();
const { onCellClick, handleSelect, columns } = params; const { onCellClick, handleSelect, columns } = params;
const pluginCols = usePluginListColumns('benchmarks');
return useMemo(() => { 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 [ return [
{ {
title: ( title: (
@@ -34,6 +42,7 @@ const useBenchmarkColumns = (params: {
</AutoTooltip> </AutoTooltip>
) )
}, },
...pluginRendered,
...columns, ...columns,
{ {
title: ( title: (
@@ -51,7 +60,7 @@ const useBenchmarkColumns = (params: {
) )
} }
]; ];
}, [intl, onCellClick, handleSelect, columns]); }, [intl, onCellClick, handleSelect, columns, pluginCols]);
}; };
export default useBenchmarkColumns; export default useBenchmarkColumns;
+80
View File
@@ -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 { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock';
import { ExclamationCircleFilled } from '@ant-design/icons'; import { ExclamationCircleFilled } from '@ant-design/icons';
import { AlertBlockInfo, FormDrawer, ModalFooter } from '@gpustack/core-ui'; import { AlertBlockInfo, FormDrawer, ModalFooter } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
@@ -39,20 +40,23 @@ const AddCluster: React.FC<AddModalProps> = ({
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const form = useRef<any>(null); const form = useRef<any>(null);
const { loading, guard, run, release } = useSubmitLock();
// Whether the user has changed any k8s_options field. Lifted from ClusterForm // 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 // so the "re-run registration" notice can sit in the drawer footer, above the
// Save/Cancel buttons (mirrors the model edit interaction). // Save/Cancel buttons (mirrors the model edit interaction).
const [k8sOptionsChanged, setK8sOptionsChanged] = useState<boolean>(false); const [k8sOptionsChanged, setK8sOptionsChanged] = useState<boolean>(false);
const handleSubmit = () => { const handleSubmit = () => {
form.current?.submit(); guard(() => form.current?.submit());
}; };
const handleOk = async (data: FormData) => { const handleOk = async (data: FormData) => {
onOk({ await run(() =>
...data, onOk({
provider ...data,
}); provider
})
);
}; };
const handleCancel = () => { const handleCancel = () => {
@@ -84,6 +88,7 @@ const AddCluster: React.FC<AddModalProps> = ({
<ModalFooter <ModalFooter
onOk={handleSubmit} onOk={handleSubmit}
onCancel={handleCancel} onCancel={handleCancel}
loading={loading}
style={ModalFooterStyle} style={ModalFooterStyle}
></ModalFooter> ></ModalFooter>
</> </>
@@ -96,6 +101,7 @@ const AddCluster: React.FC<AddModalProps> = ({
action={action} action={action}
currentData={currentData} currentData={currentData}
onFinish={handleOk} onFinish={handleOk}
onFinishFailed={release}
onK8sOptionsChange={setK8sOptionsChanged} onK8sOptionsChange={setK8sOptionsChanged}
/> />
</FormDrawer> </FormDrawer>
@@ -10,6 +10,7 @@ type ViewModalProps = {
containerName?: string; containerName?: string;
gpustackDataVolume?: string; gpustackDataVolume?: string;
advertisAddress?: string; advertisAddress?: string;
dtkVersion?: string;
registrationInfo: { registrationInfo: {
token: string; token: string;
image: string; image: string;
@@ -26,7 +27,8 @@ const AddWorkerCommand: React.FC<ViewModalProps> = ({
cacheDir, cacheDir,
currentGPU, currentGPU,
containerName, containerName,
gpustackDataVolume gpustackDataVolume,
dtkVersion
}) => { }) => {
const code = React.useMemo(() => { const code = React.useMemo(() => {
const commandCode = addWorkerGuide['all']; const commandCode = addWorkerGuide['all'];
@@ -42,6 +44,7 @@ const AddWorkerCommand: React.FC<ViewModalProps> = ({
cacheDir: cacheDir, cacheDir: cacheDir,
containerName: containerName, containerName: containerName,
gpustackDataVolume: gpustackDataVolume, gpustackDataVolume: gpustackDataVolume,
dtkVersion: dtkVersion,
image: registrationInfo.image, image: registrationInfo.image,
token: registrationInfo.token || '${token}' token: registrationInfo.token || '${token}'
}) })
@@ -56,7 +59,8 @@ const AddWorkerCommand: React.FC<ViewModalProps> = ({
cacheDir, cacheDir,
containerName, containerName,
gpustackDataVolume, gpustackDataVolume,
advertisAddress advertisAddress,
dtkVersion
]); ]);
return ( return (
@@ -67,7 +67,6 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
); );
const { update, summary, register } = useSummaryStatus(); const { update, summary, register } = useSummaryStatus();
const { addedCount, createModelsChunkRequest } = useAddWorkerMessage(); const { addedCount, createModelsChunkRequest } = useAddWorkerMessage();
console.log('addedCount=========', addedCount);
const onToggle = (open: boolean, key: string) => { const onToggle = (open: boolean, key: string) => {
setCollapseKey(open ? new Set([key]) : new Set()); setCollapseKey(open ? new Set([key]) : new Set());
@@ -82,7 +81,6 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
setCollapseKey(new Set([stepList[0]])); setCollapseKey(new Set([stepList[0]]));
}, [stepList]); }, [stepList]);
console.log('actionSource=========', actionSource, registrationInfo);
React.useEffect(() => { React.useEffect(() => {
// this effect is only triggered when used in cluster create page inner // this effect is only triggered when used in cluster create page inner
if (actionSource === 'page' && registrationInfo?.cluster_id) { 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 // Downstream steps (check env, run command, ...) only make sense after a
// GPU vendor has been chosen. If the user toggled off every vendor in // 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. // 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 = const selectedGPUs =
(summary.get('selectedGPUs') as string[] | undefined) || []; (summary.get('selectedGPUs') as string[] | undefined) || [];
const currentGPU = (summary.get('currentGPU') as string | undefined) || ''; const currentGPU = (summary.get('currentGPU') as string | undefined) || '';
const noVendorSelected = !currentGPU && selectedGPUs.length === 0; 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(() => { React.useEffect(() => {
// If the user just deselected everything, collapse any downstream // If the user just deselected everything on a non-K8s provider, collapse
// panel back to the GPU step so they aren't left looking at a stale // any downstream panel back to the GPU step so they aren't left looking at
// disabled-but-open command. Functional updater so we don't have to // a stale disabled-but-open command. For K8s, CheckEnv stays open with a
// depend on `collapseKey` and re-run the effect on every toggle. // ready-nodes check, so no collapsing needed.
if (!noVendorSelected) return; if (!noVendorSelected || isK8s) return;
setCollapseKey((prev) => setCollapseKey((prev) =>
prev.has(StepNamesMap.SelectGPU) prev.has(StepNamesMap.SelectGPU)
? prev ? prev
: new Set([StepNamesMap.SelectGPU]) : new Set([StepNamesMap.SelectGPU])
); );
}, [noVendorSelected]); }, [noVendorSelected, isK8s]);
return ( return (
<AddWorkerContext.Provider <AddWorkerContext.Provider
@@ -147,7 +151,9 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
!stepList.includes(StepNamesMap.SelectCluster)) && ( !stepList.includes(StepNamesMap.SelectCluster)) && (
<> <>
<SelectVendor disabled={disabled}></SelectVendor> <SelectVendor disabled={disabled}></SelectVendor>
<CheckEnvironment disabled={downstreamDisabled}></CheckEnvironment> {(isK8s || !noVendorSelected) && (
<CheckEnvironment disabled={disabled}></CheckEnvironment>
)}
{provider === ProviderValueMap.Kubernetes && ( {provider === ProviderValueMap.Kubernetes && (
<K8sRunCommand disabled={downstreamDisabled}></K8sRunCommand> <K8sRunCommand disabled={downstreamDisabled}></K8sRunCommand>
@@ -1,6 +1,7 @@
import { BulbOutlined } from '@ant-design/icons'; import { BulbOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Alert, Typography } from 'antd'; import { Alert, Typography } from 'antd';
import { ProviderValueMap } from '../../config';
import CheckEnvCommand from '../check-env-command'; import CheckEnvCommand from '../check-env-command';
import { useAddWorkerContext } from './add-worker-context'; import { useAddWorkerContext } from './add-worker-context';
import { AddWorkerStepProps, StepNamesMap } from './config'; import { AddWorkerStepProps, StepNamesMap } from './config';
@@ -10,8 +11,8 @@ import StepCollapse from './step-collapse';
const CheckEnvironment: React.FC<AddWorkerStepProps> = ({ disabled }) => { const CheckEnvironment: React.FC<AddWorkerStepProps> = ({ disabled }) => {
const { stepList, summary, provider } = useAddWorkerContext(); const { stepList, summary, provider } = useAddWorkerContext();
const intl = useIntl(); const intl = useIntl();
const currentGPU = summary.get('currentGPU'); const currentGPU = summary.get('currentGPU') as string;
const currentGPUs: string[] = summary.get('selectedGPUs') || []; const currentGPUs: string[] = (summary.get('selectedGPUs') as string[]) || [];
const workerCommand = summary.get('workerCommand') || { const workerCommand = summary.get('workerCommand') || {
label: '', label: '',
link: '', link: '',
@@ -19,6 +20,8 @@ const CheckEnvironment: React.FC<AddWorkerStepProps> = ({ disabled }) => {
}; };
const stepIndex = stepList.indexOf(StepNamesMap.CheckEnv) + 1; const stepIndex = stepList.indexOf(StepNamesMap.CheckEnv) + 1;
const isK8s = provider === ProviderValueMap.Kubernetes;
const isCPUOnly = isK8s && !currentGPU && currentGPUs.length === 0;
return ( return (
<StepCollapse <StepCollapse
@@ -31,27 +34,37 @@ const CheckEnvironment: React.FC<AddWorkerStepProps> = ({ disabled }) => {
</Title> </Title>
} }
> >
<Alert {isCPUOnly ? (
type="info" <Typography.Paragraph style={{ marginBottom: 8 }}>
showIcon {intl.formatMessage({
icon={<BulbOutlined />} id: 'clusters.addworker.checkEnv.cpuOnlyTips'
style={{ })}
marginBottom: 8 </Typography.Paragraph>
}} ) : (
title={ <>
<span <Alert
dangerouslySetInnerHTML={{ type="info"
__html: intl.formatMessage( showIcon
{ id: 'clusters.create.addworker.tips' }, icon={<BulbOutlined />}
{ label: workerCommand.label, link: workerCommand.link } style={{
) marginBottom: 8
}} }}
></span> title={
} <span
></Alert> dangerouslySetInnerHTML={{
<Typography.Paragraph style={{ marginBottom: 8 }}> __html: intl.formatMessage(
{intl.formatMessage({ id: 'cluster.create.checkEnv.tips' })} { id: 'clusters.create.addworker.tips' },
</Typography.Paragraph> { label: workerCommand.label, link: workerCommand.link }
)
}}
></span>
}
></Alert>
<Typography.Paragraph style={{ marginBottom: 8 }}>
{intl.formatMessage({ id: 'cluster.create.checkEnv.tips' })}
</Typography.Paragraph>
</>
)}
<CheckEnvCommand <CheckEnvCommand
provider={provider} provider={provider}
currentGPU={currentGPU} currentGPU={currentGPU}
@@ -36,6 +36,7 @@ export interface SummaryDataKeys {
selectedGPUs: string[]; selectedGPUs: string[];
cluster_id: number; cluster_id: number;
clusterName: string; clusterName: string;
dtkVersion: string;
workerCommand: { workerCommand: {
label: string; label: string;
link: string; link: string;
@@ -41,6 +41,8 @@ const DockerRunCommand: React.FC<AddWorkerStepProps> = ({ disabled }) => {
const currentGPU = summary.get('currentGPU') || ''; const currentGPU = summary.get('currentGPU') || '';
const dtkVersion = summary.get('dtkVersion') || '25.04';
const stepIndex = stepList.indexOf(StepNamesMap.RunCommand) + 1; const stepIndex = stepList.indexOf(StepNamesMap.RunCommand) + 1;
return ( return (
@@ -80,6 +82,7 @@ const DockerRunCommand: React.FC<AddWorkerStepProps> = ({ disabled }) => {
gpustackDataVolumeConfig.enable ? gpustackDataVolumeConfig.path : '' gpustackDataVolumeConfig.enable ? gpustackDataVolumeConfig.path : ''
} }
currentGPU={currentGPU} currentGPU={currentGPU}
dtkVersion={dtkVersion}
/> />
</StepCollapse> </StepCollapse>
); );
@@ -4,7 +4,6 @@ import {
GPUsConfigs GPUsConfigs
} from '@/pages/resources/config/gpu-driver'; } from '@/pages/resources/config/gpu-driver';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Tag } from 'antd';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { ProviderValueMap } from '../../config'; import { ProviderValueMap } from '../../config';
import SupportedGPUs from '../support-gpus'; import SupportedGPUs from '../support-gpus';
@@ -94,7 +93,8 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
}; };
useEffect(() => { 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, { handleSelect(GPUDriverMap.NVIDIA, {
label: 'NVIDIA', label: 'NVIDIA',
hiddenTitle: true, hiddenTitle: true,
@@ -116,18 +116,18 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
{stepIndex}.{' '} {stepIndex}.{' '}
{intl.formatMessage({ id: 'clusters.addworker.selectGPU' })} {intl.formatMessage({ id: 'clusters.addworker.selectGPU' })}
{multiCapable && ( {multiCapable && (
<Tag <span
color="blue"
style={{ style={{
marginLeft: 8, marginLeft: 8,
fontWeight: 400, fontWeight: 400,
borderRadius: 4 fontSize: 13,
color: 'var(--ant-color-text-secondary)'
}} }}
> >
{intl.formatMessage({ {intl.formatMessage({
id: 'clusters.addworker.selectGPU.multiTag' id: 'clusters.addworker.selectGPU.subtitle'
})} })}
</Tag> </span>
)} )}
</Title> </Title>
} }
@@ -1,7 +1,11 @@
import { GPUDriverMap } from '@/pages/resources/config/gpu-driver';
import { ExclamationCircleFilled } from '@ant-design/icons'; 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 { useIntl } from '@umijs/max';
import { Input, Switch, Typography } from 'antd'; import { Input, Radio, Switch, Typography } from 'antd';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useAddWorkerContext } from './add-worker-context'; import { useAddWorkerContext } from './add-worker-context';
@@ -111,6 +115,14 @@ const SpecifyArguments: React.FC<AddWorkerStepProps> = ({ disabled }) => {
path: 'gpustack-data' 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: { const setWorkerIPConfig = (config: {
enable: boolean; enable: boolean;
ip?: string; ip?: string;
@@ -166,12 +178,14 @@ const SpecifyArguments: React.FC<AddWorkerStepProps> = ({ disabled }) => {
const unregisterGpustackDataVolume = registerField( const unregisterGpustackDataVolume = registerField(
'gpustackDataVolumeConfig' 'gpustackDataVolumeConfig'
); );
const unregisterVersion = registerField('dtkVersion');
return () => { return () => {
unregisterWorkerIP(); unregisterWorkerIP();
unregisterModelDir(); unregisterModelDir();
unregisterCacheDir(); unregisterCacheDir();
unregisterContainerName(); unregisterContainerName();
unregisterGpustackDataVolume(); unregisterGpustackDataVolume();
unregisterVersion();
}; };
}, []); }, []);
@@ -207,6 +221,7 @@ const SpecifyArguments: React.FC<AddWorkerStepProps> = ({ disabled }) => {
ip: '', ip: '',
required: false required: false
}); });
updateField('dtkVersion', '25.04');
}, []); }, []);
return ( return (
@@ -229,6 +244,30 @@ const SpecifyArguments: React.FC<AddWorkerStepProps> = ({ disabled }) => {
marginBottom: 8 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 */} {/* worker IP config */}
<SwitchSetting <SwitchSetting
label={ label={
@@ -1,7 +1,12 @@
import { addWorkerGuide } from '@/pages/resources/config'; import { addWorkerGuide } from '@/pages/resources/config';
import { HighlightCode } from '@gpustack/core-ui'; import { HighlightCode } from '@gpustack/core-ui';
import React from 'react'; 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 = { type ViewModalProps = {
provider: ProviderType; provider: ProviderType;
@@ -25,6 +30,12 @@ const AddWorkerCommand: React.FC<ViewModalProps> = ({
: currentGPU : currentGPU
? [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 ''; if (!keys.length) return '';
const lines = keys const lines = keys
.map((k) => configs.checkEnvCommand(k)?.[provider || '']) .map((k) => configs.checkEnvCommand(k)?.[provider || ''])
@@ -36,6 +36,7 @@ type AddModalProps = {
provider: ProviderType; provider: ProviderType;
credentialList: Global.BaseOption<number>[]; credentialList: Global.BaseOption<number>[];
onFinish: (values: FormData) => void; onFinish: (values: FormData) => void;
onFinishFailed?: (errorInfo: any) => void;
// Reports whether the user has changed any k8s_options field, so the parent // Reports whether the user has changed any k8s_options field, so the parent
// can show the "re-run registration" notice in the footer. // can show the "re-run registration" notice in the footer.
onK8sOptionsChange?: (changed: boolean) => void; onK8sOptionsChange?: (changed: boolean) => void;
@@ -49,6 +50,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
currentData, currentData,
credentialList, credentialList,
onFinish, onFinish,
onFinishFailed,
onK8sOptionsChange onK8sOptionsChange
}, },
ref ref
@@ -212,8 +214,9 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
} }
})); }));
const handleOnFinishFailed = () => { const handleOnFinishFailed = (errorInfo: any) => {
setSubmitAttempted(true); setSubmitAttempted(true);
onFinishFailed?.(errorInfo);
}; };
return ( return (
@@ -260,10 +263,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
<Form.Item<FormData> <Form.Item<FormData>
name="description" name="description"
rules={[{ required: false }]} 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={{ style={{
marginBottom: marginBottom:
provider === ProviderValueMap.Kubernetes ? undefined : 8 provider === ProviderValueMap.Kubernetes ? undefined : 8
@@ -4,7 +4,7 @@ import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form } from 'antd'; import { Form } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useEffect, useId } from 'react'; import React, { useEffect, useId, useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { ClusterListItem as ListItem } from '../config/types'; import { ClusterListItem as ListItem } from '../config/types';
import ImageCredential from './image-credential'; import ImageCredential from './image-credential';
@@ -56,7 +56,6 @@ export const OperatorImageForm: React.FC = () => {
<SectionWrap> <SectionWrap>
<Form.Item <Form.Item
name={['k8s_options', 'operatorImage']} name={['k8s_options', 'operatorImage']}
style={{ marginBottom: 0 }}
normalize={(value) => value || null} normalize={(value) => value || null}
> >
<CInput.Input <CInput.Input
@@ -289,10 +288,9 @@ export const GpuInstancesStaticAddressForm: React.FC = () => {
); );
}; };
// Strip UI-only / undefined-valued noise so two k8s_options snapshots compare // Strip UI-only noise so two k8s_options snapshots compare on real content.
// on real content. `sourceType` is derived from `volumeSource` purely for the // `sourceType` is derived from `volumeSource` purely for the volume-mount UI
// volume-mount UI (see cluster-form init), and the JSON round-trip drops // (see cluster-form init).
// undefined-valued keys so a missing key and `key: undefined` compare equal.
const cleanK8sOptions = (opts: any) => { const cleanK8sOptions = (opts: any) => {
const cloned = _.cloneDeep(opts || {}); const cloned = _.cloneDeep(opts || {});
if (Array.isArray(cloned.volumeMounts)) { if (Array.isArray(cloned.volumeMounts)) {
@@ -300,24 +298,33 @@ const cleanK8sOptions = (opts: any) => {
({ sourceType, ...rest }: any) => rest ({ sourceType, ...rest }: any) => rest
); );
} }
// The edit form always seeds k8s_options.volumeMounts to [] even when the return _.pickBy(cloned, (value: any) => !_.isNil(value));
// 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 // Custom comparator for isEqualWith: treats null, undefined, empty strings,
// non-empty -> empty edit is still detected, since only the empty side drops. // empty arrays, and empty objects as equivalent. Form normalize converts empty
Object.keys(cloned).forEach((key) => { // inputs to null while the API may omit absent keys (read as undefined when
if (Array.isArray(cloned[key]) && cloned[key].length === 0) { // accessed on the object). This lets "user clears field back to original"
delete cloned[key]; // compare as unchanged without needing to recursively strip nullish values.
} const nullishCustomizer = (val1: any, val2: any) => {
}); if (
return JSON.parse(JSON.stringify(cloned)); (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 // 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 // changed any k8s_options field or the top-level system_default_container_registry
// nothing — the notice itself is shown in the form footer, above Save/Cancel // from the cluster's saved values. It renders nothing — the notice itself is shown
// (see cluster-create.tsx), mirroring the model edit interaction. Must be // in the form footer, above Save/Cancel (see cluster-create.tsx), mirroring the
// mounted inside the cluster <Form> so the watch reads the form store. // model edit interaction. Must be mounted inside the cluster <Form> so the watch
// reads the form store.
export const K8sOptionsChangeWatcher: React.FC<{ export const K8sOptionsChangeWatcher: React.FC<{
action: PageActionType; action: PageActionType;
currentData?: ListItem; currentData?: ListItem;
@@ -327,13 +334,31 @@ export const K8sOptionsChangeWatcher: React.FC<{
// gpuInstanceOptions which is toggled via setFieldValue without a mounted // gpuInstanceOptions which is toggled via setFieldValue without a mounted
// Form.Item (mirrors ClusterTypeSelector). // Form.Item (mirrors ClusterTypeSelector).
const k8sOptions = Form.useWatch(['k8s_options'], { preserve: true }); 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 = const changed =
action === PageAction.EDIT && action === PageAction.EDIT && (k8sOptionsChanged || registryChanged);
!_.isEqual(
cleanK8sOptions(currentData?.k8s_options),
cleanK8sOptions(k8sOptions)
);
useEffect(() => { useEffect(() => {
onChange(changed); onChange(changed);
@@ -1,3 +1,4 @@
import { CheckOutlined } from '@ant-design/icons';
import { IconFont, TemplateCard } from '@gpustack/core-ui'; import { IconFont, TemplateCard } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
@@ -62,6 +63,29 @@ const Header = styled.div`
const CardBox = styled.div` const CardBox = styled.div`
display: flex; display: flex;
overflow: hidden; 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 { interface ProviderCatalogProps {
@@ -160,6 +184,13 @@ const ProviderCatalog: React.FC<ProviderCatalogProps> = ({
header={renderTitle(action)} header={renderTitle(action)}
icon={action.icon} icon={action.icon}
></TemplateCard> ></TemplateCard>
{(Array.isArray(current)
? current.includes(action.key)
: current === action.key) && (
<ActiveBadge>
<CheckOutlined />
</ActiveBadge>
)}
</CardBox> </CardBox>
</Tooltip> </Tooltip>
))} ))}
@@ -24,6 +24,7 @@ const Box = styled.div`
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
&.active { &.active {
background-color: var(--ant-color-fill-tertiary); background-color: var(--ant-color-fill-tertiary);
border-color: var(--ant-color-primary);
} }
.template-card-icon { .template-card-icon {
margin-right: 0; margin-right: 0;
@@ -57,6 +58,7 @@ const Box = styled.div`
} }
&.active { &.active {
background-color: var(--ant-color-bg-solid); background-color: var(--ant-color-bg-solid);
border-color: var(--ant-color-primary);
} }
} }
} }
@@ -18,6 +18,7 @@ export interface CredentialListItem {
access_key: string; access_key: string;
secret_key: string; secret_key: string;
description?: string; description?: string;
owner_principal_id?: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -2,6 +2,7 @@
import { systemConfigAtom } from '@/atoms/system'; import { systemConfigAtom } from '@/atoms/system';
import { tableSorter } from '@/config/settings'; import { tableSorter } from '@/config/settings';
import { getGPUStackPlugin } from '@/plugins'; import { getGPUStackPlugin } from '@/plugins';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { StarFilled } from '@ant-design/icons'; import { StarFilled } from '@ant-design/icons';
import { import {
AutoTooltip, AutoTooltip,
@@ -80,6 +81,7 @@ const useClusterColumns = (
): SealColumnProps[] => { ): SealColumnProps[] => {
const intl = useIntl(); const intl = useIntl();
const systemConfig = useAtomValue(systemConfigAtom); const systemConfig = useAtomValue(systemConfigAtom);
const pluginCols = usePluginListColumns('clusters');
// The cluster-detail page is shipped in OSS source, but OSS keeps // The cluster-detail page is shipped in OSS source, but OSS keeps
// it unreachable from the cluster list — the link is only // it unreachable from the cluster list — the link is only
// surfaced when a plugin opts in via // surfaced when a plugin opts in via
@@ -102,6 +104,40 @@ const useClusterColumns = (
}; };
return useMemo(() => { 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 [ return [
{ {
title: intl.formatMessage({ id: 'common.table.name' }), title: intl.formatMessage({ id: 'common.table.name' }),
@@ -133,11 +169,12 @@ const useClusterColumns = (
</> </>
) )
}, },
...pluginRendered,
{ {
title: intl.formatMessage({ id: 'clusters.table.provider' }), title: intl.formatMessage({ id: 'clusters.table.provider' }),
dataIndex: 'provider', dataIndex: 'provider',
sorter: tableSorter(2), sorter: tableSorter(2),
span: 3, span: spans.provider,
render: (value: string) => ( render: (value: string) => (
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{ProviderLabelMap[value]} {ProviderLabelMap[value]}
@@ -145,7 +182,7 @@ const useClusterColumns = (
) )
}, },
{ {
title: 'GPUs', title: intl.formatMessage({ id: 'menu.resources.gpus' }),
dataIndex: 'gpus', dataIndex: 'gpus',
span: 2, span: 2,
sorter: tableSorter(3), sorter: tableSorter(3),
@@ -155,14 +192,14 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'clusters.table.deployments' }), title: intl.formatMessage({ id: 'clusters.table.deployments' }),
dataIndex: 'models', dataIndex: 'models',
sorter: tableSorter(4), sorter: tableSorter(4),
span: 3, span: spans.deployments,
render: (value: number) => <span>{value}</span> render: (value: number) => <span>{value}</span>
}, },
{ {
title: intl.formatMessage({ id: 'resources.nodes' }), title: intl.formatMessage({ id: 'resources.nodes' }),
dataIndex: 'workers', dataIndex: 'workers',
sorter: tableSorter(5), sorter: tableSorter(5),
span: 3, span: spans.workers,
render: (value: number, record: ClusterListItem) => ( render: (value: number, record: ClusterListItem) => (
<span> <span>
{record.ready_workers} / {record.workers} {record.ready_workers} / {record.workers}
@@ -172,7 +209,7 @@ const useClusterColumns = (
{ {
title: intl.formatMessage({ id: 'common.table.status' }), title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: 'state', dataIndex: 'state',
span: 3, span: spans.status,
render: (value: number, record: ClusterListItem) => ( render: (value: number, record: ClusterListItem) => (
<StatusTag <StatusTag
statusValue={{ statusValue={{
@@ -187,7 +224,7 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'common.table.createTime' }), title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at', dataIndex: 'created_at',
sorter: tableSorter(6), sorter: tableSorter(6),
span: 4, span: spans.createTime,
render: (value: string) => ( render: (value: string) => (
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{dayjs(value).format('YYYY-MM-DD HH:mm:ss')} {dayjs(value).format('YYYY-MM-DD HH:mm:ss')}
@@ -206,7 +243,7 @@ const useClusterColumns = (
) )
} }
]; ];
}, [handleSelect, onCellClick]); }, [handleSelect, onCellClick, intl, pluginCols]);
}; };
export default useClusterColumns; export default useClusterColumns;
@@ -1,5 +1,6 @@
// columns.ts // columns.ts
import { tableSorter } from '@/config/settings'; import { tableSorter } from '@/config/settings';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui'; import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { ColumnsType } from 'antd/es/table'; import { ColumnsType } from 'antd/es/table';
@@ -12,8 +13,15 @@ const useCredentialColumns = (
handleSelect: (val: string, record: ListItem) => void handleSelect: (val: string, record: ListItem) => void
): ColumnsType<ListItem> => { ): ColumnsType<ListItem> => {
const intl = useIntl(); const intl = useIntl();
const pluginCols = usePluginListColumns('cloudCredentials');
return useMemo(() => { 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 [ return [
{ {
title: intl.formatMessage({ id: 'common.table.name' }), title: intl.formatMessage({ id: 'common.table.name' }),
@@ -25,6 +33,7 @@ const useCredentialColumns = (
</AutoTooltip> </AutoTooltip>
) )
}, },
...pluginRendered,
{ {
title: intl.formatMessage({ id: 'clusters.table.provider' }), title: intl.formatMessage({ id: 'clusters.table.provider' }),
dataIndex: 'provider', dataIndex: 'provider',
@@ -68,7 +77,7 @@ const useCredentialColumns = (
) )
} }
]; ];
}, [intl, handleSelect]); }, [intl, handleSelect, pluginCols]);
}; };
export default useCredentialColumns; export default useCredentialColumns;
@@ -1,8 +1,7 @@
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import useUserSettings from '@/hooks/use-user-settings'; import useUserSettings from '@/hooks/use-user-settings';
import { ExclamationCircleFilled } from '@ant-design/icons'; import { Input as CInput, IconFont } from '@gpustack/core-ui';
import { AlertBlockInfo, Input as CInput, IconFont } from '@gpustack/core-ui';
import { YamlEditor } from '@gpustack/core-ui/yaml-editor'; import { YamlEditor } from '@gpustack/core-ui/yaml-editor';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button, Form } from 'antd'; import { Button, Form } from 'antd';
@@ -98,7 +97,6 @@ const ClusterAdvanceConfig: React.FC<{
this column). */} this column). */}
<Form.Item<FormData> <Form.Item<FormData>
name="system_default_container_registry" name="system_default_container_registry"
style={{ marginBottom: 16 }}
normalize={(value) => value?.trim?.() || null} normalize={(value) => value?.trim?.() || null}
> >
<CInput.Input <CInput.Input
@@ -120,14 +118,6 @@ const ClusterAdvanceConfig: React.FC<{
<Title> <Title>
{intl.formatMessage({ id: 'clusters.create.workerConfig' })} {intl.formatMessage({ id: 'clusters.create.workerConfig' })}
</Title> </Title>
{action === PageAction.EDIT && (
<AlertBlockInfo
type="warning"
style={{ marginBottom: 8 }}
icon={<ExclamationCircleFilled />}
message={intl.formatMessage({ id: 'clusters.edit.workerConfig.tip' })}
></AlertBlockInfo>
)}
<YamlEditor <YamlEditor
ref={editorRef} ref={editorRef}
isDarkTheme={isDarkTheme} isDarkTheme={isDarkTheme}
@@ -163,10 +163,6 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
[apiData, pickedNames, othersLabel] [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 { legendNames, colorMap } = useMemo(() => {
const names: string[] = []; const names: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -231,15 +227,6 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
}, },
legend: { legend: {
orient: 'vertical', 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, right: 0,
top: 18, top: 18,
bottom: 18, bottom: 18,
@@ -249,12 +236,16 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
textStyle: { textStyle: {
color: token.colorTextTertiary, color: token.colorTextTertiary,
overflow: 'truncate', overflow: 'truncate',
// 15% of typical xl card canvas (~680-900px) ≈ 102-135px column; width: 180
// marker(8) + gap(5) + 90 = 103 fits even at the narrow end.
width: 90
}, },
data: legendNames, 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: [ series: [
{ {
@@ -295,12 +286,6 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
apiLabel 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(() => { useEffect(() => {
let raf: number | undefined; let raf: number | undefined;
let detach: (() => void) | 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 { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list'; 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 Separator from '@/pages/llmodels/components/separator';
import { getGPUStackPlugin } from '@/plugins';
import { SearchOutlined } from '@ant-design/icons'; import { SearchOutlined } from '@ant-design/icons';
import { import {
AlertBlockInfo, AlertBlockInfo,
@@ -9,14 +12,15 @@ import {
GSDrawer, GSDrawer,
ModalFooter ModalFooter
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl, useModel } from '@umijs/max';
import { Empty, Input, Typography } from 'antd'; import { Empty, Input, Typography } from 'antd';
import _ from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { ListItem as TemplateItem } from '../../templates/config/types'; import { ListItem as TemplateItem } from '../../templates/config/types';
import useQueryTemplates from '../../templates/services/use-query-templates'; import useQueryTemplates from '../../templates/services/use-query-templates';
import { FormData, InstanceTypeItem, ListItem } from '../config/types'; import { FormData, InstanceTypeItem, ListItem } from '../config/types';
import GPUServiceInstanceForm from '../forms'; 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 useQueryInstanceTypes from '../services/use-query-instance-types';
import styles from '../styles/instances.module.less'; import styles from '../styles/instances.module.less';
import InstanceTypeList from './instance-type-list'; import InstanceTypeList from './instance-type-list';
@@ -77,6 +81,12 @@ const AddModal: React.FC<AddModalProps> = ({
realAction realAction
}) => { }) => {
const intl = useIntl(); 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 form = useRef<any>(null);
const sessionRef = useRef(0); const sessionRef = useRef(0);
const [instanceTypeSelection, setInstanceTypeSelection] = useState<{ const [instanceTypeSelection, setInstanceTypeSelection] = useState<{
@@ -89,7 +99,8 @@ const AddModal: React.FC<AddModalProps> = ({
const [templateId, setTemplateId] = useState<number | undefined>(); const [templateId, setTemplateId] = useState<number | undefined>();
const [instanceKeyword, setInstanceKeyword] = useState(''); const [instanceKeyword, setInstanceKeyword] = useState('');
const [templateKeyword, setTemplateKeyword] = useState(''); const [templateKeyword, setTemplateKeyword] = useState('');
const [loading, setLoading] = useState(false); const { loading, guard, run, release } = useSubmitLock();
const initializedRef = useRef(false);
const { const {
detailData: instanceTypeList, detailData: instanceTypeList,
@@ -169,17 +180,27 @@ const AddModal: React.FC<AddModalProps> = ({
return JSON.stringify({ return JSON.stringify({
name: instanceType.name, name: instanceType.name,
spec: { 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 // apply the selection of instance type and template
const applySelection = ( const applySelection = (
instanceType: InstanceTypeItem, instanceType: InstanceTypeItem,
template: TemplateItem | undefined template: TemplateItem | undefined
) => { ) => {
const manufacturer = instanceType.spec?.manufacturer; const manufacturer = manufacturerOf(instanceType);
setInstanceTypeSelection({ setInstanceTypeSelection({
instanceType: instanceType.name, instanceType: instanceType.name,
@@ -210,6 +231,35 @@ const AddModal: React.FC<AddModalProps> = ({
form.current?.applyInstanceType?.(instanceType); 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 = ( const findAggregateOf = (
candidateName: string | undefined, candidateName: string | undefined,
clusterId: number | null | undefined, clusterId: number | null | undefined,
@@ -246,7 +296,7 @@ const AddModal: React.FC<AddModalProps> = ({
if (aggregate) { if (aggregate) {
setInstanceTypeSelection({ setInstanceTypeSelection({
instanceType: aggregate.name, instanceType: aggregate.name,
manufacturer: aggregate.spec?.manufacturer manufacturer: manufacturerOf(aggregate)
}); });
} }
return; return;
@@ -254,31 +304,10 @@ const AddModal: React.FC<AddModalProps> = ({
// Scope to clusters the chosen org owns (admin "All" view). // Scope to clusters the chosen org owns (admin "All" view).
const owned = filterTypesByOwner(instanceTypes, clusters || [], orgId); const owned = filterTypesByOwner(instanceTypes, clusters || [], orgId);
const first = owned.find((item) => !item.disabled);
if (!first) { // On create, auto-select the first available instance type (clears the
// The chosen org has no clusters (hence no instance types). Clear any // selection when the chosen org has none).
// prior pick so a stale instance type / cross-org cluster isn't left autoSelectFirst(owned, templates);
// 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);
}; };
// Fetch the (tenant-scoped) instance types + templates and auto-select. // 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. // picker settling on its default) the latest scope's result wins.
const loadCreateResources = (orgId?: number | null) => { const loadCreateResources = (orgId?: number | null) => {
const session = ++sessionRef.current; const session = ++sessionRef.current;
Promise.all([ try {
fetchData({ page: -1 }), Promise.all([
fetchTemplates({ page: -1 }), fetchData({ page: -1 }),
fetchClusterList({ page: -1 }) fetchTemplates({ page: -1 }),
]).then(([instanceResItems, templatesRes, clusters]) => { fetchClusterList({ page: -1 })
if (sessionRef.current !== session) return; ]).then(([instanceResItems, templatesRes, clusters]) => {
applyAutoSelection( if (sessionRef.current !== session) return;
instanceResItems || [], applyAutoSelection(
templatesRes?.items || [], instanceResItems || [],
(Array.isArray(clusters) ? clusters : (clusters as any)?.items) || [], templatesRes?.items || [],
orgId (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 // 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) => { const handleScopeChange = (orgId?: number | null) => {
if (!open || action !== PageAction.CREATE) return; if (!open || action !== PageAction.CREATE) return;
setScopeOrgId(orgId); setScopeOrgId(orgId);
setInstanceTypeSelection({ // Drop the instance-type-derived selection + form state (the selected type
instanceType: undefined,
manufacturer: undefined
});
setTemplateId(undefined);
// Also clear the instance-type-derived form state (the selected type
// card + its limits, the cluster, and spec.type). The cluster decides // card + its limits, the cluster, and spec.type). The cluster decides
// where the instance is scheduled, so a stale pick from the previous // where the instance is scheduled, so a stale pick from the previous
// scope must not survive — otherwise an instance owned by the newly // scope must not survive — otherwise an instance owned by the newly
// chosen org could land on the old org's cluster. The reload's // 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 // owner-scoped auto-selection re-fills them from the new org, or leaves
// them empty (blocking submit) when the chosen org has no clusters. // them empty (blocking submit) when the chosen org has no clusters.
form.current?.applyInstanceType?.(undefined); clearSelection();
form.current?.setFieldValue?.('clusterId', null); initializedRef.current = false;
form.current?.setFieldValue?.(['spec', 'type'], undefined);
loadCreateResources(orgId); loadCreateResources(orgId);
}; };
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
initializedRef.current = false;
sessionRef.current += 1; sessionRef.current += 1;
setInstanceTypeSelection({ setInstanceTypeSelection({
instanceType: undefined, 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 = () => { const handleSubmit = () => {
form.current?.submit(); guard(() => form.current?.submit());
}; };
const handleCancel = () => { const handleCancel = () => {
@@ -383,20 +496,17 @@ const AddModal: React.FC<AddModalProps> = ({
}; };
const onFinish = async (values: FormData) => { const onFinish = async (values: FormData) => {
setLoading(true); await run(async () => {
try {
await onOk({ await onOk({
...values ...values
}); });
console.log('submit form values', values); console.log('submit form values', values);
} finally { });
setLoading(false);
}
}; };
const handleInstanceTypeChange = (item: InstanceTypeItem) => { const handleInstanceTypeChange = (item: InstanceTypeItem) => {
const template = findTemplateByManufacturer( const template = findTemplateByManufacturer(
item.spec?.manufacturer, manufacturerOf(item),
templateList templateList
); );
applySelection(item, template); applySelection(item, template);
@@ -456,7 +566,9 @@ const AddModal: React.FC<AddModalProps> = ({
}} }}
> >
<ColTitle style={{ paddingBottom: 0 }}> <ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({ id: 'gpuservice.instance.types' })} {intl.formatMessage({
id: 'gpuservice.instance.types'
})}
</ColTitle> </ColTitle>
<Input <Input
allowClear allowClear
@@ -507,10 +619,10 @@ const AddModal: React.FC<AddModalProps> = ({
onChange={(e) => setTemplateKeyword(e.target.value)} onChange={(e) => setTemplateKeyword(e.target.value)}
/> />
</div> </div>
{filteredTemplates.length > 0 ? ( {filteredTemplates.length > 0 && initializedRef.current ? (
<TemplateSelector <TemplateSelector
value={templateId} value={templateId}
dataList={filteredTemplates} groups={templateGroups}
onChange={handleTemplateChange} onChange={handleTemplateChange}
/> />
) : ( ) : (
@@ -565,6 +677,7 @@ const AddModal: React.FC<AddModalProps> = ({
currentData={data} currentData={data}
disabled={readonly} disabled={readonly}
onFinish={onFinish} onFinish={onFinish}
onFinishFailed={release}
onScopeChange={handleScopeChange} onScopeChange={handleScopeChange}
open={open} open={open}
instanceTypeList={ownedInstanceTypes} instanceTypeList={ownedInstanceTypes}
@@ -24,7 +24,6 @@ const InstanceTypeCell: React.FC<{
}> = ({ title, name, sections }) => { }> = ({ title, name, sections }) => {
const specInfo = ( const specInfo = (
<div style={{ minWidth: 200 }}> <div style={{ minWidth: 200 }}>
{name && <div style={{ fontWeight: 600, marginBottom: 8 }}>{name}</div>}
{sections.map((sec) => { {sections.map((sec) => {
const rows = sec.rows.filter(([, v]) => v && v !== '-'); const rows = sec.rows.filter(([, v]) => v && v !== '-');
if (!rows.length) return null; if (!rows.length) return null;

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