Compare commits

..
Author SHA1 Message Date
jialin 1451a49da2 feat: namespace browser storage keys by deploy path 2026-07-15 09:46:27 +08:00
jialinandjialin 06a2777632 fix: center NoResult empty state with minHeight on list pages 2026-07-14 19:54:45 +08:00
hibigandjialin 8f0691395b chore: bump @gpustack/core-ui to v1.0.36 2026-07-14 19:46:22 +08:00
jialinandjialin 8eb871d0f5 fix(user-models): hold empty state until infra queries resolve
Managers start in a loading state and gate the empty state on the
cluster/worker fetch, so the 'no clusters/workers' guidance no longer
flashes before the real infra data lands.
2026-07-14 17:37:53 +08:00
jialinandjialin 6e321d2d99 feat(models): resource-aware empty state on My Models; tag GPU cluster type experimental
My Models empty state now guides managers to create a cluster/worker or
deploy a model (queries gated to canSeeAdmin/canSeeOrgAdmin); plain users
keep the consumer copy. Add an Experimental badge to the GPU-instance
cluster type card.
2026-07-14 17:37:53 +08:00
jialinandjialin 97179365bb feat(cluster): drop detail page; host Cluster Access drawer
Remove the cluster-detail route and the name-link navigation; the
cluster name is now plain text. Mount the plugin-provided
clusterDetail.AccessDrawer so the enterprise row action can open it.
2026-07-14 17:37:53 +08:00
micheliaandjialin 046d466e16 fix(usage): memoize resource breakdown filters (review comment)
The GPU Instances / Storage tabs built the filter object inline (in
baseRequest and again in the extra tab's props), duplicating the
conditional-spread logic and handing the enterprise Organization tab a fresh
object every render — which refired its fetch effect on any state change.
Hoist it into one memoized ``breakdownFilters`` shared by the chart request
and the extra tab, mirroring the Tokens tab's stable-filters shape.
2026-07-14 14:35:23 +08:00
micheliaandjialin 77a2abad32 fix(usage): satisfy Rules of Hooks for plugin extra tabs; date-safe export
- change the breakdown extra-tab contract from a `useVisible()` hook to a plain
  `isVisible(ctx)` function so the host no longer calls hooks inside a loop
  (token breakdown-tabs + resource GPU/Storage tabs)
- resource export: drop the time portion of the date bucket by slicing the ISO
  string instead of re-parsing with dayjs (avoids a timezone day-shift)
2026-07-14 14:35:23 +08:00
micheliaandjialin efb63335e7 feat(usage): add plugin injection points for org / user-group filters and org breakdown tab
- token & resource filter bars render a plugin slot (UsageFilterBar /
  ResourceUsageFilterBar) for the organization / user-group filters
- breakdown tabs read plugin-provided extra sub-tabs (breakdownExtraTabs /
  resourceBreakdownExtraTabs) so the enterprise plugin can inject the
  Organization sub-tab
- thread the org / user-group filter state through the token filter hook and
  the resource tabs / breakdown tables / summary / export dialog
- standardize the organization label on the principal name; resource export
  date formatted to date-only
2026-07-14 14:35:23 +08:00
jialinandjialin d0b6498bda fix(llmodels): don't evaluate on cluster seed before a model is selected
The basic form seeds a default cluster on open, which fired the full cluster-change handler and triggered an evaluate request before any model was picked. Split off a seed callback that refreshes scoped options without evaluating, and guard the evaluate handler with a model-selected check as a backstop.
2026-07-13 17:14:43 +08:00
jialinandjialin 35ed397c2a fix: use branded sidebar logo, gate help menu behind plugin, add instance pending/starting filters 2026-07-13 13:59:13 +08:00
jialinandjialin cb380d676e refactor(usage): use SimpleSelect for the events-tab event-type filter
Match the other usage tabs' dropdown component instead of the raw antd
Select.
2026-07-10 17:41:54 +08:00
jialinandjialin 03c47530bd fix(usage): map owner creator fields for chart-export User column
Read creator_id / creator_name / creator_deleted from the breakdown item
root into the flat user_* fields, so the GPU Instances / Storage chart
export marks a deleted owner independently of the grouped instance/volume.
2026-07-10 17:41:54 +08:00
jialinandjialin ab92641541 fix(usage): sort deleted filter options to the bottom of every dropdown
Deleted users / models / API keys / instances / volumes now sink below the
live entries in each usage tab's filter, while the current account stays on
top and live entries keep their order (stable sort).
2026-07-10 17:41:54 +08:00
jialinandjialin 1f861c5844 feat(usage): break down chart export by instance/volume with owner user column
Group the GPU Instances and Storage chart exports by date+instance /
date+volume, add Instance/Storage and (org-admin only) User columns, and
mark deleted instances/volumes and their owner user independently in both
the preview and the exported sheet.
2026-07-10 16:31:35 +08:00
jialinandjialin f92499cbe5 refactor(llmodels): rename MyModelsStatusValueMap keys to Stopped/NotReady/Ready 2026-07-10 16:31:35 +08:00
jialinandjialin 6a9cba8020 feat(clusters): warn about K8s version requirement on registration 2026-07-09 20:36:52 +08:00
jialinandjialin 4f4b527014 fix(usage): unique filter option values so same-named entries don't co-select 2026-07-09 20:36:52 +08:00
jialinandjialin 0d5812ba88 fix(cluster): ensure gpuInstanceOptions is submitted for gpu k8s cluster 2026-07-09 20:36:52 +08:00
jialinandjialin 650091b57a fix(usage): carry deleted flag in summary-tab user filter union 2026-07-09 20:36:52 +08:00
jialinandjialin 5bdfb36721 fix(apikeys): description incorrect when empty data 2026-07-09 20:36:52 +08:00
jialinandjialin 0436872093 style(user-models): larger logo, restyle playground button on card 2026-07-09 20:36:52 +08:00
jialinandjialin 3d88b2825b feat(models): rename user-models menu to Models, reorder nav, fix glm logo 2026-07-09 20:36:52 +08:00
jialinandjialin dc54c21d70 feat(user-models): clickable model card opens API access info 2026-07-09 20:36:52 +08:00
jialinandjialin 794ac724ca refactor(usage): address PR review — null-safe mark, hook renames, lazy intl 2026-07-09 15:17:53 +08:00
jialinandjialin d91fbec9d7 refactor(cluster-management): use menu.resources i18n keys 2026-07-09 15:17:53 +08:00
jialinandjialin 555aab714e fix(usage): mark deleted rows and correct api-key header in token chart export 2026-07-09 15:17:53 +08:00
jialinandjialin eb037a3613 style(dashboard): show deleted marker in usage chart labels 2026-07-09 15:17:53 +08:00
jialinandjialin b1efa415e8 style(usage): show deleted marker in chart legends and exports 2026-07-09 15:17:53 +08:00
jialinandjialin e79d49f2a8 style(usage): unify name column header + DeletedTag across breakdown tables 2026-07-09 15:17:53 +08:00
jialinandjialin 7d9f68da2d style(usage): extract DeletedTag, unify deleted-entity tag across filters 2026-07-09 15:17:53 +08:00
jialinandjialin c691df25c0 feat(usage): mark current account first in resource tab user filters
Apply the Tokens tab's current-user-first ordering and [Current Account] marker to the Summary/Storage/Instances tab user filters.
2026-07-09 15:17:53 +08:00
gitlawrandjialin cd4b1b8629 fix(clusters): scope cluster pickers to the owning org
Cluster pickers listed every visible cluster, which includes clusters
shared with all authenticated users (e.g. the default org's "shared with
everyone" clusters). A member of a custom org could then pick another
org's cluster for a model deployment or a new worker.

Request only the current org's own clusters (mine=true) for:
- the model deploy picker (models page),
- the deploy-from-model-file picker (model files page), and
- the add-worker picker.

These resources are owner-scoped on the backend, so the model list and
the worker table (including its cluster-name column) only reference
own-org clusters. The add-model-file worker cascader is already own-org
via the owner-scoped worker list. Platform admin in the "All" view
bypasses mine and is scoped instead by the org picker.
2026-07-08 20:12:00 +08:00
jialinandjialin 70229a9757 feat(gpu-service): add status column to storage and storage-type lists 2026-07-08 16:55:42 +08:00
gitlawrandjialin 644aba3fc2 fix: scope storage type picker to the target organization
The storage create form fetched persistent-volume types once with no
tenant scope, so in the platform-admin "All" view the dropdown listed
every org's types and never re-scoped when the create-scope picker
retargeted the form — letting an org with no cluster-access grant pick
another org's types. The inline "add storage" sub-drawer in the instance
create form had the same gap plus a redundant org picker that could
retarget the storage away from the instance's org.

Both surfaces now pin the storage-type list to the chosen org (via the
X-Organization-Id header); the sub-drawer inherits the instance's scope
and hides its own picker. Page/table listings stay unscoped so every
row's type label still renders.
2026-07-08 16:31:53 +08:00
gitlawrandjialin c025257fea feat(gpu-service): clone an instance template into the caller's scope
Add a Clone action to instance template cards. It reuses the create
flow prefilled from the source, dropping the source id and ownership so
the backend assigns a fresh id and scopes the copy to the caller's own
principal. Clone is offered on every visible template — including
admin-curated Global rows a non-admin can otherwise only view — so
users can fork a Global preset into their own organization without
admin involvement.

The prefilled name defaults to a `-clone` suffix (trimmed to the
63-char limit) so cloning within the same scope doesn't collide on the
unique name. Edit/Delete remain gated on ownership; Clone is always
available.
2026-07-08 16:27:40 +08:00
gitlawrandjialin fdafdfc3d1 fix(gpu-service): show global instance templates under custom scopes
The Instance Templates management page requested the `mine` list scope,
which drops admin-curated Global templates for non-admin callers. The
Inference Backend page uses the default scope and shows Global + own
rows, so the two pages behaved inconsistently.

Use the default scope on the templates page so Global templates are
visible alongside the caller's own, and gate Edit/Delete on the card so
Global rows a non-admin can't modify render read-only.
2026-07-08 16:27:40 +08:00
jialinandjialin f18c12662b Revert "fix(usage): remove the delete tag of models and users in tokens tab"
This reverts commit 1b3d4748d4de963ba0c710e3aac91e3af0015531.
2026-07-08 10:45:23 +08:00
jialinandjialin 71840cfc8f refactor(footer): use useAtomValue for version 2026-07-07 20:37:39 +08:00
jialinandjialin e75beb7324 fix(footer): show version reactively via useAtom 2026-07-07 20:37:39 +08:00
jialinandjialin 90cc6ec753 fix(cluster): drive k8s cluster type from shared state
Replace unreliable Form.useWatch on the unregistered gpuInstanceOptions
path with explicit shared clusterType state. Fixes the type selector
being unclickable and flickering, and ensures a model cluster no longer
submits a stale gpuInstanceOptions payload.
2026-07-07 20:37:39 +08:00
jialinandjialin bd4954f161 fix(usage): remove the delete tag of models and users in tokens tab 2026-07-07 20:37:39 +08:00
jialinandjialin 32fab0fa62 style: gpu selector 2026-07-07 20:37:39 +08:00
gitlawrandjialin d12e92a916 fix(models): preselect the scoped default cluster on deploy
The deploy form's cluster select could open blank even when a valid
(default) cluster exists for the current scope. The open handler seeds
the cluster once, but that seed can be empty (options not loaded yet) or
point outside the resolved scope (the scope id and the option list both
settle after mount, and switching scope re-scopes the list), and nothing
re-picked afterwards — so the field stayed empty.

Make the form enforce the invariant directly: whenever the scope or the
available options change, if the current selection is empty or not among
the options, fall back to the scope's default cluster (then a Ready one,
then the first). A still-valid selection is left untouched.
2026-07-07 18:18:39 +08:00
jialinandjialin dc41b61d69 fix(deployments): display gpu allocated value in gpu selector 2026-07-06 17:48:22 +08:00
116 changed files with 2251 additions and 696 deletions
+10 -21
View File
@@ -100,6 +100,15 @@ const baseRoutes = [
path: '/models',
redirect: '/models/deployments'
},
{
name: 'userModels',
path: '/models/user-models',
key: 'userModels',
icon: 'icon-models',
selectedIcon: 'icon-models-filled',
defaultIcon: 'icon-models',
component: './llmodels/user-models'
},
{
name: 'modelCatalog',
path: '/models/catalog',
@@ -110,16 +119,6 @@ const baseRoutes = [
access: 'canSeeOrgAdmin',
component: './llmodels/catalog'
},
{
name: 'userModels',
path: '/models/user-models',
key: 'userModels',
icon: 'icon-models',
selectedIcon: 'icon-models-filled',
defaultIcon: 'icon-models',
access: 'canSeeUser',
component: './llmodels/user-models'
},
{
name: 'deployment',
path: '/models/deployments',
@@ -274,7 +273,7 @@ const baseRoutes = [
selectedIcon: 'icon-cluster2-filled',
defaultIcon: 'icon-cluster2-outline',
component: './cluster-management/clusters',
subMenu: ['/resources/clusters/detail', '/resources/clusters/create']
subMenu: ['/resources/clusters/create']
},
{
name: 'workers',
@@ -302,16 +301,6 @@ const baseRoutes = [
selectedIcon: 'icon-credential-filled',
defaultIcon: 'icon-credential-outline',
component: './cluster-management/credentials'
},
{
name: 'clusterDetail',
path: '/resources/clusters/detail',
key: 'clusterDetail',
icon: 'icon-cluster2-outline',
selectedIcon: 'icon-cluster2-filled',
defaultIcon: 'icon-cluster2-outline',
hideInMenu: true,
component: './cluster-management/cluster-detail'
}
]
},
+1 -1
View File
@@ -17,7 +17,7 @@
"@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.35",
"@gpustack/core-ui": "^1.0.36",
"@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6",
+5 -5
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1
version: 7.1.2
'@gpustack/core-ui':
specifier: ^1.0.35
version: 1.0.35(czdvzceysqw7iv6pct2ucnb23e)
specifier: ^1.0.36
version: 1.0.36(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf':
specifier: ^0.1.7
version: 0.1.18
@@ -1481,8 +1481,8 @@ packages:
resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz}
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
'@gpustack/core-ui@1.0.35':
resolution: {integrity: sha512-MaEmCM3FikeKZdUgDSyUtIfXtNMbNOLeFiTynl6DcKEraUOpk6tgQXPDginBY3uXuisG4hzoIQbpHHkzaYiQew==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.35.tgz}
'@gpustack/core-ui@1.0.36':
resolution: {integrity: sha512-gF8ShMZ2SKYo3+skfsI3zo+aBSsXqKGMX+jWaKv81e38bDwJw71C33TR64d+6MyV9iUoFOoSK+ht1GefyvR17g==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.36.tgz}
peerDependencies:
'@ant-design/icons': ^6.1.0
'@ant-design/pro-components': 3.1.0-0
@@ -10802,7 +10802,7 @@ snapshots:
'@formatjs/intl-utils@2.3.0': {}
'@gpustack/core-ui@1.0.35(czdvzceysqw7iv6pct2ucnb23e)':
'@gpustack/core-ui@1.0.36(czdvzceysqw7iv6pct2ucnb23e)':
dependencies:
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+2 -1
View File
@@ -1,3 +1,4 @@
import { nsLocalJSONStorage } from '@gpustack/core-ui/utils';
import { getDefaultStore } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
@@ -8,7 +9,7 @@ export interface PaginationState {
export const paginationAtom = atomWithStorage<Record<string, any>>(
'paginationStatus',
{},
undefined,
nsLocalJSONStorage,
{ getOnInit: true }
);
+10 -7
View File
@@ -1,4 +1,5 @@
import { COLOR_PRIMARY } from '@/config/theme';
import { nsLocal, nsLocalJSONStorage } from '@gpustack/core-ui/utils';
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
@@ -23,9 +24,7 @@ export const defaultSettings: UserSettings = {
export const getStorageUserSettings = () => {
if (typeof window === 'undefined') return defaultSettings;
try {
const savedSettings = JSON.parse(
localStorage.getItem('userSettings') || '{}'
);
const savedSettings = JSON.parse(nsLocal.get('userSettings') || '{}');
return {
...defaultSettings,
...savedSettings
@@ -35,9 +34,13 @@ export const getStorageUserSettings = () => {
}
};
export const userSettingsAtom = atomWithStorage<UserSettings>('userSettings', {
...getStorageUserSettings()
});
export const userSettingsAtom = atomWithStorage<UserSettings>(
'userSettings',
{
...getStorageUserSettings()
},
nsLocalJSONStorage
);
export const userSettingsHelperAtom = atom(
(get) => get(userSettingsAtom),
@@ -59,6 +62,6 @@ export const hideModalTemporarilyAtom = atom<boolean>(false);
export const collapsedMenuGroupsAtom = atomWithStorage<string[]>(
'collapsedMenuGroups',
[],
undefined,
nsLocalJSONStorage,
{ getOnInit: true }
);
+3 -1
View File
@@ -1,9 +1,11 @@
import { nsLocalJSONStorage } from '@gpustack/core-ui/utils';
import { getDefaultStore } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export const tabActiveAtom = atomWithStorage<Map<string, any>>(
'tabActiveStatus',
new Map()
new Map(),
nsLocalJSONStorage
);
export const setActiveStatus = (key: string, value: any) => {
+10 -4
View File
@@ -1,7 +1,12 @@
import { nsLocal, nsLocalJSONStorage } from '@gpustack/core-ui/utils';
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export const userAtom = atomWithStorage<any>('userInfo', null);
export const userAtom = atomWithStorage<any>(
'userInfo',
null,
nsLocalJSONStorage
);
// Backs the `currentOrganizationId` localStorage key. Stays null in
// builds with no Org context (single-tenant), and is shared with any
@@ -9,7 +14,8 @@ export const userAtom = atomWithStorage<any>('userInfo', null);
// without one side having to import from the other.
export const currentOrganizationIdAtom = atomWithStorage<number | null>(
'currentOrganizationId',
null
null,
nsLocalJSONStorage
);
export const GPUStackVersionAtom = atom<{
@@ -70,7 +76,7 @@ export const getCurrentOrgNamespace = (
const getStoredCurrentOrgId = (): number | null => {
try {
const raw = localStorage.getItem('currentOrganizationId');
const raw = nsLocal.get('currentOrganizationId');
if (!raw) return null;
const value = JSON.parse(raw);
return typeof value === 'number' ? value : null;
@@ -108,7 +114,7 @@ export const getOrgById = (
const target = String(id);
for (const key of ORG_CACHE_KEYS) {
try {
const raw = localStorage.getItem(key);
const raw = nsLocal.get(key);
if (!raw) continue;
const list = JSON.parse(raw) as CachedOrg[];
if (!Array.isArray(list)) continue;
+4 -5
View File
@@ -1,18 +1,17 @@
import { defaultSettings } from '@/atoms/settings';
import { nsLocal } from '@gpustack/core-ui/utils';
import { getDefaultStore } from 'jotai';
export const clearStorageUserSettings = () => {
try {
const savedSettings = JSON.parse(
localStorage.getItem('userSettings') || '{}'
);
const savedSettings = JSON.parse(nsLocal.get('userSettings') || '{}');
// colorPrimary is an enterprise-wide branding setting (set by admins
// and applied by `onAppInit` from /enterprise/settings), not a per-user
// preference. Preserve it across login — otherwise the next layout
// mount triggers `atomWithStorage.onMount`, re-reads localStorage,
// and falls back to the default color until a full page refresh
// re-runs `applyEnterpriseSettings`.
localStorage.setItem(
nsLocal.set(
'userSettings',
JSON.stringify({
...savedSettings,
@@ -26,7 +25,7 @@ export const clearStorageUserSettings = () => {
export const resetStorageUserSettings = () => {
try {
localStorage.setItem(
nsLocal.set(
'userSettings',
JSON.stringify({
...defaultSettings,
+3 -2
View File
@@ -1,10 +1,10 @@
import { GPUStackVersionAtom } from '@/atoms/user';
import { getAtomStorage } from '@/atoms/utils';
import VersionInfo, { modalConfig } from '@/components/version-info';
import externalLinks from '@/constants/external-links';
import { useIntl } from '@umijs/max';
import { Button, Divider, Modal, Typography } from 'antd';
import { createStyles } from 'antd-style';
import { useAtomValue } from 'jotai';
import styled from 'styled-components';
const CompanyWrapper = styled.div`
@@ -35,6 +35,7 @@ const Footer: React.FC = () => {
const intl = useIntl();
const [modal, contextHolder] = Modal.useModal();
const { styles } = useStyles();
const version = useAtomValue(GPUStackVersionAtom);
const showVersion = () => {
modal.info({
@@ -73,7 +74,7 @@ const Footer: React.FC = () => {
</Button>
<Divider orientation="vertical" />
<Button type="link" size="small" onClick={showVersion}>
{getAtomStorage(GPUStackVersionAtom)?.version}
{version?.version}
</Button>
</div>
</div>
+3 -2
View File
@@ -1,6 +1,7 @@
import Logo from '@/assets/images/gpustack-logo.png';
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
import externalLinks from '@/constants/external-links';
import { useLogo } from '@/hooks/use-logo';
import { Button } from 'antd';
import { useAtom } from 'jotai';
import React from 'react';
@@ -18,7 +19,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
isProd,
isDev
} = gpuStackVersionAtom;
const { sidebarLogo } = useLogo();
// user info
const { is_admin } = userDataAtom || {};
@@ -30,7 +31,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
return (
<div className="version-box">
<div className="img">
<img src={Logo} alt="logo" />
<img src={sidebarLogo || Logo} alt="logo" />
</div>
<div className="ver">
+13 -9
View File
@@ -4,6 +4,7 @@ import VersionInfo, { modalConfig } from '@/components/version-info';
import externalLinks from '@/constants/external-links';
import useBodyScroll from '@/hooks/use-body-scroll';
import { logout } from '@/pages/login/apis';
import { getGPUStackPlugin } from '@/plugins';
import { useModel } from '@@/plugin-model';
import {
DiscordOutlined,
@@ -98,6 +99,7 @@ const CustomItem = styled.div`
export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
const { isDarkTheme } = props;
const plugin = getGPUStackPlugin();
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [modal, contextHolder] = Modal.useModal();
const [version] = useAtom(GPUStackVersionAtom);
@@ -286,15 +288,17 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
</NewLabel>
)}
</div>
<DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}>
<IconWrapper>
<IconFont
type="icon-help"
className="font-size-20"
style={{ color: 'var(--ant-color-text-tertiary)' }}
/>
</IconWrapper>
</DropdownActions>
{!plugin && (
<DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}>
<IconWrapper>
<IconFont
type="icon-help"
className="font-size-20"
style={{ color: 'var(--ant-color-text-tertiary)' }}
/>
</IconWrapper>
</DropdownActions>
)}
<PluginExtraField name="GlobalSettings" />
<DropdownActions menu={{ ...userMenu }} popupRender={userPopupRender}>
<IconWrapper>
+3 -5
View File
@@ -1,5 +1,6 @@
import externalLinks from '@/constants/external-links';
import { GithubFilled } from '@ant-design/icons';
import { nsLocal } from '@gpustack/core-ui/utils';
import { useIntl } from '@umijs/max';
import { Tooltip } from 'antd';
import { useEffect, useState } from 'react';
@@ -66,7 +67,7 @@ type CacheEntry = { value: number; time: number };
const readCache = (): CacheEntry | null => {
try {
const raw = localStorage.getItem(CACHE_KEY);
const raw = nsLocal.get(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (typeof parsed?.value !== 'number' || typeof parsed?.time !== 'number') {
@@ -80,10 +81,7 @@ const readCache = (): CacheEntry | null => {
const writeCache = (value: number) => {
try {
localStorage.setItem(
CACHE_KEY,
JSON.stringify({ value, time: Date.now() })
);
nsLocal.set(CACHE_KEY, JSON.stringify({ value, time: Date.now() }));
} catch {
// ignore quota errors
}
+2
View File
@@ -44,6 +44,8 @@ export default {
'On the Worker that needs to be added, run the following command to join it to the cluster.',
'clusters.create.addCommand.k8s.tips':
'On the Kubernetes cluster that needs to be registered, run the following command to create the Kubernetes resources and register the cluster.',
'clusters.create.addCommand.k8s.version.warning':
'The minimum supported Kubernetes version is 1.23. To use the GPU Service feature, the minimum supported Kubernetes version is 1.27.',
'clusters.create.register.tips':
'On the Kubernetes cluster that needs to be added, run the following command to join its nodes to the cluster.',
'cluster.create.checkEnv.tips':
+1
View File
@@ -2,6 +2,7 @@ export default {
'gpuservice.template': 'GPU Instance Template',
'gpuservice.template.add': 'Add Instance Template',
'gpuservice.template.edit': 'Edit Instance Template',
'gpuservice.template.clone': 'Clone Instance Template',
'gpuservice.template.filter.name': 'Filter by name',
'gpuservice.template.filter.vendor': 'Filter by vendor',
'gpuservice.template.image': 'Image',
+1 -1
View File
@@ -13,7 +13,7 @@ export default {
'menu.models.modelCatalog': 'Catalog',
'menu.models.catalog': 'Model Catalog',
'menu.models.deployment': 'Deployments',
'menu.models.userModels': 'My Models',
'menu.models.userModels': 'Models',
'menu.models.benchmark': 'Benchmarks',
'menu.models.benchmarkDetail': 'Benchmark Details',
'menu.models.providers': 'Providers',
+2
View File
@@ -44,6 +44,8 @@ export default {
'On the Worker that needs to be added, run the following command to join it to the cluster.',
'clusters.create.addCommand.k8s.tips':
'登録する Kubernetes クラスターで以下のコマンドを実行し、Kubernetes リソースを作成してクラスターを登録します。',
'clusters.create.addCommand.k8s.version.warning':
'サポートされる Kubernetes の最小バージョンは 1.23 です。GPU Service 機能を使用する場合、サポートされる Kubernetes の最小バージョンは 1.27 です。',
'cluster.create.checkEnv.tips':
'Use the following command to check if the environment is ready.',
'clusters.create.register.tips':
+1
View File
@@ -2,6 +2,7 @@ export default {
'gpuservice.template': 'GPU インスタンステンプレート',
'gpuservice.template.add': 'インスタンステンプレートを追加',
'gpuservice.template.edit': 'インスタンステンプレートを編集',
'gpuservice.template.clone': 'インスタンステンプレートを複製',
'gpuservice.template.filter.name': '名前でフィルター',
'gpuservice.template.filter.vendor': 'ベンダーでフィルター',
'gpuservice.template.image': 'コンテナイメージ',
+6 -6
View File
@@ -41,7 +41,7 @@ export default {
'menu.accessControl.organizations': 'Organizations',
'menu.resources.clusters': 'Clusters',
'menu.resources.credentials': 'Cloud Credentials',
'menu.models.userModels': 'My Models',
'menu.models.userModels': 'Models',
'menu.resources.clusterDetail': 'Cluster Detail',
'menu.resources.clusterCreate': 'Create Cluster',
'menu.models.backendsList': 'Inference Backends',
@@ -62,11 +62,11 @@ export default {
// 6. 'menu.accessControl.apikeys': 'API Keys',
// 7. 'menu.accessControl.users': 'Users',
// 8. 'menu.clusterManagement': 'Cluster Management',
// 9. 'menu.clusterManagement.clusters': 'Clusters',
// 10. 'menu.clusterManagement.credentials': 'Cloud Credentials',
// 11. 'menu.models.userModels': 'My Models'
// 12. 'menu.clusterManagement.clusterDetail': 'Cluster Detail',
// 13. 'menu.clusterManagement.clusterCreate': 'Create Cluster',
// 9. 'menu.resources.clusters': 'Clusters',
// 10. 'menu.resources.credentials': 'Cloud Credentials',
// 11. 'menu.models.userModels': 'Models'
// 12. 'menu.resources.clusterDetail': 'Cluster Detail',
// 13. 'menu.resources.clusterCreate': 'Create Cluster',
// 14. 'menu.models.backendsList': 'Inference Backends',
// 15. 'menu.models.benchmark': 'Benchmarks',
// 15. 'menu.models.provider': 'Provider',
+2
View File
@@ -44,6 +44,8 @@ export default {
'На воркере, который необходимо добавить, выполните следующую команду, чтобы присоединить его к кластеру.',
'clusters.create.addCommand.k8s.tips':
'На Kubernetes-кластере, который необходимо зарегистрировать, выполните следующую команду, чтобы создать ресурсы Kubernetes и зарегистрировать этот кластер.',
'clusters.create.addCommand.k8s.version.warning':
'Минимальная поддерживаемая версия Kubernetes — 1.23. Для использования функции GPU Service минимальная поддерживаемая версия Kubernetes — 1.27.',
'cluster.create.checkEnv.tips':
'Используйте следующую команду для проверки готовности окружения',
'clusters.create.register.tips':
+1
View File
@@ -2,6 +2,7 @@ export default {
'gpuservice.template': 'Шаблон экземпляра GPU',
'gpuservice.template.add': 'Добавить шаблон экземпляра',
'gpuservice.template.edit': 'Редактировать шаблон экземпляра',
'gpuservice.template.clone': 'Клонировать шаблон экземпляра',
'gpuservice.template.filter.name': 'Фильтр по имени',
'gpuservice.template.filter.vendor': 'Фильтр по производителю',
'gpuservice.template.image': 'Образ',
+1 -1
View File
@@ -40,7 +40,7 @@ export default {
'menu.accessControl.organizations': 'Организации',
'menu.resources.clusters': 'Кластеры',
'menu.resources.credentials': 'Облачные аккаунты',
'menu.models.userModels': 'Мои модели',
'menu.models.userModels': 'Модели',
'menu.resources.clusterDetail': 'Детали кластера',
'menu.resources.clusterCreate': 'Создать кластер',
'menu.models.backendsList': 'Бэкенды запуска',
+2
View File
@@ -44,6 +44,8 @@ export default {
'Eklenmesi gereken İşçi Düğümde, kümeye katılması için aşağıdaki komutu çalıştırın.',
'clusters.create.addCommand.k8s.tips':
'Kaydedilmesi gereken Kubernetes kümesinde, Kubernetes kaynaklarını oluşturmak ve kümeyi kaydetmek için aşağıdaki komutu çalıştırın.',
'clusters.create.addCommand.k8s.version.warning':
'Desteklenen minimum Kubernetes sürümü 1.23tür. GPU Service özelliğini kullanmak için desteklenen minimum Kubernetes sürümü 1.27dir.',
'clusters.create.register.tips':
'Eklenmesi gereken Kubernetes kümesinde, düğümlerini kümeye katılması için aşağıdaki komutu çalıştırın.',
'cluster.create.checkEnv.tips':
+1
View File
@@ -2,6 +2,7 @@ export default {
'gpuservice.template': 'GPU Örnek Şablonu',
'gpuservice.template.add': 'Örnek Şablonu Ekle',
'gpuservice.template.edit': 'Örnek Şablonunu Düzenle',
'gpuservice.template.clone': 'Örnek Şablonunu Klonla',
'gpuservice.template.filter.name': 'Ada göre filtrele',
'gpuservice.template.filter.vendor': 'Tedarikçiye göre filtrele',
'gpuservice.template.image': 'İmaj',
+2 -2
View File
@@ -13,12 +13,12 @@ export default {
'menu.models.modelCatalog': 'Katalog',
'menu.models.catalog': 'Model Kataloğu',
'menu.models.deployment': 'Dağıtımlar',
'menu.models.userModels': 'Modellerim',
'menu.models.userModels': 'Modeller',
'menu.models.benchmark': 'Kıyaslamalar',
'menu.models.benchmarkDetail': 'Kıyaslama Detayları',
'menu.models.providers': 'Sağlayıcılar',
'menu.models.routes': 'Yönlendirmeler',
'menu.models.usage': 'Usage',
'menu.models.usage': 'Kullanım',
'menu.modelCatalog': 'Katalog',
'menu.resources': 'Kaynaklar',
'menu.apikeys': 'API Anahtarları',
+2
View File
@@ -43,6 +43,8 @@ export default {
'在需要添加的节点上运行以下命令,将其加入到集群中。',
'clusters.create.addCommand.k8s.tips':
'在需要注册的 Kubernetes 集群中运行以下命令,创建 Kubernetes 资源,注册该集群。',
'clusters.create.addCommand.k8s.version.warning':
'Kubernetes 版本最低支持 1.23,如果要使用 GPU Service 功能,Kubernetes 版本最低支持 1.27。',
'clusters.create.register.tips':
'在需要添加的 Kubernetes 集群上运行以下命令,将其中的节点加入到集群中。',
'cluster.create.checkEnv.tips': '使用以下命令检查环境是否准备妥当。',
+1
View File
@@ -2,6 +2,7 @@ export default {
'gpuservice.template': 'GPU 实例模板',
'gpuservice.template.add': '添加实例模板',
'gpuservice.template.edit': '编辑实例模板',
'gpuservice.template.clone': '克隆实例模板',
'gpuservice.template.filter.name': '按名称过滤',
'gpuservice.template.filter.vendor': '按厂商过滤',
'gpuservice.template.image': '镜像',
+1 -1
View File
@@ -12,7 +12,7 @@ export default {
'menu.models.modelList': '部署与管理',
'menu.models.modelCatalog': '模型库',
'menu.models.deployment': '部署',
'menu.models.userModels': '我的模型',
'menu.models.userModels': '模型广场',
'menu.models.benchmark': '基准测试',
'menu.models.benchmarkDetail': '基准测试详情',
'menu.models.providers': '提供商',
+5 -1
View File
@@ -183,11 +183,15 @@ const APIKeys: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-key" />}
filters={_.omit(queryParams, ['sort_by'])}
filters={{
..._.omit(queryParams, ['sort_by']),
user_id: queryParams.user_id === '*' ? undefined : queryParams.user_id
}}
noFoundText={intl.formatMessage({
id: 'noresult.keys.nofound'
})}
@@ -164,6 +164,7 @@ const CommunityBackends: React.FC<{
renderItem={renderItem}
></BackendCardList>
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
+1
View File
@@ -282,6 +282,7 @@ const BackendList = () => {
onSelect={handleOnSelect}
></BackendCardList>
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
+1
View File
@@ -177,6 +177,7 @@ const Benchmark: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={[]}
+10 -13
View File
@@ -5,6 +5,7 @@ import type { PageActionType } from '@/config/types';
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
import useTableFetch from '@/hooks/use-table-fetch';
import useWatchList from '@/hooks/use-watch-list';
import { getGPUStackPlugin } from '@/plugins';
import {
DeleteModal,
FilterBar,
@@ -14,7 +15,7 @@ import {
TableOrder,
TableProvider
} from '@gpustack/core-ui';
import { useIntl, useNavigate } from '@umijs/max';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { message } from 'antd';
import { useAtom } from 'jotai';
@@ -73,7 +74,7 @@ const Clusters: React.FC = () => {
deleteAPI: deleteCluster,
watch: true,
API: CLUSTERS_API,
contentForDelete: 'menu.clusterManagement.clusters',
contentForDelete: 'menu.resources.clusters',
defaultQueryParams: {
// Management view: drop cross-Org cluster_access grants. Org
// Owner only sees the clusters they own here. Pickers that
@@ -82,10 +83,13 @@ const Clusters: React.FC = () => {
mine: true
}
});
const navigate = useNavigate();
const { goToGrafana, ActionButton } = useGranfanaLink({
type: 'cluster'
});
// Cluster Access lives in the enterprise plugin: it contributes the
// row action and this self-controlled drawer, owning its own
// open/close state. OSS just mounts it (nothing without a plugin).
const AccessDrawer = getGPUStackPlugin()?.clusterDetail?.AccessDrawer;
const { watchDataList: allWorkerPoolList } = useWatchList(WORKER_POOLS_API);
const [expandAtom] = useAtom(expandKeysAtom);
const [clusterSession, setClusterSession] = useAtom(clusterSessionAtom);
@@ -272,14 +276,6 @@ const Clusters: React.FC = () => {
);
};
const handleOnCell = useMemoizedFn((record: ClusterListItem, dataIndex) => {
if (dataIndex === 'name') {
navigate(
`/resources/clusters/detail?id=${record.id}&name=${record.name}&page=clusters`
);
}
});
useEffect(() => {
const fetchCredentialList = async () => {
const data = await queryCredentialList({ page: -1 });
@@ -366,7 +362,7 @@ const Clusters: React.FC = () => {
);
};
const columns = useClusterColumns(handleSelect, handleOnCell);
const columns = useClusterColumns(handleSelect);
return (
<>
@@ -420,7 +416,7 @@ const Clusters: React.FC = () => {
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-cluster-outline" />}
filters={_.omit(queryParams, ['sort_by'])}
filters={_.omit(queryParams, ['sort_by', 'mine'])}
noFoundText={intl.formatMessage({
id: 'noresult.cluster.nofound'
})}
@@ -482,6 +478,7 @@ const Clusters: React.FC = () => {
onClose={handleClusterModalClose}
></ClusterModal>
{AddWorkerModal}
{AccessDrawer && <AccessDrawer />}
</>
);
};
@@ -1,3 +1,5 @@
import { ExclamationCircleFilled } from '@ant-design/icons';
import { AlertBlockInfo } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Typography } from 'antd';
import RegisterClusterInner from '../register-cluster-inner';
@@ -34,6 +36,14 @@ const K8sRunCommand: React.FC<AddWorkerStepProps> = ({ disabled }) => {
id: 'clusters.create.addCommand.k8s.tips'
})}
</Typography.Paragraph>
<AlertBlockInfo
type="warning"
style={{ marginBottom: 8 }}
icon={<ExclamationCircleFilled />}
message={intl.formatMessage({
id: 'clusters.create.addCommand.k8s.version.warning'
})}
></AlertBlockInfo>
<RegisterClusterInner
registrationInfo={registrationInfo}
currentGPU={currentGPU}
@@ -59,6 +59,12 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]);
const [submitAttempted, setSubmitAttempted] = useState(false);
// Single source of truth for the K8s cluster type, seeded from the cluster
// being edited. Shared via FormContext so the type selector and the
// GPU-only fields stay in sync deterministically (no cross-component watch).
const [clusterType, setClusterType] = useState<'model' | 'gpu'>(() =>
currentData?.k8s_options?.gpuInstanceOptions ? 'gpu' : 'model'
);
const advanceConfigRef = React.useRef<any>(null);
const systemConfig = useAtomValue(systemConfigAtom);
@@ -87,6 +93,20 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const next: any = { ...opts };
// "model" clusters must not carry GPU-instance config. The field's UI is
// unmounted when model is selected, but strip it here too so the payload
// never keeps a stale gpuInstanceOptions shape from a prior "gpu" choice.
if (clusterType === 'model') {
next.gpuInstanceOptions = null;
} else if (clusterType === 'gpu' && !next.gpuInstanceOptions) {
// gpuInstanceOptions has no always-mounted Form.Item (its only child,
// the optional static address, may be unmounted or empty), so with
// preserve={false} onFinish's `values` can omit it. Read it straight
// from the store so a "gpu" cluster always carries the field.
next.gpuInstanceOptions =
form.getFieldValue(['k8s_options', 'gpuInstanceOptions']) ?? {};
}
const creds = opts.imageCredentials;
if (Array.isArray(creds)) {
next.imageCredentials = creds.map((c: any) => ({
@@ -220,7 +240,9 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
};
return (
<FormContext.Provider value={{ submitAttempted }}>
<FormContext.Provider
value={{ submitAttempted, clusterType, setClusterType }}
>
<Form
name="clusterForm"
form={form}
@@ -2,10 +2,12 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { Form } from 'antd';
import _ from 'lodash';
import React, { useEffect, useId, useMemo } from 'react';
import styled from 'styled-components';
import { useFormContext } from '../config/form-context';
import { useStepsContext } from '../config/steps-context';
import { ClusterListItem as ListItem } from '../config/types';
import ImageCredential from './image-credential';
@@ -68,13 +70,6 @@ export const OperatorImageForm: React.FC = () => {
);
};
// The presence of `gpuInstanceOptions` on `k8s_options` is the source of truth
// for whether GPU instances are enabled. Both the cluster-type selector
// (rendered up top) and the static-address field (rendered in the advanced
// section) watch this same path so they stay in sync without sharing local
// state.
const GPU_INSTANCE_OPTIONS_PATH = ['k8s_options', 'gpuInstanceOptions'];
// Visual parity with @gpustack/core-ui's SwitchCard so the selector blends
// in with surrounding form fields: same border, radius, padding, and
// typography. The only differences are the two-column grid layout and an
@@ -101,6 +96,7 @@ const ClusterTypeGrid = styled.div`
`;
const ClusterTypeCard = styled.div<{ $active: boolean }>`
position: relative;
display: flex;
align-items: flex-start;
gap: 10px;
@@ -140,6 +136,17 @@ const ClusterTypeCard = styled.div<{ $active: boolean }>`
}
`;
const ExperimentalTag = styled.span`
position: absolute;
right: 2px;
top: 2px;
padding: 2px;
border-radius: 2px;
font-size: 10px;
font-weight: 400;
background-color: var(--ant-blue-1);
`;
const RadioDot = styled.span<{ $active: boolean }>`
position: relative;
flex-shrink: 0;
@@ -169,37 +176,30 @@ const RadioDot = styled.span<{ $active: boolean }>`
// Card-based selector for cluster type. The two options are mutually exclusive
// and the choice maps directly to the presence/absence of `gpuInstanceOptions`
// on the form — "model" clears it, "gpu" seeds it to {} (preserving any
// already-entered static address). No standalone form field is registered;
// state is read via useWatch with `preserve: true` so it tracks updates made
// through setFieldValue.
// on the submitted payload. No standalone form field is registered; the click
// only updates the shared `clusterType` state (see FormContext) — the payload's
// `gpuInstanceOptions` shape is derived from it at submit (see cluster-form's
// normalizeOutgoing), and the static-address field mounts/unmounts off it.
export const ClusterTypeSelector: React.FC = () => {
const intl = useIntl();
const form = Form.useFormInstance();
const { presetClusterType } = useStepsContext();
const labelId = useId();
const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
form,
preserve: true
});
const value: 'model' | 'gpu' = gpuInstanceOptions ? 'gpu' : 'model';
// Cluster type is shared, explicit state (see FormContext): the click is the
// source of truth. This replaced a Form.useWatch on an unregistered path that
// did not re-render reliably when cleared to undefined.
const { clusterType, setClusterType } = useFormContext();
const value: 'model' | 'gpu' = clusterType ?? 'model';
const handleSelect = (next: 'model' | 'gpu') => {
if (!form || next === value) return;
if (next === 'gpu') {
form.setFieldValue(
GPU_INSTANCE_OPTIONS_PATH,
form.getFieldValue(GPU_INSTANCE_OPTIONS_PATH) ?? {}
);
} else {
form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined);
}
};
const handleSelect = useMemoizedFn((next: 'model' | 'gpu') => {
if (next === value) return;
setClusterType?.(next);
});
const options: {
key: 'model' | 'gpu';
title: string;
description: string;
experimental?: boolean;
}[] = [
{
key: 'model',
@@ -209,7 +209,8 @@ export const ClusterTypeSelector: React.FC = () => {
{
key: 'gpu',
title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }),
description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' })
description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' }),
experimental: true
}
];
@@ -244,6 +245,11 @@ export const ClusterTypeSelector: React.FC = () => {
}}
>
<RadioDot $active={active} />
{opt.experimental && (
<ExperimentalTag>
{intl.formatMessage({ id: 'common.tag.experimental' })}
</ExperimentalTag>
)}
<div className="body">
<div className="title">{opt.title}</div>
<div className="description">{opt.description}</div>
@@ -261,14 +267,13 @@ export const ClusterTypeSelector: React.FC = () => {
// default container registry and the worker config (节点配置).
export const GpuInstancesStaticAddressForm: React.FC = () => {
const intl = useIntl();
// See note in ClusterTypeSelector: watch the full store so this field's
// visibility tracks the selector even before it has mounted its own
// Form.Item.
const enabled = !!Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
preserve: true
});
// Visibility tracks the shared cluster-type state (see FormContext), so this
// field mounts/unmounts deterministically with the selector. Its Form.Item is
// the only thing keeping gpuInstanceOptions alive, so unmounting it here (with
// the form's preserve={false}) also clears that path from the store.
const { clusterType } = useFormContext();
if (!enabled) {
if (clusterType !== 'gpu') {
return null;
}
@@ -5,6 +5,12 @@ import { ClusterListItem } from './types';
interface FormContextProps {
currentData?: ClusterListItem;
submitAttempted?: boolean;
// K8s cluster type. `gpuInstanceOptions` on the form is derived from this —
// the selector, the static-address field, and submit all read this single
// source of truth instead of independently watching the (unregistered) form
// path, which did not re-render reliably.
clusterType?: 'model' | 'gpu';
setClusterType?: (type: 'model' | 'gpu') => void;
}
export const FormContext = createContext<FormContextProps>({});
+2 -1
View File
@@ -58,7 +58,7 @@ const Credentials: React.FC = () => {
key: PaginationKey.Credentials,
fetchAPI: queryCredentialList,
deleteAPI: deleteCredential,
contentForDelete: 'menu.clusterManagement.credentials'
contentForDelete: 'menu.resources.credentials'
});
const [, setClusterSession] = useAtom(clusterSessionAtom);
const [isFromCluster, setIsFromCluster] = useAtom(fromClusterCreationAtom);
@@ -157,6 +157,7 @@ const Credentials: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={[]}
@@ -13,7 +13,7 @@ import {
type TableColumnProps as SealColumnProps
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Tooltip, Typography } from 'antd';
import { Tooltip } from 'antd';
import dayjs from 'dayjs';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
@@ -85,21 +85,15 @@ const clusterActionList = [
];
const useClusterColumns = (
handleSelect: (val: string, record: ClusterListItem, item?: any) => void,
onCellClick?: (record: ClusterListItem, dataIndex: string) => void
handleSelect: (val: string, record: ClusterListItem, item?: any) => void
): SealColumnProps[] => {
const intl = useIntl();
const systemConfig = useAtomValue(systemConfigAtom);
const pluginCols = usePluginListColumns('clusters');
// The cluster-detail page is shipped in OSS source, but OSS keeps
// it unreachable from the cluster list — the link is only
// surfaced when a plugin opts in via
// `clusterDetail.linkableName`. Without a plugin we render the
// name as plain text (matches the pre-restore behaviour); with one
// we use Typography.Link wired to the parent's `onCellClick`.
const { linkableName: nameLinkable, useGenerateActions } =
getGPUStackPlugin()?.clusterDetail || {};
// The cluster name is plain text: there is no cluster-detail page
// to route into. A plugin may still contribute extra row actions
// (topology, Cluster Access) via `clusterDetail.useGenerateActions`.
const { useGenerateActions } = getGPUStackPlugin()?.clusterDetail || {};
const actionList =
useGenerateActions?.({ actions: clusterActionList }) || clusterActionList;
@@ -160,13 +154,7 @@ const useClusterColumns = (
render: (text: string, record: ClusterListItem) => (
<>
<AutoTooltip ghost title={text}>
{nameLinkable ? (
<Typography.Link onClick={() => onCellClick?.(record, 'name')}>
{record.name}
</Typography.Link>
) : (
<span className="text-primary">{record.name}</span>
)}
<span className="text-primary">{record.name}</span>
</AutoTooltip>
{record.is_default && (
<Tooltip
@@ -258,7 +246,7 @@ const useClusterColumns = (
)
}
];
}, [handleSelect, onCellClick, intl, pluginCols]);
}, [handleSelect, intl, pluginCols]);
};
export default useClusterColumns;
@@ -22,7 +22,12 @@ export const useQueryClusterList = (options?: { useStateData?: boolean }) => {
loading,
cancel
} = useRequest(
async (params: { page: number; perPage?: number }) => {
async (params: {
page: number;
perPage?: number;
mine?: boolean;
gpu_instance_enabled?: boolean;
}) => {
axiosTokenRef.current?.cancel();
axiosTokenRef.current = createAxiosToken();
const res = await queryClusterList(params, {
@@ -18,10 +18,17 @@ const BasicForm = forwardRef((props: BasicFormProps, ref) => {
console.log(values);
};
// Forward through to the live ClusterForm ref on each call instead of
// snapshotting its methods. ClusterForm rebuilds these closures whenever its
// internal state (e.g. clusterType) changes, but BasicForm does not re-render
// with it — a frozen snapshot would keep calling stale closures (reading the
// initial clusterType) and be null on the first render before the ref attaches.
useImperativeHandle(ref, () => ({
validateFields: formRef.current?.validateFields,
getFieldsValue: formRef.current?.getFieldsValue,
submit: formRef.current?.submit
validateFields: (...args: any[]) =>
formRef.current?.validateFields(...args),
getFieldsValue: (...args: any[]) =>
formRef.current?.getFieldsValue(...args),
submit: (...args: any[]) => formRef.current?.submit(...args)
}));
return (
+42 -25
View File
@@ -2,6 +2,20 @@ import {
BreakdownItem,
UsageBreakdownResponse
} from '@/pages/usage/config/types';
import { withDeletedMark } from '@/pages/usage/utils/deleted-label';
import { getIntl } from '@umijs/max';
// group dimension → the id field inside ``identity.current`` (the backend nulls
// it for deleted entities, so the marker degrades to just "[Deleted]").
const GROUP_ID_KEY: Record<
UsageGroupBy,
'model_id' | 'route_id' | 'user_id' | 'api_key_id'
> = {
model: 'model_id',
route: 'route_id',
user: 'user_id',
api_key: 'api_key_id'
};
export const overviewConfigs = [
{
@@ -88,6 +102,7 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
return groupItem;
}
let baseLabel: string;
if (groupBy === 'model') {
const providerName =
identityValue?.provider_name || groupValue?.provider_name;
@@ -97,36 +112,38 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
rawItem.model_name ||
rawItem.model;
if (providerName && modelName) {
return `${providerName}/${modelName}`;
}
return groupItem?.label || modelName || '-';
}
if (groupBy === 'route') {
return (
baseLabel =
providerName && modelName
? `${providerName}/${modelName}`
: groupItem?.label || modelName || '-';
} else if (groupBy === 'route') {
baseLabel =
groupItem?.label ||
identityValue?.route_name ||
groupValue?.route_name ||
'-'
);
'-';
} else {
baseLabel =
groupItem?.label ||
identityValue?.user_name ||
identityValue?.api_key_name ||
identityValue?.access_key ||
groupValue?.user_name ||
groupValue?.api_key_name ||
groupValue?.access_key ||
rawItem.user_name ||
rawItem.api_key_name ||
rawItem.access_key ||
rawItem[groupBy] ||
'-';
}
return (
groupItem?.label ||
identityValue?.user_name ||
identityValue?.api_key_name ||
identityValue?.access_key ||
groupValue?.user_name ||
groupValue?.api_key_name ||
groupValue?.access_key ||
rawItem.user_name ||
rawItem.api_key_name ||
rawItem.access_key ||
rawItem[groupBy] ||
'-'
);
// Mark deleted entities in the chart legend / tooltip as text (a legend can't
// render a tag), matching the usage tabs. The id degrades to just "[Deleted]"
// when the backend nulls ``identity.current`` for a deleted entity.
const deletedWord = getIntl().formatMessage({ id: 'usage.table.deleted' });
const id = groupItem?.identity?.current?.[GROUP_ID_KEY[groupBy]];
return withDeletedMark(baseLabel, groupItem?.deleted, deletedWord, id);
};
export const getUsageResponseItems = (
@@ -77,7 +77,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
}, [resourceAtom, loadingStatus]);
const contentInfo = useMemo(() => {
if (!resourceCount.cluster_count) {
if (!resourceCount?.cluster_count) {
return {
title: intl.formatMessage({ id: 'noresult.cluster.title' }),
subTitle: intl.formatMessage({ id: 'noresult.resources.cluster' }),
@@ -98,7 +98,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
const handleCreate = () => {
setHideModalTemporarily(true);
onCreated?.();
if (!resourceCount.cluster_count) {
if (!resourceCount?.cluster_count) {
setClusterSession({
firstAddWorker: false,
firstAddCluster: true
@@ -6,8 +6,8 @@ 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.
* (gated on `canSeeAdmin`) and when a plugin provides its own
* `OwnerScopeTag` slot.
*/
const OwnerTag: React.FC<{ ownerId?: number | null }> = ({ ownerId }) => {
const intl = useIntl();
@@ -10,12 +10,18 @@ import useOverlayLayout from '../hooks/use-overlay-layout';
interface StorageOverlayProps {
open: boolean;
// Org the surrounding instance create form targets (platform admin "All"
// view). The storage inherits this scope, so the type list is pinned to
// it — the picker only offers types that org can reference. Undefined
// when there's no create-scope picker (the ambient org context applies).
scopeOrgId?: number | null;
onCancel: () => void;
onSubmit: (values: StorageFormData) => Promise<void> | void;
}
const StorageOverlay: React.FC<StorageOverlayProps> = ({
open,
scopeOrgId,
onCancel,
onSubmit
}) => {
@@ -28,9 +34,14 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
useEffect(() => {
if (open) {
fetchStorageClass({ page: -1 });
fetchStorageClass(
{ page: -1 },
scopeOrgId != null
? { headers: { 'X-Organization-Id': String(scopeOrgId) } }
: undefined
);
}
}, [open]);
}, [open, scopeOrgId]);
const handleSubmit = () => {
formRef.current?.submit();
@@ -75,6 +86,7 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
ref={formRef}
action={PageAction.CREATE}
open={open}
showOrgScope={false}
onFinish={handleFinish}
/>
</FormContext.Provider>
@@ -30,6 +30,10 @@ const StorageVolume = ({
const { getRuleMessage } = useAppUtils();
const form = Form.useFormInstance<FormData>();
const storageMode = Form.useWatch('storageMode', form);
// Owned by the instance create-scope picker (platform admin "All" view).
// A storage added inline belongs to the same org as the instance, so pass
// it to the overlay to scope the storage-type list to that org.
const scopeOrgId = Form.useWatch('organization_id', form);
const { fetchData: createStorage } = useCreateStorage();
const { detailData: storageData, fetchData: fetchStorage } =
useQueryStorage();
@@ -232,6 +236,7 @@ const StorageVolume = ({
<StorageOverlay
open={overlayOpen}
scopeOrgId={scopeOrgId}
onCancel={() => setOverlayOpen(false)}
onSubmit={handleCreateStorage}
/>
@@ -271,6 +271,7 @@ const GPUService: React.FC = () => {
if (!clusterLoading && !hasK8sCluster) {
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading || clusterLoading}
loadend={dataSource.loadend}
dataSource={[]}
@@ -294,6 +295,7 @@ const GPUService: React.FC = () => {
}
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
@@ -110,6 +110,7 @@ const GPUServicePublicKeys: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
@@ -1,6 +1,23 @@
import { StatusMaps } from '@/config';
import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui';
import { StorageTypeKind } from './types';
export const StorageTypePhaseValueMap = {
Ready: 'Ready',
Deleting: 'Deleting'
};
export const StorageTypePhaseLabelMap: Record<string, string> = {
[StorageTypePhaseValueMap.Ready]: 'Ready',
[StorageTypePhaseValueMap.Deleting]: 'Deleting'
};
export const status: Record<string, StatusType> = {
[StorageTypePhaseValueMap.Ready]: StatusMaps.success,
[StorageTypePhaseValueMap.Deleting]: StatusMaps.warning
};
export const StorageTypeKindValueMap: Record<string, StorageTypeKind> = {
NFS: 'nfs',
S3: 's3'
@@ -44,4 +44,8 @@ export interface ListItem {
nfs?: StorageTypeNFS | null;
s3?: Omit<StorageTypeS3, 'secretKey'> | null;
};
status?: {
phase?: string | null;
phaseMessage?: string | null;
} | null;
}
@@ -5,13 +5,19 @@ import {
AutoTooltip,
DropdownButtons,
IconFont,
StatusTag,
ThemeTag
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { rowActionList, StorageTypeKindLabelMap } from '../config';
import {
rowActionList,
status,
StorageTypeKindLabelMap,
StorageTypePhaseLabelMap
} from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
@@ -81,6 +87,24 @@ const useStorageTypeColumns = ({
sorter: false,
render: (_text, record) => getKindLabel(record)
},
{
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: ['status', 'phase'],
key: 'status',
sorter: false,
render: (value: string, record: ListItem) =>
value ? (
<StatusTag
statusValue={{
status: status[value],
text: StorageTypePhaseLabelMap[value] || value,
message: record?.status?.phaseMessage || ''
}}
></StatusTag>
) : (
'-'
)
},
...creatorCols,
// {
// title: intl.formatMessage({ id: 'common.table.description' }),
@@ -104,6 +104,7 @@ const GPUServiceStorageTypes: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
@@ -51,6 +51,11 @@ export async function queryStorageClass(
return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, {
method: 'GET',
params,
// `headers` lets the create form pin the request to a specific org so
// the picker only offers storage types that org can reference. GETs
// otherwise inherit the ambient org context, which is empty in the
// platform-admin "All" view and would list every org's types.
headers: options?.headers,
cancelToken: options?.token
});
}
@@ -1,10 +1,11 @@
import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock';
import { FormDrawer, ModalFooter } from '@gpustack/core-ui';
import { useRef } from 'react';
import { useEffect, useRef } from 'react';
import { FormContext } from '../config/form-context';
import { FormData, ListItem } from '../config/types';
import GPUServiceStorageForm from '../forms';
import useQueryStorageClass from '../services/use-query-storage-class';
type AddModalProps = {
title: string;
@@ -13,7 +14,6 @@ type AddModalProps = {
onOk: (values: FormData) => void;
data?: ListItem | null;
onCancel: () => void;
storageClassList: Global.BaseOption<string>[];
};
const AddModal: React.FC<AddModalProps> = ({
@@ -22,11 +22,33 @@ const AddModal: React.FC<AddModalProps> = ({
open,
onOk,
data,
onCancel,
storageClassList
onCancel
}) => {
const form = useRef<any>(null);
const { loading, guard, run, release } = useSubmitLock();
// The dropdown gets its own storage-type list, scoped to the org the
// create-scope picker targets — distinct from the page-level list, which
// stays unscoped so the table can label every org's rows.
const { storageClassList, fetchData: fetchStorageClass } =
useQueryStorageClass();
useEffect(() => {
if (open) {
fetchStorageClass({ page: -1 });
}
}, [open]);
// Platform admin picked a target org in the create-scope slot: reload the
// storage-type list pinned to that org so the dropdown only offers types
// the org can reference (its own plus any reachable via cluster access).
const handleScopeChange = (orgId?: number | null) => {
fetchStorageClass(
{ page: -1 },
orgId != null
? { headers: { 'X-Organization-Id': String(orgId) } }
: undefined
);
};
const handleSubmit = () => {
guard(() => form.current?.submit());
@@ -70,6 +92,7 @@ const AddModal: React.FC<AddModalProps> = ({
ref={form}
action={action}
currentData={data}
onScopeChange={handleScopeChange}
onFinish={onFinish}
onFinishFailed={release}
open={open}
@@ -3,21 +3,18 @@ import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui';
export const StoragePhaseValueMap = {
Available: 'Available',
Unavailable: 'Unavailable',
Pending: 'Pending'
Ready: 'Ready',
Deleting: 'Deleting'
};
export const StoragePhaseLabelMap: Record<string, string> = {
[StoragePhaseValueMap.Available]: 'Available',
[StoragePhaseValueMap.Unavailable]: 'Unavailable',
[StoragePhaseValueMap.Pending]: 'Pending'
[StoragePhaseValueMap.Ready]: 'Ready',
[StoragePhaseValueMap.Deleting]: 'Deleting'
};
export const status: Record<string, StatusType> = {
[StoragePhaseValueMap.Available]: StatusMaps.success,
[StoragePhaseValueMap.Unavailable]: StatusMaps.error,
[StoragePhaseValueMap.Pending]: StatusMaps.transitioning
[StoragePhaseValueMap.Ready]: StatusMaps.success,
[StoragePhaseValueMap.Deleting]: StatusMaps.warning
};
export const rowActionList = [
@@ -33,6 +33,7 @@ export interface ListItem {
};
status?: {
phase?: string | null;
phaseMessage?: string | null;
} | null;
}
+23 -2
View File
@@ -12,7 +12,23 @@ import { useContext } from 'react';
import { FormContext } from '../config/form-context';
import { FormData } from '../config/types';
const Basic = ({ action, open }: { action: string; open: boolean }) => {
const Basic = ({
action,
open,
showOrgScope = true,
onOrgScopeChange
}: {
action: string;
open: boolean;
// Hosts that already fix the tenant scope elsewhere (e.g. the instance
// create form, which owns the org picker) hide this slot so the storage
// is created in the surrounding scope rather than a second, conflicting
// one.
showOrgScope?: boolean;
// Fired on a user selection in the create-scope picker so the host can
// reload the org-scoped storage-type list.
onOrgScopeChange?: (orgId: number | null | undefined) => void;
}) => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
const { storageClassList } = useContext(FormContext);
@@ -44,7 +60,12 @@ const Basic = ({ action, open }: { action: string; open: boolean }) => {
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
{showOrgScope && (
<PluginExtraFields
name="CreateOrgScopeField"
context={{ action, onChange: onOrgScopeChange }}
/>
)}
<Flex gap={16}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
+62 -3
View File
@@ -1,7 +1,8 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { useMemoizedFn } from 'ahooks';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { FormData, ListItem } from '../config/types';
import Basic from './basic';
@@ -10,14 +11,67 @@ interface StorageFormProps {
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
// Hide the create-scope org picker when the host already fixes the
// tenant scope (e.g. the instance create form). Defaults to shown.
showOrgScope?: boolean;
// Fired when the create-scope picker (platform admin "All" view)
// retargets the form to another org. Only emitted on genuine changes,
// never on the initial mount, and never in builds where the picker
// isn't mounted (the watched field stays undefined). Lets the parent
// reload the org-scoped storage-type options.
onScopeChange?: (orgId: number | null | undefined) => void;
onFinish: (values: FormData) => Promise<void>;
onFinishFailed?: (errorInfo: any) => void;
}
const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
(props, ref) => {
const { action, currentData, open, onFinish, onFinishFailed } = props;
const {
action,
currentData,
open,
showOrgScope = true,
onScopeChange,
onFinish,
onFinishFailed
} = props;
const [form] = Form.useForm<FormData>();
// `organization_id` is owned by the create-scope picker slot; it only
// exists/changes when a platform admin retargets the form. Watch it so
// the initial/default scope can be propagated once the picker resolves it.
const scopeOrgId = Form.useWatch('organization_id', form);
const scopeInitRef = useRef(true);
// Stable wrapper so the effect / change handler always call the latest
// callback without re-subscribing on the parent's fn identity.
const orgScope = useMemoizedFn((orgId?: number | null) => {
onScopeChange?.(orgId);
});
// Genuine retarget by the create-scope picker: drop the stale type pick
// (it may name a type the newly chosen org can't reference) and reload the
// org-scoped list. Wired to the picker's own onChange, so it fires only on
// a user selection — never on the picker's programmatic default.
const handleOrgScopeChange = useMemoizedFn((orgId?: number | null) => {
scopeInitRef.current = false;
form.setFieldValue(['spec', 'type'], undefined);
orgScope(orgId ?? null);
});
useEffect(() => {
if (!open) {
// Re-arm the initial-scope propagation for the next open.
scopeInitRef.current = true;
return;
}
// Propagate the initial/default scope once the picker resolves it so the
// parent loads the org-scoped type list, leaving any pre-filled type
// intact. User-driven retargets go through handleOrgScopeChange instead.
if (scopeInitRef.current && scopeOrgId != null) {
scopeInitRef.current = false;
orgScope(scopeOrgId);
}
}, [open, scopeOrgId, orgScope]);
useEffect(() => {
if (!open) {
@@ -56,7 +110,12 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
preserve={false}
initialValues={{}}
>
<Basic action={action} open={open} />
<Basic
action={action}
open={open}
showOrgScope={showOrgScope}
onOrgScopeChange={handleOrgScopeChange}
/>
</Form>
);
}
@@ -1,11 +1,11 @@
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import { AutoTooltip, DropdownButtons, StatusTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { rowActionList } from '../config';
import { rowActionList, status, StoragePhaseLabelMap } from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
@@ -70,24 +70,25 @@ const useStorageColumns = ({
sorter: false,
render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-')
},
{
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: ['status', 'phase'],
key: 'status',
sorter: false,
render: (value: string, record: ListItem) =>
value ? (
<StatusTag
statusValue={{
status: status[value],
text: StoragePhaseLabelMap[value] || value,
message: record?.status?.phaseMessage || ''
}}
></StatusTag>
) : (
'-'
)
},
...creatorCols,
// {
// title: intl.formatMessage({ id: 'common.table.status' }),
// dataIndex: ['status', 'phase'],
// key: 'status',
// sorter: false,
// render: (value: string) =>
// value ? (
// <StatusTag
// statusValue={{
// status: status[value],
// text: StoragePhaseLabelMap[value] || value
// }}
// ></StatusTag>
// ) : (
// '-'
// )
// },
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
+1 -1
View File
@@ -109,6 +109,7 @@ const GPUServiceStorage: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
@@ -177,7 +178,6 @@ const GPUServiceStorage: React.FC = () => {
action={openStorageModalStatus.action}
title={openStorageModalStatus.title}
data={openStorageModalStatus.currentData}
storageClassList={storageClassList}
onCancel={closeStorageModal}
onOk={handleModalOk}
/>
@@ -28,7 +28,7 @@ import {
TemplateCard,
ThemeTag
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useAccess, useIntl } from '@umijs/max';
import { Button, Tag } from 'antd';
import { useMemo } from 'react';
import styled from 'styled-components';
@@ -144,8 +144,26 @@ interface TemplateCardProps {
const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
const intl = useIntl();
const access = useAccess();
const { isDarkTheme } = useUserSettings();
// Only an explicit NULL owner (Global, admin-curated) is admin-only.
// A principal-owned row reaches a non-admin's list only when they own
// it, and an absent id (single-owner builds omit it on the wire) has
// no tenancy to restrict — both are manageable, so the check is strict
// ``!== null``. Clone stays available regardless — it reads the source
// and creates a fresh copy in the caller's own scope — so a non-admin
// can fork a Global preset into their org.
const canManage = !!access.canSeeAdmin || data.owner_principal_id !== null;
const actions = useMemo(
() =>
canManage
? templateActions
: templateActions.filter((action) => action.key === 'clone'),
[canManage]
);
const manufacturerLabelMap: Record<string, string> = useMemo(() => {
return Object.values(GPUsConfigs).reduce(
(acc, item) => {
@@ -238,7 +256,7 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
<span onClick={handleonClickAction} className="operations">
<DropdownActions
menu={{
items: templateActions,
items: actions,
onClick: handleOnSelect
}}
>
@@ -114,6 +114,12 @@ export const templateActions: Array<{
locale: true,
icon: icons.EditOutlined
},
{
label: 'common.button.clone',
key: 'clone',
locale: true,
icon: icons.CopyOutlined
},
{
label: 'common.button.delete',
key: 'delete',
@@ -1,4 +1,3 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
@@ -26,11 +25,13 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
return;
}
if (action === PageAction.EDIT && currentData) {
// Prefill on Edit and on Clone (Create carrying a source row).
// A plain Create opens with no ``currentData`` and keeps the
// blank ``initialValues``.
if (currentData) {
form.setFieldsValue({
...currentData
});
return;
}
}, [action, currentData, form, open]);
+46 -7
View File
@@ -45,13 +45,11 @@ const GPUServiceTemplates: React.FC = () => {
isInfiniteScroll: true,
contentForDelete: intl.formatMessage({ id: 'gpuservice.template' }),
defaultQueryParams: {
perPage: 24,
// Management view: drop Global rows for non-admin callers — the
// page is a CRUD surface, and admin-curated Global templates
// they can't edit only add visual noise. The instance-create
// picker (uses ``useQueryTemplates`` separately) doesn't set
// this and so still sees Global presets.
mine: true
// Default (non-``mine``) scope: Global rows plus rows owned by the
// caller's current principal, matching the Inference Backend page.
// Global rows a non-admin can't edit render read-only — the card
// gates Edit/Delete on ownership.
perPage: 24
}
});
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
@@ -92,6 +90,42 @@ const GPUServiceTemplates: React.FC = () => {
);
};
const handleCloneTemplate = (row: ListItem) => {
// Clone reuses the create flow: drop the source's identity and
// ownership so the backend assigns a fresh id and scopes the copy
// to the caller's own principal. The prefilled name is editable —
// the create endpoint enforces per-owner name uniqueness.
const source = _.omit(row, [
'id',
'owner_principal_id',
'creator_id',
'created_at',
'updated_at',
'status'
]) as ListItem;
// Default to a ``-clone`` suffix so cloning within the same scope
// (e.g. a Global preset kept Global) doesn't immediately collide on
// the unique name. Keep within the 63-char name limit by trimming
// the base first.
const suffix = '-clone';
const base = (row.name ?? '').slice(0, 63 - suffix.length);
source.name = `${base}${suffix}`;
// The card renders ``displayName || name``, so a copied displayName
// would make the clone indistinguishable from its source. Append a
// localized clone label, trimmed to the same 63-char field limit.
if (row.displayName) {
const cloneLabel = intl.formatMessage({ id: 'common.button.clone' });
const displaySuffix = ` (${cloneLabel})`;
const displayBase = row.displayName.slice(0, 63 - displaySuffix.length);
source.displayName = `${displayBase}${displaySuffix}`;
}
openTemplateModal(
PageAction.CREATE,
intl.formatMessage({ id: 'gpuservice.template.clone' }),
source
);
};
const handleModalOk = async (data: FormData) => {
try {
if (openTemplateModalStatus.action === PageAction.EDIT) {
@@ -114,6 +148,10 @@ const GPUServiceTemplates: React.FC = () => {
handleEditTemplate(item.data);
return;
}
if (item.action === 'clone') {
handleCloneTemplate(item.data);
return;
}
if (item.action === 'delete') {
handleDelete({ ...item.data, name: item.data.name });
}
@@ -172,6 +210,7 @@ const GPUServiceTemplates: React.FC = () => {
onSelect={handleOnSelect}
/>
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
+1
View File
@@ -166,6 +166,7 @@ const Catalog: React.FC = () => {
isFirst={!dataSource.loadend}
></CatalogList>
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
+23 -29
View File
@@ -1,6 +1,7 @@
import { convertFileSize } from '@/utils';
import { AutoTooltip } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex } from 'antd';
import React from 'react';
import styled from 'styled-components';
import '../style/gpu-card.less';
@@ -19,12 +20,14 @@ const Header = styled.div`
width: 100%;
`;
const ItemInfo = styled.div`
display: flex;
flex-wrap: wrap;
white-space: break-spaces;
font-size: 13px;
`;
const Metric: React.FC<{ label: React.ReactNode; value: React.ReactNode }> = ({
label,
value
}) => (
<span style={{ lineHeight: 1.2 }}>
{label}: <span>{value}</span>
</span>
);
const Description = styled.div`
display: flex;
@@ -64,29 +67,20 @@ const GPUCard: React.FC<{
}
description={
info || (
<>
<ItemInfo>
{intl.formatMessage({ id: 'resources.table.vram' })}(
{intl.formatMessage({ id: 'resources.table.used' })}/
{intl.formatMessage({ id: 'resources.table.total' })}):{' '}
<span>
{convertFileSize(
data?.memory?.used || data?.memory?.allocated || 0
)}{' '}
/ {convertFileSize(data?.memory?.total || 0)}
</span>
</ItemInfo>
{/* <ItemInfo>
<span>
{intl.formatMessage({ id: 'resources.table.gpuutilization' })}
:{' '}
</span>
{data?.memory?.used
? _.round(data?.memory?.utilization_rate || 0, 2)
: _.round(data.memory?.allocated / data.memory?.total, 2) * 100}
%
</ItemInfo> */}
</>
<Flex wrap gap={8} style={{ fontSize: 11 }}>
<Metric
label={intl.formatMessage({ id: 'resources.table.total' })}
value={convertFileSize(data?.memory?.total || 0)}
/>
<Metric
label={intl.formatMessage({ id: 'resources.table.used' })}
value={convertFileSize(data?.memory?.used || 0)}
/>
<Metric
label={intl.formatMessage({ id: 'resources.table.allocated' })}
value={convertFileSize(data?.memory?.allocated || 0)}
/>
</Flex>
)
}
></CardContainer>
+28 -9
View File
@@ -1,4 +1,5 @@
import {
IconFont,
StatusTag,
TagsWrapper,
TemplateCard,
@@ -44,7 +45,7 @@ const ModelItemContent = styled.div`
flex-direction: column;
height: 100%;
width: 100%;
cursor: default;
cursor: pointer;
.content {
display: flex;
justify-content: space-between;
@@ -85,8 +86,8 @@ const ModelItemContent = styled.div`
`;
const ModelLogo = styled.img`
width: 24px;
height: 24px;
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: contain;
flex: none;
@@ -127,15 +128,23 @@ const renderTag = (item: any, index = 0) => {
const ModelItem: React.FC<{
model: Record<string, any>;
onClick?: (model: Record<string, any>) => void;
}> = (props) => {
const { model } = props;
const { model, onClick } = props;
const intl = useIntl();
const navigate = useNavigate();
const handleCardClick = () => {
onClick?.(model);
};
// ``model.name`` from ``/v2/my-models`` is the OpenAI-style id
// (org-prefixed for non-platform routes, bare for platform). Use it
// verbatim — the playground / dispatcher both key off that exact id.
const handleOpenPlayGroundClick = () => {
const handleOpenPlayGroundClick = (e: React.MouseEvent) => {
// Card is clickable (opens API access info); keep the playground
// action isolated so it doesn't also trigger the card click.
e.stopPropagation();
const modelName = encodeURIComponent(model.name);
for (const [category, path] of Object.entries(categoryToPathMap)) {
if (
@@ -178,12 +187,13 @@ const ModelItem: React.FC<{
<CardWrapper>
<TemplateCard
height={140}
clickable={false}
clickable={true}
hoverable={true}
ghost
onClick={handleCardClick}
header={
<Header>
<span className="text gap-8">
<span className="text gap-16">
<ModelLogo src={getModelLogo(model.name)} alt="" />
<span>{model.name}</span>
</span>
@@ -239,14 +249,23 @@ const ModelItem: React.FC<{
</>
)}
</div>
{[MyModelsStatusValueMap.Active].includes(model.status) && (
{[MyModelsStatusValueMap.Ready].includes(model.status) && (
<Button
size="middle"
type="default"
className="btn"
type="primary"
style={{
borderRadius: 6,
paddingInline: 12
}}
onClick={handleOpenPlayGroundClick}
>
{intl.formatMessage({ id: 'models.openinplayground' })}
<IconFont
type="icon-down2"
rotate={-90}
style={{ marginLeft: 4 }}
></IconFont>
</Button>
)}
</div>
+9 -9
View File
@@ -196,21 +196,21 @@ export const status: any = {
};
export const MyModelsStatusValueMap = {
Inactive: 'stopped',
Degrade: 'not_ready',
Active: 'ready'
Stopped: 'stopped',
NotReady: 'not_ready',
Ready: 'ready'
};
export const MyModelsStatusMap = {
[MyModelsStatusValueMap.Inactive]: StatusMaps.inactive,
[MyModelsStatusValueMap.Degrade]: StatusMaps.error,
[MyModelsStatusValueMap.Active]: StatusMaps.success
[MyModelsStatusValueMap.Stopped]: StatusMaps.inactive,
[MyModelsStatusValueMap.NotReady]: StatusMaps.error,
[MyModelsStatusValueMap.Ready]: StatusMaps.success
};
export const MyModelsStatusLabelMap = {
[MyModelsStatusValueMap.Inactive]: 'models.mymodels.status.inactive',
[MyModelsStatusValueMap.Degrade]: 'models.mymodels.status.degrade',
[MyModelsStatusValueMap.Active]: 'models.mymodels.status.active'
[MyModelsStatusValueMap.Stopped]: 'models.mymodels.status.inactive',
[MyModelsStatusValueMap.NotReady]: 'models.mymodels.status.degrade',
[MyModelsStatusValueMap.Ready]: 'models.mymodels.status.active'
};
export const ScheduleValueMap = {
+26 -23
View File
@@ -13,7 +13,7 @@ import {
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import styled from 'styled-components';
import { DeployFormKeyMap, sourceOptions } from '../config';
import { useFormContext } from '../config/form-context';
@@ -24,6 +24,7 @@ import CustomBackend from './custom-backend';
import LocalPathSource from './local-path-source';
import ModeField from './mode-field';
import OnlineSource from './online-source';
const ClusterOption = styled.span`
display: flex;
padding: 8px 0;
@@ -84,6 +85,7 @@ interface BasicFormProps {
}
>[];
handleClusterChange: (value: number) => void;
onClusterSeed: (value: number) => void;
onSourceChange?: (value: string) => void;
}
@@ -93,6 +95,7 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
clusterList,
sourceDisable,
handleClusterChange,
onClusterSeed,
onSourceChange
} = props;
const intl = useIntl();
@@ -116,7 +119,6 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
// cluster dropdown to that org's own clusters — the backend derives the
// deployment's owner from the chosen cluster, so this keeps them aligned.
const scopeOrgId = Form.useWatch('organization_id', form);
const prevScopeRef = useRef<number | null | undefined>(undefined);
const clusterOptions = useMemo(() => {
return clusterList
@@ -139,37 +141,38 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
});
}, [clusterList, scopeOrgId]);
// On a genuine org change (not the initial value — the modal's open
// handler seeds the first cluster), drop a now-out-of-scope cluster and
// re-pick within the new org so GPU/backend options refetch for it.
// Keep the cluster selection consistent with the available (scoped)
// options. The modal's open handler seeds a cluster, but that seed can be
// empty (options not loaded yet) or point outside the current scope (the
// scope id and the option list both settle after mount, and an org switch
// re-scopes the list) — leaving the field blank even though a valid option
// exists. Whenever the scope or the options change, drop an invalid/empty
// selection and fall back to the scope's default cluster (then a Ready one,
// then the first) so GPU/backend options refetch for it. A selection that's
// still valid is left untouched, so a user's (or edit's) choice is kept.
// Use the seed callback (not handleClusterChange) so this auto-pick refreshes
// options without firing an evaluate request before a model is selected.
useEffect(() => {
if (prevScopeRef.current === undefined) {
prevScopeRef.current = scopeOrgId;
if (!clusterOptions?.length) {
return;
}
if (prevScopeRef.current === scopeOrgId) {
return;
}
prevScopeRef.current = scopeOrgId;
const current = form.getFieldValue('cluster_id');
const stillValid = clusterOptions?.some((c) => c.value === current);
if (stillValid) {
const stillValid = clusterOptions.some((c) => c.value === current);
if (current != null && stillValid) {
return;
}
const next =
clusterOptions?.find((c) => c.is_default)?.value ??
clusterOptions?.find((c) => c.state === ClusterStatusValueMap.Ready)
clusterOptions.find((c) => c.is_default)?.value ??
clusterOptions.find((c) => c.state === ClusterStatusValueMap.Ready)
?.value ??
clusterOptions?.[0]?.value ??
clusterOptions[0]?.value ??
null;
form.setFieldValue('cluster_id', next ?? null);
if (next != null) {
handleClusterChange?.(next as number);
if (next == null || next === current) {
return;
}
// The prevScopeRef guard above makes this a no-op unless scopeOrgId
// actually changed, so listing the other deps is safe (no extra runs).
}, [scopeOrgId, clusterOptions, form, handleClusterChange]);
form.setFieldValue('cluster_id', next);
onClusterSeed?.(next);
}, [clusterOptions, form, onClusterSeed]);
const clusterOptionRender = (option: any) => {
const { data } = option;
+18 -2
View File
@@ -236,20 +236,35 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
onOk(allValues);
};
const handleClusterChange = async (value: number) => {
await onClusterChange?.(value);
// Shared work when the target cluster changes: refetch the GPU/backend
// options for the new cluster and reset schedule/gpu selection.
const applyClusterScopedOptions = (value: number) => {
getGPUOptionList({ clusterId: value });
getBackendOptions({ cluster_id: value });
form.setFieldsValue({
scheduleType: ScheduleValueMap.Auto,
gpu_selector: null
});
};
// User explicitly picked a cluster: refresh scoped options and re-evaluate.
const handleClusterChange = async (value: number) => {
await onClusterChange?.(value);
applyClusterScopedOptions(value);
await new Promise((resolve) => {
setTimeout(resolve, 150);
});
onValuesChange?.({}, form.getFieldsValue());
};
// The basic form seeds a default cluster on open, before a model is picked.
// Refresh scoped options for it but don't fire the evaluate request — there
// is no model to evaluate yet.
const handleClusterSeed = async (value: number) => {
await onClusterChange?.(value);
applyClusterScopedOptions(value);
};
const getFieldPaths = (obj: Record<string, any>, prefix = ''): string => {
const result = Object.entries(obj).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
@@ -467,6 +482,7 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
clusterList={clusterList}
sourceDisable={sourceDisable}
handleClusterChange={handleClusterChange}
onClusterSeed={handleClusterSeed}
onSourceChange={onSourceChange}
></BasicForm>
<CollapsePanel
@@ -20,6 +20,7 @@ import {
import { useFormContext } from '../config/form-context';
import { FormData } from '../config/types';
import { backendOptionsMap } from '../constants/backend-parameters';
import '../style/gpu-selector.less';
const InputWrapper = styled.div`
padding: 8px 4px;
+19
View File
@@ -401,6 +401,24 @@ export const useCheckCompatibility = () => {
);
};
// Evaluation needs a model reference. The basic form seeds a default cluster
// on open, which can fire onValuesChange before the user has picked a model —
// skip evaluation until the current source's model field is filled.
const noModelSelected = (allValues: any) => {
switch (allValues.source) {
case modelSourceMap.huggingface_value:
return !allValues.huggingface_repo_id;
case modelSourceMap.modelscope_value:
return !allValues.model_scope_model_id;
case modelSourceMap.ollama_library_value:
return !allValues.ollama_library_model_name;
case modelSourceMap.local_path_value:
return !allValues.local_path;
default:
return false;
}
};
const handleOnValuesChange = async (params: {
changedValues: any;
allValues: any;
@@ -410,6 +428,7 @@ export const useCheckCompatibility = () => {
if (
_.isEqual(cacheFormValuesRef.current, allValues) ||
noLocalPathValue(allValues) ||
noModelSelected(allValues) ||
!allValues.replicas
) {
console.log('No changes detected, skipping evaluation.');
@@ -24,7 +24,7 @@ const useFilterStatus = (options?: {
const statusOptions = [
{
value: MyModelsStatusValueMap.Active,
value: MyModelsStatusValueMap.Ready,
color: 'var(--ant-color-success)',
icon: <Dot color="var(--ant-color-success)"></Dot>,
label: intl.formatMessage({
@@ -32,7 +32,7 @@ const useFilterStatus = (options?: {
})
},
{
value: MyModelsStatusValueMap.Inactive,
value: MyModelsStatusValueMap.Stopped,
color: 'var(--ant-color-fill-secondary)',
icon: <Dot color="var(--ant-color-fill-secondary)"></Dot>,
label: intl.formatMessage({
@@ -40,7 +40,7 @@ const useFilterStatus = (options?: {
})
},
{
value: MyModelsStatusValueMap.Degrade,
value: MyModelsStatusValueMap.NotReady,
color: 'var(--ant-color-warning)',
icon: <Dot color="var(--ant-color-warning)"></Dot>,
label: intl.formatMessage({
@@ -199,7 +199,13 @@ export const useGenerateWorkerOptions = () => {
page: -1
}),
queryClusterList({
page: -1
page: -1,
// Own-org clusters only. Feeds the deploy-from-model-file cluster
// picker, which must not offer another org's cluster (e.g. the
// Default org's "shared with everyone" clusters). The worker
// cascader on this page is already own-org via the owner-scoped
// worker list.
mine: true
})
]);
const workerList = workerRes.items || ([] as WorkerListItem[]);
@@ -272,7 +278,13 @@ export default function useFormInitialValues() {
// Exclude clusters that opt in to GPU-instance handling
// (k8s_options.gpu_instance_options set) — those are for the
// GPU-service flow, not model deployment.
gpu_instance_enabled: false
gpu_instance_enabled: false,
// Only clusters owned by the current org. Drops cross-org grants
// (e.g. the Default org's "shared with everyone" clusters) so a
// tenant can't deploy onto another org's infrastructure. Platform
// admin in the "All" view bypasses this and is scoped instead by
// the org picker (see basic.tsx).
mine: true
});
const list = response.items.map((item) => ({
label: item.name,
@@ -95,6 +95,7 @@ const useNoResourceResult = (props: {
const noResourceResult = (
<NoResult
minHeight="calc(100vh - 300px)"
loading={loading}
loadend={loadend}
dataSource={dataSource}
@@ -0,0 +1,30 @@
import { useState } from 'react';
const useViewApIInfo = () => {
const [apiAccessInfo, setAPIAccessInfo] = useState<any>({
show: false,
data: {}
});
const openViewAPIInfo = (row: any) => {
setAPIAccessInfo({
show: true,
data: row
});
};
const closeViewAPIInfo = () => {
setAPIAccessInfo({
show: false,
data: {}
});
};
return {
apiAccessInfo,
openViewAPIInfo,
closeViewAPIInfo
};
};
export default useViewApIInfo;
@@ -31,6 +31,16 @@ const filterOptions = {
label: 'Error',
value: 'error',
color: 'var(--ant-color-error)'
},
{
label: 'Pending',
value: 'pending',
color: 'var(--ant-color-info)'
},
{
label: 'Starting',
value: 'starting',
color: 'var(--ant-color-info)'
}
]
};
@@ -99,6 +109,7 @@ const InstanceView = forwardRef((props, ref) => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
@@ -0,0 +1,5 @@
.cascader-popup-wrapper.gpu-selector {
.ant-cascader-menu-item-content {
padding-right: 0;
}
}
+153 -93
View File
@@ -2,20 +2,22 @@ import useTableFetch from '@/hooks/use-table-fetch';
import { SyncOutlined } from '@ant-design/icons';
import {
BaseSelect,
IconFont,
InfiniteScrollerProvider,
NoResult,
PageTools,
TemplateCardList
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useAccess, useIntl, useNavigate } from '@umijs/max';
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
import { Button, Input, Space } from 'antd';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PageBox from '../_components/page-box';
import { MY_MODELS_API, queryMyModels } from './apis';
import APIAccessInfoModal from './components/api-access-info';
import ModelItem from './components/model-item';
import { categoryOptions, MyModelsStatusValueMap } from './config';
import useFormInitialValues from './hooks/use-form-initial-values';
import useNoResourceResult from './hooks/use-no-resource-result';
import useViewApIInfo from './hooks/use-view-api-info';
const Dot = ({ color }: { color: string }) => {
return (
<span
@@ -53,29 +55,56 @@ const UserModels: React.FC = () => {
watch: false,
isInfiniteScroll: true,
defaultQueryParams: {
perPage: 24
perPage: 24,
state: MyModelsStatusValueMap.Ready
}
});
const intl = useIntl();
const access = useAccess();
const navigate = useNavigate();
const { apiAccessInfo, openViewAPIInfo, closeViewAPIInfo } = useViewApIInfo();
// Only managers (platform admin or org owner) can see / manage
// clusters and workers, so only they hit those endpoints. A plain
// user falls straight through to the default "no models" empty state
// without the infra-guidance queries firing.
const canManageResources = access?.canSeeAdmin || access?.canSeeOrgAdmin;
const { getClusterList, getWorkerList, clusterList, workerList } =
useFormInitialValues();
// Managers start in a loading state so the empty state waits for the
// infra queries to resolve — otherwise the initially-empty
// cluster/worker lists briefly flash the "no clusters" / "no workers"
// guidance before the real data lands.
const [infraLoading, setInfraLoading] = useState(!!canManageResources);
useEffect(() => {
if (canManageResources) {
setInfraLoading(true);
Promise.all([getClusterList(), getWorkerList()]).finally(() => {
setInfraLoading(false);
});
}
}, [canManageResources]);
const statusOptions = useMemo(() => {
return [
{
value: MyModelsStatusValueMap.Active,
value: MyModelsStatusValueMap.Ready,
color: 'var(--ant-color-success)',
label: intl.formatMessage({
id: 'models.mymodels.status.active'
})
},
{
value: MyModelsStatusValueMap.Inactive,
value: MyModelsStatusValueMap.Stopped,
color: 'var(--ant-color-fill)',
label: intl.formatMessage({
id: 'models.mymodels.status.inactive'
})
},
{
value: MyModelsStatusValueMap.Degrade,
value: MyModelsStatusValueMap.NotReady,
color: 'var(--ant-color-warning)',
label: intl.formatMessage({
id: 'models.mymodels.status.degrade'
@@ -91,7 +120,7 @@ const UserModels: React.FC = () => {
};
const renderCard = (data: any) => {
return <ModelItem model={data} />;
return <ModelItem model={data} onClick={openViewAPIInfo} />;
};
const loadMore = useMemoizedFn((nextPage: number) => {
@@ -122,17 +151,17 @@ const UserModels: React.FC = () => {
const getStatus = useCallback((model: any) => {
if (!model.targets && !model.ready_targets) {
return MyModelsStatusValueMap.Inactive;
return MyModelsStatusValueMap.Stopped;
}
if (model.targets > 0 && !model.ready_targets) {
return MyModelsStatusValueMap.Degrade;
return MyModelsStatusValueMap.NotReady;
}
if (model.ready_targets > 0 && model.targets > 0) {
return MyModelsStatusValueMap.Active;
return MyModelsStatusValueMap.Ready;
}
return MyModelsStatusValueMap.Degrade;
return MyModelsStatusValueMap.NotReady;
}, []);
const dataList = useMemo(() => {
@@ -151,87 +180,118 @@ const UserModels: React.FC = () => {
});
};
const { noResourceResult } = useNoResourceResult({
// Hold the empty state until infra queries resolve so managers don't
// see a "no clusters/workers" flash before the real data arrives.
loading: dataSource.loading || infraLoading,
loadend: dataSource.loadend,
dataSource: dataList,
// Preserve the original filters heuristic: only treat the current
// query as an active filter when there is data across pages, so a
// truly empty account still shows the full empty state (CTA).
queryParams: dataSource.totalPage > 0 ? queryParams : {},
iconType: 'icon-models',
title: intl.formatMessage({ id: 'noresult.mymodels.title' }),
noClusters: !!canManageResources && !clusterList.length,
noWorkers:
!!canManageResources && workerList.length === 0 && clusterList.length > 0,
defaultContent: {
// Infra is in place but no models yet: guide managers to deploy
// one, reusing the deployments-page copy so the two empty states
// read consistently. Plain users can't deploy (and skip the infra
// queries), so they keep the consumer-facing "ask an admin" copy
// and a button-less empty state.
subTitle: canManageResources
? intl.formatMessage({ id: 'noresult.deployments.subTitle' })
: intl.formatMessage({ id: 'noresult.mymodels.subTitle' }),
noFoundText: intl.formatMessage({ id: 'noresult.mymodels.nofound' }),
buttonText: canManageResources
? intl.formatMessage({ id: 'models.table.button.deploy' })
: '',
onClick: canManageResources ? () => navigate('/models/catalog') : () => {}
}
});
return (
<PageBox>
<PageTools
marginBottom={22}
marginTop={0}
left={
<Space>
<Input
placeholder={intl.formatMessage({ id: 'common.filter.name' })}
style={{ width: 230 }}
size="large"
allowClear
onClear={() =>
handleNameChange({
target: {
value: ''
}
})
}
onChange={handleNameChange}
></Input>
<BaseSelect
allowClear
showSearch={false}
placeholder={intl.formatMessage({ id: 'models.filter.category' })}
style={{ width: 180 }}
size="large"
maxTagCount={1}
options={categoryOptions}
onChange={handleCategoryChange}
></BaseSelect>
<BaseSelect
allowClear
showSearch={false}
placeholder={intl.formatMessage({ id: 'common.filter.status' })}
style={{ width: 180 }}
size="large"
maxTagCount={1}
optionRender={optionRender}
labelRender={labelRender}
options={statusOptions}
onChange={handleStatusChange}
></BaseSelect>
<Button
type="text"
style={{ color: 'var(--ant-color-text-tertiary)' }}
icon={<SyncOutlined></SyncOutlined>}
onClick={handleRefresh}
></Button>
</Space>
}
></PageTools>
<InfiniteScrollerProvider
value={{
total: dataSource.totalPage,
current: queryParams.page,
loading: dataSource.loading,
refresh: loadMore
}}
>
<TemplateCardList
dataList={dataList}
loading={dataSource.loading}
activeId={false}
isFirst={!dataSource.loadend}
renderItem={renderCard}
></TemplateCardList>
<NoResult
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataList}
image={<IconFont type="icon-models" />}
filters={{ ...queryParams }}
noFoundText={intl.formatMessage({
id: 'noresult.mymodels.nofound'
})}
title={intl.formatMessage({ id: 'noresult.mymodels.title' })}
subTitle={intl.formatMessage({ id: 'noresult.mymodels.subTitle' })}
></NoResult>
</InfiniteScrollerProvider>
</PageBox>
<>
<PageBox>
<PageTools
marginBottom={22}
marginTop={0}
left={
<Space>
<Input
placeholder={intl.formatMessage({ id: 'common.filter.name' })}
style={{ width: 230 }}
size="large"
allowClear
onClear={() =>
handleNameChange({
target: {
value: ''
}
})
}
onChange={handleNameChange}
></Input>
<BaseSelect
allowClear
showSearch={false}
placeholder={intl.formatMessage({
id: 'models.filter.category'
})}
style={{ width: 180 }}
size="large"
maxTagCount={1}
options={categoryOptions}
onChange={handleCategoryChange}
></BaseSelect>
<BaseSelect
allowClear
showSearch={false}
placeholder={intl.formatMessage({ id: 'common.filter.status' })}
style={{ width: 180 }}
size="large"
maxTagCount={1}
optionRender={optionRender}
labelRender={labelRender}
options={statusOptions}
value={queryParams.state}
onChange={handleStatusChange}
></BaseSelect>
<Button
type="text"
style={{ color: 'var(--ant-color-text-tertiary)' }}
icon={<SyncOutlined></SyncOutlined>}
onClick={handleRefresh}
></Button>
</Space>
}
></PageTools>
<InfiniteScrollerProvider
value={{
total: dataSource.totalPage,
current: queryParams.page,
loading: dataSource.loading,
refresh: loadMore
}}
>
<TemplateCardList
dataList={dataList}
loading={dataSource.loading}
activeId={false}
isFirst={!dataSource.loadend}
renderItem={renderCard}
></TemplateCardList>
{noResourceResult}
</InfiniteScrollerProvider>
</PageBox>
<APIAccessInfoModal
open={apiAccessInfo.show}
data={apiAccessInfo.data}
onClose={closeViewAPIInfo}
></APIAccessInfoModal>
</>
);
};
+1 -1
View File
@@ -30,7 +30,7 @@ const MODEL_ICON_RULES: [string, string][] = [
['gemma', 'google'],
['command-r', 'cohere'],
['cohere', 'cohere'],
['glm', 'thudm'],
['glm', 'zai'],
['yi-', '01ai'],
['ernie', 'ernie'],
['hunyuan', 'hunyuan'],
-2
View File
@@ -131,8 +131,6 @@ const Login = () => {
[shouldUseCustomLogin, userSettings]
);
console.log('useCustomLogin', useCustomLogin, userSettings);
const gotoDefaultPage = async (info: any) => {
if (!info || info?.require_password_change) {
return;
+1
View File
@@ -121,6 +121,7 @@ const MaasProvider: React.FC = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
+1
View File
@@ -381,6 +381,7 @@ const ModelRoutes: React.FC = () => {
expandable={true}
empty={
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
+1
View File
@@ -72,6 +72,7 @@ const GPUList: React.FC<GPUListProps> = ({ clusterId, source }) => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
@@ -233,6 +233,7 @@ const ModelFiles = () => {
if (type !== 'Table') return;
return (
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
+7 -1
View File
@@ -103,8 +103,14 @@ const Workers: React.FC<WorkersProps> = ({ clusterId, source }) => {
const getClusterList = async () => {
try {
// Own-org clusters only (mine=true). A worker can only join a cluster
// its org owns, so another org's cluster (e.g. the Default org's
// "shared with everyone" clusters) must not be offered in the picker.
// The worker list is owner-scoped too, so this list also covers every
// cluster the table's name column can reference.
const params = {
page: -1
page: -1,
mine: true
};
const items = await fetchClusterList(params);
const clusterMap = items?.reduce(
+104 -12
View File
@@ -10,7 +10,8 @@
* (cpu/memory/ephemeral hours, dangling volumes) are left at 0 — the
* whole-machine SKU model meters runtime, not decomposed components.
*/
import { request } from '@umijs/max';
import { getIntl, request } from '@umijs/max';
import { withDeletedMark } from '../utils/deleted-label';
import { instanceTypeSeriesLabel } from '../utils/format-instance-type';
export interface ResourceUsageFilters {
@@ -19,6 +20,10 @@ export interface ResourceUsageFilters {
instance_ids?: number[];
gpu_types?: string[];
volume_ids?: number[];
// Platform-wide "All" view only (backend-gated): consumer-Org ids and
// user-group ids (expanded server-side to the groups' direct members).
organization_ids?: number[];
user_group_ids?: number[];
}
export interface ResourceBreakdownRequest {
@@ -64,6 +69,19 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
volume_name?: string;
user_id?: number;
user_name?: string;
// Organization grouping (platform-wide "All" view). ``organization_name``
// is resolved live server-side; a gone Org sets ``deleted``.
organization_id?: number;
organization_name?: string;
// The grouped entity (instance / volume / user) no longer exists. The name
// fields keep the clean (stale) name; the tables show a DeletedTag off this
// flag plus the id, matching the Tokens tab.
deleted?: boolean;
// Owner user of a per-instance / per-volume row (compound date+dim grouping),
// with its own deletion state — independent of the row's ``deleted`` (which
// refers to the grouped instance/volume). Lets the export mark the User
// column separately, matching the Tokens tab.
user_deleted?: boolean;
// Grouped-trend rows carry the sub-group label (sku / instance / user / …)
// alongside ``date`` so the chart can pivot one series per group.
group?: string;
@@ -197,11 +215,20 @@ interface ServerMetrics {
// gpu_type / type both mean the sku (Type) on the server.
interface ServerBreakdownItem {
date?: string | null;
// Grouped entity: ``key`` is its display name, ``id`` its id, ``deleted`` its
// own lifecycle state (instance / volume / user / sku, per group_by).
key?: string | null;
id?: number | null;
date?: string | null;
sku?: string | null;
deleted?: boolean | null;
// Owner (creator) of the instance/volume row (compound date+dim grouping),
// at the item root alongside the grouped entity, with its OWN deletion state
// — independent of ``deleted`` — so the export can show a User column that
// marks a deleted owner separately from a deleted instance/volume.
creator_id?: number | null;
creator_name?: string | null;
creator_deleted?: boolean | null;
dimensions?: {
product?: string | null;
unit_cpu_milli?: number | null;
@@ -242,6 +269,7 @@ const GROUP_BY_MAP: Record<string, string> = {
instance: 'instance',
volume: 'volume',
user: 'user',
organization: 'organization',
date: 'date'
};
@@ -277,12 +305,24 @@ function flattenItem(
};
if (it.date) flat.date = it.date;
const id = it.id ?? undefined;
// Deleted entities get a "(Deleted)" suffix, matching the Token breakdown.
const deleted = !!it.deleted;
const rawKey = it.key ?? undefined;
const key = it.deleted && rawKey != null ? `${rawKey} (Deleted)` : rawKey;
// The chart series legend can't render a tag, so it carries the deleted
// marker as text ("<name> [Deleted.<id>]"); the tables render a DeletedTag off
// ``flat.deleted`` + the id and so keep the clean name.
const key =
rawKey != null
? withDeletedMark(
rawKey,
deleted,
deleted ? getIntl().formatMessage({ id: 'usage.table.deleted' }) : '',
id
)
: rawKey;
// Generic group label — for a compound (date + dim) trend row the key is the
// sub-group value (the switch below targets single-dimension table rows).
if (rawKey != null) flat.group = key;
flat.deleted = deleted;
switch (groupBy) {
case 'resource_type':
flat.resource_type = key;
@@ -292,17 +332,21 @@ function flattenItem(
flat.gpu_type = key;
break;
case 'instance':
flat.instance_name = key;
flat.instance_name = rawKey;
flat.instance_id = id;
break;
case 'volume':
flat.volume_name = key;
flat.volume_name = rawKey;
flat.volume_id = id;
break;
case 'user':
flat.user_name = key;
flat.user_name = rawKey;
flat.user_id = id;
break;
case 'organization':
flat.organization_name = rawKey;
flat.organization_id = id;
break;
default:
break;
}
@@ -330,6 +374,12 @@ function flattenItem(
if (dims.storage_type) flat.storage_type = dims.storage_type;
if (dims.capacity_mib != null) flat.capacity_mib = dims.capacity_mib;
}
// Owner (creator) of a per-instance / per-volume row — the grouped entity is
// the instance/volume (``key``/``deleted``), so the owner sits at the item
// root with its own deleted flag for the export's User column.
if (it.creator_name != null) flat.user_name = it.creator_name;
if (it.creator_id != null) flat.user_id = it.creator_id;
if (it.creator_deleted != null) flat.user_deleted = !!it.creator_deleted;
// Instance-type grouped trend: the series label (``group``) defaults to the
// raw flavor slug. Instance Types are grouped by actual shape, so label each
// series by that shape — "<product> x <cards>" / "CPU Only · 3 vCPU · 6 GB" —
@@ -356,7 +406,13 @@ function flattenResponse(
function toServerRequest(data: ResourceBreakdownRequest) {
const groupByList = data.group_by?.length ? data.group_by : ['resource_type'];
const { creator_ids, instance_ids, volume_ids } = data.filters ?? {};
const {
creator_ids,
instance_ids,
volume_ids,
organization_ids,
user_group_ids
} = data.filters ?? {};
// The non-date dimension drives response flattening into the right field.
const dim = groupByList.find((g) => g !== 'date');
return {
@@ -371,6 +427,8 @@ function toServerRequest(data: ResourceBreakdownRequest) {
...(creator_ids?.length ? { creator_ids } : {}),
...(instance_ids?.length ? { instance_ids } : {}),
...(volume_ids?.length ? { volume_ids } : {}),
...(organization_ids?.length ? { organization_ids } : {}),
...(user_group_ids?.length ? { user_group_ids } : {}),
...(data.order_by ? { order_by: data.order_by } : {}),
...(data.descending !== undefined ? { descending: data.descending } : {}),
page: data.page ?? 1,
@@ -440,6 +498,8 @@ export async function queryResourceEvents(
options?: { skipErrorHandler?: boolean; token?: any }
): Promise<ResourceEventsResponse> {
const creatorIds = data.filters?.creator_ids;
const organizationIds = data.filters?.organization_ids;
const userGroupIds = data.filters?.user_group_ids;
return request<ResourceEventsResponse>(URL.EVENTS, {
params: {
start_date: data.start_date,
@@ -449,6 +509,12 @@ export async function queryResourceEvents(
// GET endpoints take list params as CSV strings (avoids axios array
// serialization quirks); the server splits them back into lists.
...(creatorIds?.length ? { creator_ids: creatorIds.join(',') } : {}),
...(organizationIds?.length
? { organization_ids: organizationIds.join(',') }
: {}),
...(userGroupIds?.length
? { user_group_ids: userGroupIds.join(',') }
: {}),
...(data.event_types?.length
? { event_types: data.event_types.join(',') }
: {}),
@@ -466,12 +532,18 @@ export interface ResourceFilterOption {
id: number;
label: string;
deleted?: boolean;
// ``org`` / ``user`` / ``group`` — only set on organization options so the
// filter dropdown can tag a personal (USER) consumer.
kind?: string;
}
export interface ResourceFilterMeta {
creators: ResourceFilterOption[];
instances: ResourceFilterOption[];
volumes: ResourceFilterOption[];
// Platform-wide "All" view only (backend returns them empty otherwise).
organizations: ResourceFilterOption[];
user_groups: ResourceFilterOption[];
}
export async function queryResourceFilterMeta(
@@ -484,7 +556,9 @@ export async function queryResourceFilterMeta(
return {
creators: res.creators || [],
instances: res.instances || [],
volumes: res.volumes || []
volumes: res.volumes || [],
organizations: res.organizations || [],
user_groups: res.user_groups || []
};
}
@@ -494,10 +568,12 @@ export async function queryUsageSummary(
end_date: string;
scope?: 'self' | 'all';
creator_ids?: number[];
organization_ids?: number[];
user_group_ids?: number[];
},
options?: { token?: any }
): Promise<UsageSummaryResponse> {
const { creator_ids, ...rest } = params;
const { creator_ids, organization_ids, user_group_ids, ...rest } = params;
const res = await request<{
total_tokens: number;
input_tokens: number;
@@ -511,7 +587,13 @@ export async function queryUsageSummary(
params: {
...rest,
scope: params.scope ?? 'all',
...(creator_ids?.length ? { creator_ids: creator_ids.join(',') } : {})
...(creator_ids?.length ? { creator_ids: creator_ids.join(',') } : {}),
...(organization_ids?.length
? { organization_ids: organization_ids.join(',') }
: {}),
...(user_group_ids?.length
? { user_group_ids: user_group_ids.join(',') }
: {})
},
method: 'GET',
cancelToken: options?.token
@@ -528,7 +610,17 @@ export async function queryUsageSummary(
end_date: params.end_date,
scope: params.scope ?? 'all',
group_by: ['gpu_type'],
...(creator_ids?.length ? { filters: { creator_ids } } : {}),
...(creator_ids?.length ||
organization_ids?.length ||
user_group_ids?.length
? {
filters: {
...(creator_ids?.length ? { creator_ids } : {}),
...(organization_ids?.length ? { organization_ids } : {}),
...(user_group_ids?.length ? { user_group_ids } : {})
}
}
: {}),
page: 1,
perPage: 100
},
+20 -1
View File
@@ -12,6 +12,15 @@ import {
UsageBreakdownResponse,
UsageFilterItem
} from '../config/types';
import { withDeletedMark } from '../utils/deleted-label';
// group dimension → the id field inside ``identity.current`` (the backend nulls
// it for deleted entities, so the marker falls back to just "[Deleted]").
const GROUP_ID_KEY: Record<string, 'route_id' | 'user_id' | 'api_key_id'> = {
route: 'route_id',
user: 'user_id',
api_key: 'api_key_id'
};
const ControlsWrapper = styled.div`
display: flex;
@@ -124,12 +133,22 @@ const DailyUsage: React.FC<DailyUsageProps> = (props) => {
(a, b) => dayjs(a).valueOf() - dayjs(b).valueOf()
);
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
const groupOrder: string[] = [];
const groupItemsMap = new Map<string, Map<string, BreakdownItem>>();
items.forEach((item) => {
const groupEntity = groupDim
? (item[groupDim] as UsageFilterItem)
: undefined;
const groupLabel = groupDim
? ((item[groupDim] as UsageFilterItem)?.label ?? '-')
? withDeletedMark(
groupEntity?.label ?? '-',
groupEntity?.deleted,
deletedWord,
groupEntity?.identity?.current?.[GROUP_ID_KEY[groupDim]]
)
: '__total__';
if (!groupItemsMap.has(groupLabel)) {
@@ -0,0 +1,44 @@
/**
* Deleted-entity marker for the usage filter dropdowns (model / user / api-key
* on the Tokens tab; user / instance / volume on the resource tabs). Renders an
* outlined transparent pill "Deleted·#{id}" — the id keeps two entries sharing
* a now-stale label distinguishable. Pairs with dimming the option label to the
* tertiary text color.
*
* Presentational only: each filter resolves the entity id from its own option
* shape (Tokens tab ← ``identity.current``; resource tabs ← option ``value``)
* and passes it in. When no id is available the tag falls back to plain
* "Deleted".
*/
import { useIntl } from '@umijs/max';
import { Tag } from 'antd';
import React from 'react';
interface DeletedTagProps {
id?: string | number | null;
}
const DeletedTag: React.FC<DeletedTagProps> = ({ id }) => {
const intl = useIntl();
const label = intl.formatMessage({ id: 'usage.table.deleted' });
return (
<Tag
variant="outlined"
style={{
margin: 0,
fontSize: 11,
borderRadius: 12,
background: 'transparent'
}}
>
{label}
{!!id && (
<span className="text-tertiary">
<span style={{ margin: '0 2px' }}>·</span>#{id}
</span>
)}
</Tag>
);
};
export default DeletedTag;
+53 -9
View File
@@ -5,6 +5,7 @@ import { Table, TableColumnType } from 'antd';
import React, { useEffect } from 'react';
import { useUsageFilters } from '../hooks/use-usage-filters';
import useQueryBreakdownList from '../services/use-query-breakdown-list';
import { withDeletedMark } from '../utils/deleted-label';
import getBreakdownRowKey from '../utils/get-breakdown-row-key';
import FilterBar from './filter-bar';
@@ -49,6 +50,7 @@ const ExportData: React.FC<{
initialState
} = props || {};
const intl = useIntl();
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
// Members are forced to self scope, where the backend forbids grouping by
// user (privacy) — including it 403s the export request. Drop the user
@@ -123,22 +125,49 @@ const ExportData: React.FC<{
{
title: intl.formatMessage({ id: 'usage.filter.group.model' }),
dataIndex: ['route', 'label'],
render: (text: string) => {
return <AutoTooltip ghost>{text}</AutoTooltip>;
render: (text: string, record: any) => {
return (
<AutoTooltip ghost>
{withDeletedMark(
text,
record?.route?.deleted,
deletedWord,
record?.route?.identity?.current?.route_id
)}
</AutoTooltip>
);
}
},
{
title: intl.formatMessage({ id: 'dashboard.usage.export.user' }),
dataIndex: ['user', 'label'],
render: (text: string) => {
return <AutoTooltip ghost>{text}</AutoTooltip>;
render: (text: string, record: any) => {
return (
<AutoTooltip ghost>
{withDeletedMark(
text,
record?.user?.deleted,
deletedWord,
record?.user?.identity?.current?.user_id
)}
</AutoTooltip>
);
}
},
{
title: intl.formatMessage({ id: 'usage.filter.group.apikey' }),
dataIndex: ['api_key', 'label'],
render: (text: string, record: any) => {
return <AutoTooltip ghost>{text}</AutoTooltip>;
return (
<AutoTooltip ghost>
{withDeletedMark(
text,
record?.api_key?.deleted,
deletedWord,
record?.api_key?.identity?.current?.api_key_id
)}
</AutoTooltip>
);
}
},
{
@@ -215,9 +244,24 @@ const ExportData: React.FC<{
{
jsonData: (dataSource.dataList || []).map((item: any) => ({
date: item?.date?.label,
user: item?.user?.label,
route: item?.route?.label,
api_key: item?.api_key?.label,
user: withDeletedMark(
item?.user?.label ?? '',
item?.user?.deleted,
deletedWord,
item?.user?.identity?.current?.user_id
),
route: withDeletedMark(
item?.route?.label ?? '',
item?.route?.deleted,
deletedWord,
item?.route?.identity?.current?.route_id
),
api_key: withDeletedMark(
item?.api_key?.label ?? '',
item?.api_key?.deleted,
deletedWord,
item?.api_key?.identity?.current?.api_key_id
),
input_tokens: item?.input_tokens,
input_cached_tokens: item?.input_cached_tokens,
output_tokens: item?.output_tokens,
@@ -240,7 +284,7 @@ const ExportData: React.FC<{
date: intl.formatMessage({ id: 'dashboard.usage.export.date' }),
user: intl.formatMessage({ id: 'dashboard.usage.export.user' }),
route: intl.formatMessage({ id: 'usage.filter.group.model' }),
api_key: intl.formatMessage({ id: 'usage.table.provider' }),
api_key: intl.formatMessage({ id: 'usage.filter.group.apikey' }),
input_tokens: intl.formatMessage({
id: 'usage.filter.inputTokens'
}),
+54 -9
View File
@@ -1,3 +1,4 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import useRangePickerPreset from '@/pages/dashboard/hooks/use-rangepicker-preset';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import {
@@ -13,6 +14,7 @@ import React from 'react';
import { GroupOption } from '../config';
import { UsageFilterItem } from '../config/types';
import FilterBarCss from '../styles/filter-bar.less';
import DeletedTag from './deleted-tag';
type valueType = string | number | null;
const DefaultDateConfig = {
@@ -36,6 +38,14 @@ interface FilterBarProps {
routeOptions: OptionType[];
userOptions: OptionType[];
apiKeyOptions: GroupOption<UsageFilterItem>[];
// Platform-wide "All" view only; empty otherwise (backend-gated). Rendered
// by the enterprise ``UsageFilterBar`` slot.
organizationOptions?: OptionType[];
userGroupOptions?: OptionType[];
selectedOrganizations?: string[];
selectedUserGroups?: string[];
onOrganizationsChange?: (value: string[]) => void;
onUserGroupsChange?: (value: string[]) => void;
activeApiKeys: valueType[][];
handlePickerChange: (picker: DateType) => void;
onScopeChange: (value: string) => void;
@@ -76,6 +86,12 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
onRoutesChange,
onUsersChange,
onApiKeysChange,
organizationOptions,
userGroupOptions,
selectedOrganizations,
selectedUserGroups,
onOrganizationsChange,
onUserGroupsChange,
onExportChart,
onExportTable,
handleSearch
@@ -156,6 +172,17 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
);
};
// The entity id behind a deleted option, shown in the "Deleted·#{id}" tag so
// two deleted entries with the same (now-stale) label stay distinguishable.
const getEntityId = (data: any): valueType => {
const current = data?.identity?.current;
if (!current) return null;
return current.user_id ?? current.api_key_id ?? current.route_id ?? null;
};
const deletedLabelStyle = (deleted?: boolean) =>
deleted ? { color: 'var(--ant-color-text-tertiary)' } : undefined;
const handleOnApiKeysChange = (
value: valueType[][],
selectedOptions: any
@@ -175,9 +202,10 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
if (!data.isParent) {
return (
<span className="flex-center gap-4">
<AutoTooltip ghost>{data.label}</AutoTooltip>
{data.deleted &&
renderTag(intl.formatMessage({ id: 'usage.table.deleted' }))}
<AutoTooltip ghost style={deletedLabelStyle(data.deleted)}>
{data.label}
</AutoTooltip>
{data.deleted && <DeletedTag id={getEntityId(data)} />}
</span>
);
}
@@ -193,9 +221,10 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
const { data } = option;
return (
<span className="flex-center gap-4">
<AutoTooltip ghost>{data.label}</AutoTooltip>
{data.deleted &&
renderTag(intl.formatMessage({ id: 'usage.table.deleted' }))}
<AutoTooltip ghost style={deletedLabelStyle(data.deleted)}>
{data.label}
</AutoTooltip>
{data.deleted && <DeletedTag id={getEntityId(data)} />}
</span>
);
};
@@ -214,11 +243,12 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
const { data } = option;
return (
<span className="flex-center gap-4">
<AutoTooltip ghost>{data.label}</AutoTooltip>
<AutoTooltip ghost style={deletedLabelStyle(data.deleted)}>
{data.label}
</AutoTooltip>
{data.isCurrent &&
renderTag(intl.formatMessage({ id: 'usage.user.currentAccount' }))}
{data.deleted &&
renderTag(intl.formatMessage({ id: 'usage.table.deleted' }))}
{data.deleted && <DeletedTag id={getEntityId(data)} />}
</span>
);
};
@@ -330,6 +360,21 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
onChange={onApiKeysChange}
/>
)}
{/* Enterprise-only Organization / User Group filters (platform-wide
"All" view). Renders nothing when no plugin is registered or the
backend returned no options. */}
<PluginExtraFields
name="UsageFilterBar"
context={{
organizationOptions: organizationOptions || [],
userGroupOptions: userGroupOptions || [],
selectedOrganizations: selectedOrganizations || [],
selectedUserGroups: selectedUserGroups || [],
onOrganizationsChange,
onUserGroupsChange,
optionLabelRender: singleOptionRender
}}
/>
<Button
type="text"
style={{ color: 'var(--ant-color-text-tertiary)' }}
@@ -21,6 +21,7 @@ import {
ResourceBreakdownRequest,
ResourceBreakdownResponse
} from '../apis/resource';
import { withDeletedMark } from '../utils/deleted-label';
import {
exportBreakdownRows,
toExportColumns
@@ -32,6 +33,9 @@ type Scope = 'self' | 'all';
interface SelectOption {
value: number;
label: string;
deleted?: boolean;
// ``org`` / ``user`` / ``group`` — set on organization options for the tag.
kind?: string;
}
interface ResourceExportDataProps {
@@ -61,6 +65,21 @@ interface ResourceExportDataProps {
initialDateRange: [dayjs.Dayjs, dayjs.Dayjs];
initialSelectedUsers: number[];
initialSelectedResources: number[];
// Platform-wide "All" view only; empty otherwise. Mirrors the tab's
// Organization / User Group filters so the export can re-narrow by them.
organizationOptions?: SelectOption[];
userGroupOptions?: SelectOption[];
initialSelectedOrganizations?: number[];
initialSelectedUserGroups?: number[];
// Name columns that carry a "[Deleted.#id]" marker when their entity is gone.
// Each maps a clean-name field to its id + own deleted flag, so a compound
// (date + instance/volume) row can mark the instance/volume and its owner
// User independently — mirroring the Tokens tab's chart export.
deletedNameFields?: {
name: string;
id: string;
deletedFlag: string;
}[];
}
const INITIAL_PAGE = { page: 1, perPage: 100 };
@@ -81,7 +100,12 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
resourceFilter,
initialDateRange,
initialSelectedUsers,
initialSelectedResources
initialSelectedResources,
organizationOptions = [],
userGroupOptions = [],
initialSelectedOrganizations = [],
initialSelectedUserGroups = [],
deletedNameFields
} = props;
const intl = useIntl();
@@ -92,6 +116,12 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
const [selectedResources, setSelectedResources] = useState<number[]>(
initialSelectedResources
);
const [selectedOrganizations, setSelectedOrganizations] = useState<number[]>(
initialSelectedOrganizations
);
const [selectedUserGroups, setSelectedUserGroups] = useState<number[]>(
initialSelectedUserGroups
);
const [pageParams, setPageParams] = useState(INITIAL_PAGE);
const [data, setData] = useState<ResourceBreakdownResponse | null>(null);
const [loading, setLoading] = useState(false);
@@ -107,11 +137,20 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
group_by: groupBy,
granularity: 'day',
filters:
selectedUsers.length || selectedResources.length
selectedUsers.length ||
selectedResources.length ||
selectedOrganizations.length ||
selectedUserGroups.length
? {
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
...(selectedResources.length
? { [resourceFilter.key]: selectedResources }
: {}),
...(selectedOrganizations.length
? { organization_ids: selectedOrganizations }
: {}),
...(selectedUserGroups.length
? { user_group_ids: selectedUserGroups }
: {})
}
: undefined,
@@ -135,6 +174,8 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
setDateRange(initialDateRange);
setSelectedUsers(initialSelectedUsers);
setSelectedResources(initialSelectedResources);
setSelectedOrganizations(initialSelectedOrganizations);
setSelectedUserGroups(initialSelectedUserGroups);
setPageParams(INITIAL_PAGE);
}, [open]);
@@ -142,7 +183,15 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
useEffect(() => {
if (!open) return;
fetchPreview(pageParams.page, pageParams.perPage);
}, [open, dateRange, selectedUsers, selectedResources, pageParams]);
}, [
open,
dateRange,
selectedUsers,
selectedResources,
selectedOrganizations,
selectedUserGroups,
pageParams
]);
const previewColumns = useMemo(
() => [
@@ -157,7 +206,50 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
[columns, pageParams.page, pageParams.perPage, intl]
);
const rows: ResourceBreakdownItem[] = data?.items ?? [];
// Append the "[Deleted.#id]" text marker to each configured name field of
// deleted rows — used for both the preview cells and the exported sheet, so
// the two always agree. Rows are export/preview-only copies. Each field marks
// off its own deleted flag (instance/volume vs. its owner User) so a compound
// row can flag the two entities independently.
const markRows = (
items: ResourceBreakdownItem[]
): ResourceBreakdownItem[] => {
if (!deletedNameFields?.length) return items;
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
return items.map((item) => {
let next = item;
deletedNameFields.forEach((f) => {
if ((item as any)[f.deletedFlag]) {
next = {
...next,
[f.name]: withDeletedMark(
(item as any)[f.name] ?? '',
true,
deletedWord,
(item as any)[f.id]
)
};
}
});
return next;
});
};
// Normalize the date bucket to a plain calendar day (drop the ``T00:00:00``
// the hourly ``metered_usage`` carries) so the export matches the Tokens
// tab's date-only format. Slice the ISO string rather than re-parsing with
// dayjs — a tz-offset timestamp would shift the calendar day on format.
// The export always requests day granularity.
const formatRowDates = (
items: ResourceBreakdownItem[]
): ResourceBreakdownItem[] =>
items.map((i) =>
i.date ? { ...i, date: String(i.date).slice(0, 10) } : i
);
const rows: ResourceBreakdownItem[] = formatRowDates(
markRows(data?.items ?? [])
);
const handlePageChange = (page: number, perPage: number) => {
setPageParams({ page, perPage });
@@ -170,7 +262,7 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
try {
const res = await queryFn(buildRequest(-1, INITIAL_PAGE.perPage));
exportBreakdownRows(
res.items ?? [],
formatRowDates(markRows(res.items ?? [])),
toExportColumns(columns),
fileName,
sheetName
@@ -232,6 +324,18 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
},
placeholder: resourceFilter.placeholder
}}
organizationOptions={organizationOptions}
userGroupOptions={userGroupOptions}
selectedOrganizations={selectedOrganizations}
selectedUserGroups={selectedUserGroups}
onOrganizationsChange={(ids) => {
setSelectedOrganizations(ids);
setPageParams(INITIAL_PAGE);
}}
onUserGroupsChange={(ids) => {
setSelectedUserGroups(ids);
setPageParams(INITIAL_PAGE);
}}
/>
</div>
<Table
@@ -9,6 +9,7 @@
* ``extra`` lets a tab append its own filters (e.g. Resource Events' resource
* type / event type) inline, keeping one consistent bar.
*/
import PluginExtraFields from '@/components/plugin-extra-fields';
import useRangePickerPreset from '@/pages/dashboard/hooks/use-rangepicker-preset';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { AutoTooltip, IconFont, SimpleSelect } from '@gpustack/core-ui';
@@ -17,6 +18,7 @@ import { Button, DatePicker, Dropdown, MenuProps } from 'antd';
import dayjs from 'dayjs';
import React from 'react';
import FilterBarCss from '../styles/filter-bar.less';
import DeletedTag from './deleted-tag';
const DefaultDateConfig = {
maxRange: 90,
@@ -27,6 +29,7 @@ interface SelectOption {
value: number;
label: string;
deleted?: boolean;
isCurrent?: boolean;
}
// Optional per-tab entity filter (GPU instance on the GPU tab / volume on the
@@ -52,6 +55,14 @@ interface ResourceFilterBarProps {
onExportChart?: () => void;
onExportTable?: () => void;
extra?: React.ReactNode;
// Platform-wide "All" view only; empty otherwise (backend-gated). Rendered
// by the enterprise ``ResourceUsageFilterBar`` slot.
organizationOptions?: SelectOption[];
userGroupOptions?: SelectOption[];
selectedOrganizations?: number[];
selectedUserGroups?: number[];
onOrganizationsChange?: (ids: number[]) => void;
onUserGroupsChange?: (ids: number[]) => void;
}
const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
@@ -66,7 +77,13 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
onRefresh,
onExportChart,
onExportTable,
extra
extra,
organizationOptions,
userGroupOptions,
selectedOrganizations,
selectedUserGroups,
onOrganizationsChange,
onUserGroupsChange
} = props;
const intl = useIntl();
@@ -137,20 +154,32 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
)
: [dayjs().add(-DefaultDateConfig.defaultRange, 'd'), dayjs()];
const userOptionRender = (option: any) => (
<span className="flex-center gap-4">
<AutoTooltip ghost>{option?.data?.label}</AutoTooltip>
{option?.data?.deleted && (
<span
className="text-tertiary"
style={{ fontSize: 12, marginRight: 4 }}
>
[{intl.formatMessage({ id: 'usage.table.deleted' })}]
</span>
)}
const renderTag = (tag: string) => (
<span className="text-tertiary" style={{ fontSize: 12, marginRight: 4 }}>
[{tag}]
</span>
);
const deletedLabelStyle = (deleted?: boolean) =>
deleted ? { color: 'var(--ant-color-text-tertiary)' } : undefined;
// The user / instance / volume filters mirror the Tokens tab: the option
// ``value`` is the entity id, so a deleted entry stays distinguishable via the
// "Deleted·#{id}" tag.
const userOptionRender = (option: any) => {
const { data } = option;
return (
<span className="flex-center gap-4">
<AutoTooltip ghost style={deletedLabelStyle(data?.deleted)}>
{data?.label}
</AutoTooltip>
{data?.isCurrent &&
renderTag(intl.formatMessage({ id: 'usage.user.currentAccount' }))}
{data?.deleted && <DeletedTag id={data?.value} />}
</span>
);
};
return (
<div className={FilterBarCss.wrapper}>
<div className={FilterBarCss.filters}>
@@ -202,6 +231,21 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
/>
)}
{extra}
{/* Enterprise-only Organization / User Group filters (platform-wide
"All" view). Renders nothing when no plugin is registered or the
backend returned no options. */}
<PluginExtraFields
name="ResourceUsageFilterBar"
context={{
organizationOptions: organizationOptions || [],
userGroupOptions: userGroupOptions || [],
selectedOrganizations: selectedOrganizations || [],
selectedUserGroups: selectedUserGroups || [],
onOrganizationsChange,
onUserGroupsChange,
optionLabelRender: userOptionRender
}}
/>
{onRefresh && (
<Button
type="text"
+5 -3
View File
@@ -137,12 +137,14 @@ export function groupToOptions<T, TChild>(
options: {
getGroupKey: (item: T) => string;
getGroupType: (item: T) => string;
getChild: (item: T) => TChild;
// ``index`` is a stable per-row token so ``getChild`` can mint a unique
// option value for id-less (deleted) entries that share a display name.
getChild: (item: T, index: number) => TChild;
}
): GroupOption<TChild>[] {
const groupedMap = new Map<string, GroupOption<TChild>>();
data.forEach((item) => {
data.forEach((item, index) => {
const groupKey = options.getGroupKey(item);
const groupType = options.getGroupType(item);
@@ -156,7 +158,7 @@ export function groupToOptions<T, TChild>(
});
}
const child = options.getChild(item) as TChild & { value: string };
const child = options.getChild(item, index) as TChild & { value: string };
groupedMap.get(groupKey)!.children.push(child);
});
+16 -7
View File
@@ -2,7 +2,6 @@ export interface UsageFilterItem {
identity: {
value: {
cluster_name: string;
model_name: string | null;
user_name: string;
api_key_name: string | null;
access_key: string | null;
@@ -10,12 +9,16 @@ export interface UsageFilterItem {
provider_type: string | null;
provider_name: string | null;
route_name: string | null;
// Resolved live from principals (platform-wide "All" view only).
organization_name?: string | null;
group_name?: string | null;
};
current: {
model_id: string | null;
user_id: number | null;
api_key_id: string | null;
route_id: number | null;
organization_id?: number | null;
group_id?: number | null;
};
};
label: string;
@@ -49,7 +52,6 @@ export interface TimeSeriesData {
export type BreakdownItem = {
cluster_name: string;
model_name: string;
user_name: string;
api_key_name: string;
input_tokens: number;
@@ -67,6 +69,7 @@ export type BreakdownItem = {
model: UsageFilterItem;
route: UsageFilterItem;
api_key: UsageFilterItem;
organization: UsageFilterItem;
date: {
value: string;
label: string;
@@ -88,20 +91,26 @@ export interface UsageBreakdownResponse {
export interface UsageMeta {
filters: {
models: UsageFilterItem[];
users: UsageFilterItem[];
api_keys: UsageFilterItem[];
routes: UsageFilterItem[];
// Platform-wide "All" view only; empty otherwise (backend-gated).
organizations?: UsageFilterItem[];
user_groups?: UsageFilterItem[];
};
}
export type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
// The full breakdown filter set (route / user / api_key). Every breakdown
// table sends all active dimensions — matching the trend chart — so e.g. a
// user filter narrows the Models table too, not only the Users table.
// The full breakdown filter set (route / user / api_key + org / user_group).
// Every breakdown table sends all active dimensions — matching the trend
// chart — so e.g. a user filter narrows the Models table too, not only the
// Users table. ``organizations`` / ``user_groups`` are only ever populated in
// the platform-wide "All" view (their filter options are backend-gated).
export type BreakdownFilters = {
routes?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
organizations?: FilterOptionType[];
user_groups?: FilterOptionType[];
};
+5 -2
View File
@@ -8,6 +8,7 @@
* tab (date range + "filter by user" for managers); the resource-type /
* event-type selects ride in the bar's ``extra`` slot.
*/
import { SimpleSelect } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { Input, Select, Table, Tag } from 'antd';
@@ -242,16 +243,18 @@ const ResourceEvents: React.FC = () => {
options={RESOURCE_TYPE_OPTIONS}
style={{ minWidth: 200 }}
/>
<Select
<SimpleSelect
mode="multiple"
allowClear
showSearch
maxTagCount={'responsive'}
placeholder={intl.formatMessage({
id: 'usage.events.eventType'
})}
value={queryParams.eventTypes}
onChange={(v) => fetchEvents({ eventTypes: v, page: 1 })}
options={EVENT_TYPE_OPTIONS}
style={{ minWidth: 240 }}
style={{ width: 240 }}
/>
</>
}
+12 -21
View File
@@ -1,15 +1,11 @@
// columns.ts
import { AutoTooltip } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Tag } from 'antd';
import { useMemo } from 'react';
import DeletedTag from '../components/deleted-tag';
import { BreakdownItem as ListItem } from '../config/types';
const useModelsColumns = (): Array<{
title: string;
dataIndex: string | string[];
key: string;
}> => {
const useAPIKeysColumns = () => {
const intl = useIntl();
return useMemo(() => {
@@ -19,23 +15,18 @@ const useModelsColumns = (): Array<{
dataIndex: ['api_key', 'identity', 'value', 'api_key_name'],
key: 'api_key_name',
render: (text: string, record: ListItem) => (
<span className="flex items-center">
<span className="flex items-center gap-8">
<AutoTooltip ghost title={<span>{text}</span>}>
<span className="text-primary">{text}</span>
<span
className={
record.api_key?.deleted ? 'text-tertiary' : 'text-primary'
}
>
{text}
</span>
</AutoTooltip>
{record.api_key?.deleted && (
<Tag
style={{
marginLeft: 8,
borderRadius: 12,
color: 'var(--ant-color-text-tertiary)',
borderColor: 'var(--ant-color-split)',
backgroundColor: 'transparent'
}}
variant="outlined"
>
{intl.formatMessage({ id: 'usage.table.deleted' })}
</Tag>
<DeletedTag id={record.api_key?.identity?.current?.api_key_id} />
)}
</span>
)
@@ -122,4 +113,4 @@ const useModelsColumns = (): Array<{
}, [intl]);
};
export default useModelsColumns;
export default useAPIKeysColumns;
+37 -5
View File
@@ -4,12 +4,24 @@ import {
usersTableDataAtom
} from '@/atoms/usage';
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
import { useIntl } from '@umijs/max';
import { useStore } from 'jotai';
import _ from 'lodash';
import { withDeletedMark } from '../utils/deleted-label';
import useAPIKeysColumns from './use-apikeys-columns';
import useModelsColumns from './use-models-columns';
import useUsersColumns from './use-users-columns';
// The name column's ``dataIndex`` starts with the embedded entity object
// (route / user / api_key); that entity carries ``deleted`` + the id used for
// the "[Deleted.<id>]" export marker.
const ID_KEY_BY_ENTITY: Record<string, 'route_id' | 'user_id' | 'api_key_id'> =
{
route: 'route_id',
user: 'user_id',
api_key: 'api_key_id'
};
type ColumnLike = {
title: any;
// Plugin-contributed columns may omit `dataIndex` — they render via a
@@ -23,11 +35,14 @@ type ColumnLike = {
const toFieldKey = (dataIndex: string | string[]): string =>
Array.isArray(dataIndex) ? dataIndex.join('_') : dataIndex;
const buildSheetMeta = (columns: ColumnLike[]) => {
const buildSheetMeta = (columns: ColumnLike[], deletedWord: string) => {
const fields: string[] = [];
const fieldLabels: Record<string, any> = {};
const formatMap: Record<string, (raw: any, row: any) => any> = {};
// The first entity-backed column is the name column; its exported value gets
// the "<name> [Deleted.<id>]" marker for deleted rows.
let nameMarked = false;
columns.forEach((col) => {
if (!col.dataIndex) return;
const key = toFieldKey(col.dataIndex);
@@ -35,7 +50,22 @@ const buildSheetMeta = (columns: ColumnLike[]) => {
fieldLabels[key] = col.title;
if (Array.isArray(col.dataIndex)) {
const path = col.dataIndex;
formatMap[key] = (_raw, row) => _.get(row, path);
if (!nameMarked) {
nameMarked = true;
const entityKey = path[0];
const idKey = ID_KEY_BY_ENTITY[entityKey];
formatMap[key] = (_raw, row) => {
const entity = row?.[entityKey];
return withDeletedMark(
_.get(row, path),
entity?.deleted,
deletedWord,
idKey ? entity?.identity?.current?.[idKey] : undefined
);
};
} else {
formatMap[key] = (_raw, row) => _.get(row, path);
}
}
});
@@ -44,18 +74,20 @@ const buildSheetMeta = (columns: ColumnLike[]) => {
const useExportTable = () => {
const store = useStore();
const intl = useIntl();
const modelsColumns = useModelsColumns();
const apiKeysColumns = useAPIKeysColumns();
const usersColumns = useUsersColumns();
const exportTable = () => {
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
const usersTableData = store.get(usersTableDataAtom);
const apiKeysTableData = store.get(apiKeysTableDataAtom);
const modelsTableData = store.get(modelsTableDataAtom);
const modelsMeta = buildSheetMeta(modelsColumns);
const apiKeysMeta = buildSheetMeta(apiKeysColumns);
const usersMeta = buildSheetMeta(usersColumns);
const modelsMeta = buildSheetMeta(modelsColumns, deletedWord);
const apiKeysMeta = buildSheetMeta(apiKeysColumns, deletedWord);
const usersMeta = buildSheetMeta(usersColumns, deletedWord);
exportJsonToExcel({
fileName: 'table_data.xlsx',
+10 -15
View File
@@ -2,8 +2,8 @@
import { getGPUStackPlugin } from '@/plugins';
import { AutoTooltip } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Tag } from 'antd';
import { useMemo } from 'react';
import DeletedTag from '../components/deleted-tag';
import { BreakdownItem as ListItem } from '../config/types';
// Plugin slot: enterprise plugins can contribute extra columns to the
@@ -51,27 +51,22 @@ const useModelsColumns = (): ColumnDef[] => {
dataIndex: ['route', 'label'],
key: 'route_name',
render: (text: string, record: ListItem) => (
<span className="flex items-center">
<span className="flex items-center gap-8">
<AutoTooltip
ghost
style={{ maxWidth: 400 }}
title={<span>{text}</span>}
>
<span className="text-primary">{text}</span>
<span
className={
record.route?.deleted ? 'text-tertiary' : 'text-primary'
}
>
{text}
</span>
</AutoTooltip>
{record.route?.deleted && (
<Tag
style={{
marginLeft: 8,
borderRadius: 12,
color: 'var(--ant-color-text-tertiary)',
borderColor: 'var(--ant-color-split)',
backgroundColor: 'transparent'
}}
variant="outlined"
>
{intl.formatMessage({ id: 'usage.table.deleted' })}
</Tag>
<DeletedTag id={record.route?.identity?.current?.route_id} />
)}
</span>
)
+55 -5
View File
@@ -1,3 +1,4 @@
import { useModel } from '@@/plugin-model';
import { useEffect, useState } from 'react';
import {
queryResourceFilterMeta,
@@ -8,22 +9,54 @@ export interface SelectOption {
value: number;
label: string;
deleted?: boolean;
// The signed-in user's own entry, sorted first and tagged "[Current Account]"
// in the filter dropdown (matches the Tokens tab).
isCurrent?: boolean;
// ``org`` / ``user`` / ``group`` — set on organization options so the filter
// can tag a personal (USER) consumer.
kind?: string;
}
export interface ResourceMetaOptions {
creators: SelectOption[];
instances: SelectOption[];
volumes: SelectOption[];
// Platform-wide "All" view only (empty otherwise, backend-gated).
organizations: SelectOption[];
user_groups: SelectOption[];
}
const EMPTY: ResourceMetaOptions = {
creators: [],
instances: [],
volumes: []
volumes: [],
organizations: [],
user_groups: []
};
// Deleted entries sink to the bottom of the dropdown; live ones keep their
// incoming order (sort is stable).
const toOptions = (items: ResourceFilterOption[]): SelectOption[] =>
items.map((i) => ({ value: i.id, label: i.label, deleted: i.deleted }));
items
.map((i) => ({ value: i.id, label: i.label, deleted: i.deleted }))
.sort((a, b) => Number(!!a.deleted) - Number(!!b.deleted));
// Tag the signed-in user's own entry. Ordering: current account first, deleted
// entries last, everything else keeps its incoming order — matching the Tokens
// tab's "[Current Account]" treatment while pushing stale rows out of the way.
const toUserOptions = (
items: ResourceFilterOption[],
currentUserId?: number
): SelectOption[] => {
const options = items.map((i) => ({
value: i.id,
label: i.label,
deleted: i.deleted,
isCurrent: currentUserId != null && i.id === currentUserId
}));
const rank = (o: SelectOption) => (o.isCurrent ? 0 : o.deleted ? 2 : 1);
return options.sort((a, b) => rank(a) - rank(b));
};
/**
* Loads the resource tabs' filter dropdown sources in one call:
@@ -39,21 +72,38 @@ export default function useResourceMeta(
scope: 'self' | 'all' = 'all'
): ResourceMetaOptions {
const [meta, setMeta] = useState<ResourceMetaOptions>(EMPTY);
const { initialState } = useModel('@@initialState');
const currentUserId = initialState?.currentUser?.id;
useEffect(() => {
queryResourceFilterMeta(scope)
.then((res) =>
setMeta({
creators: toOptions(res.creators),
creators: toUserOptions(res.creators, currentUserId),
instances: toOptions(res.instances),
volumes: toOptions(res.volumes)
volumes: toOptions(res.volumes),
// Keep ``kind`` on org options so the filter can tag personal (USER)
// consumers; deleted ones still sink to the bottom.
organizations: res.organizations
.map((i) => ({
value: i.id,
label: i.label,
deleted: i.deleted,
kind: i.kind
}))
.sort((a, b) => Number(!!a.deleted) - Number(!!b.deleted)),
// Groups are never flagged deleted; keep incoming order.
user_groups: res.user_groups.map((i) => ({
value: i.id,
label: i.label
}))
})
)
.catch(() => {
// Network/auth errors surface via the global interceptor; leave the
// dropdowns empty rather than crashing the tab.
});
}, [scope]);
}, [scope, currentUserId]);
return meta;
}
+64 -2
View File
@@ -1,10 +1,24 @@
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
import { useIntl } from '@umijs/max';
import dayjs from 'dayjs';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react';
import { GroupOption } from '../config';
import { BreakdownItem, UsageFilterItem } from '../config/types';
import useQueryTimeSeriesData from '../services/use-query-timeseries-data';
import { withDeletedMark } from '../utils/deleted-label';
// group dimension → the id field inside ``identity.current`` (null for deleted
// entities on the Tokens tab, so the marker degrades to just "[Deleted]").
const GROUP_ID_KEY: Record<
string,
'route_id' | 'user_id' | 'api_key_id' | 'organization_id'
> = {
route: 'route_id',
user: 'user_id',
api_key: 'api_key_id',
organization: 'organization_id'
};
const DefaultDateConfig = {
defaultRange: 29
@@ -28,6 +42,8 @@ interface UseUsageFiltersParams {
users?: UserOptionType[];
api_keys?: GroupOptionType[];
routes?: RouteOptionType[];
organizations?: RouteOptionType[];
user_groups?: RouteOptionType[];
};
chartFilters: {
metric: string;
@@ -53,12 +69,16 @@ interface UseUsageFiltersParams {
routes?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
organizations?: FilterOptionType[];
user_groups?: FilterOptionType[];
};
commonFilters: {
scope: string;
routes: string[];
users: string[];
api_keys: string[];
organizations: string[];
user_groups: string[];
start_date: string;
end_date: string;
};
@@ -74,6 +94,7 @@ export const useUsageFilters = ({
autoFetchOnFilterChange = true,
onFetchData
}: UseUsageFiltersParams) => {
const intl = useIntl();
const {
activeRoutes: initialActiveRoutes = [],
activeApiKeys: initialActiveApiKeys = [],
@@ -101,6 +122,8 @@ export const useUsageFilters = ({
routes: initialActiveRoutes,
users: initialUsers || [],
api_keys: extractSelectedValues(initialActiveApiKeys),
organizations: [] as string[],
user_groups: [] as string[],
start_date:
start_date ||
dayjs()
@@ -112,6 +135,8 @@ export const useUsageFilters = ({
const routeOptions = metaData?.routes || [];
const userOptions = metaData?.users || [];
const apiKeyOptions = metaData?.api_keys || [];
const organizationOptions = metaData?.organizations || [];
const userGroupOptions = metaData?.user_groups || [];
const [activeApiKeys, setActiveApiKeys] =
useState<ValueType[][]>(initialActiveApiKeys);
@@ -149,6 +174,8 @@ export const useUsageFilters = ({
routes?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
organizations?: FilterOptionType[];
user_groups?: FilterOptionType[];
} = {};
if (selected.routes.length > 0) {
@@ -179,6 +206,24 @@ export const useUsageFilters = ({
}));
}
if (selected.organizations.length > 0) {
const orgSet = new Set(selected.organizations);
filters.organizations = organizationOptions
.filter((item) => orgSet.has(item.value))
.map((item) => ({
identity: item.identity
}));
}
if (selected.user_groups.length > 0) {
const groupSet = new Set(selected.user_groups);
filters.user_groups = userGroupOptions
.filter((item) => groupSet.has(item.value))
.map((item) => ({
identity: item.identity
}));
}
return filters;
};
@@ -256,7 +301,7 @@ export const useUsageFilters = ({
};
const handleFilterChange = (
type: 'routes' | 'users' | 'api_keys',
type: 'routes' | 'users' | 'api_keys' | 'organizations' | 'user_groups',
value: string[]
) => {
const selectedValues: string[] = value.map((item) => {
@@ -288,12 +333,21 @@ export const useUsageFilters = ({
const dateMap: Record<string, Record<string, any>> = {};
const groupLabels = new Set<string>();
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
items.forEach((item) => {
const date = item.date?.value;
if (!date) return;
const groupEntity = groupDim
? (item[groupDim] as UsageFilterItem)
: undefined;
const groupLabel = groupDim
? ((item[groupDim] as UsageFilterItem)?.label ?? '-')
? withDeletedMark(
groupEntity?.label ?? '-',
groupEntity?.deleted,
deletedWord,
groupEntity?.identity?.current?.[GROUP_ID_KEY[groupDim]]
)
: metric;
groupLabels.add(groupLabel);
if (!dateMap[date]) dateMap[date] = { date };
@@ -344,9 +398,13 @@ export const useUsageFilters = ({
selectedRoutes: commonFilters.routes,
selectedUsers: commonFilters.users,
selectedApiKeys: commonFilters.api_keys,
selectedOrganizations: commonFilters.organizations,
selectedUserGroups: commonFilters.user_groups,
routeOptions,
userOptions,
apiKeyOptions,
organizationOptions,
userGroupOptions,
activeApiKeys,
handleSearch,
handleActiveApiKeysChange,
@@ -355,6 +413,10 @@ export const useUsageFilters = ({
onRoutesChange: (value: string[]) => handleFilterChange('routes', value),
onUsersChange: (value: string[]) => handleFilterChange('users', value),
onApiKeysChange: (value: string[]) => handleFilterChange('api_keys', value),
onOrganizationsChange: (value: string[]) =>
handleFilterChange('organizations', value),
onUserGroupsChange: (value: string[]) =>
handleFilterChange('user_groups', value),
onExportChart: handleOnExportChart
};

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