Compare commits

..
Author SHA1 Message Date
hibigandgithub-actions[bot] eedc5b295a chore: bump @gpustack/core-ui to v1.0.41 2026-07-22 02:36:57 +00:00
Yuxing Dengandjialin 5ee62f267c feat(billing): on-card charge/pay badge slots for models & instance types
Add the plugin-fed billing price-badge slots to the model cards and the
gpu-instance-type / storage cards so the enterprise plugin can surface
charge / pay indicators inline.
2026-07-22 09:46:11 +08:00
jialinandjialin ba0b2bb2c3 feat(maas-provider): allow clearing the model AutoComplete 2026-07-20 20:24:28 +08:00
jialinandjialin c3bdbc200e style(backends): elevate community card and version title backgrounds 2026-07-20 20:24:28 +08:00
jialinandjialin 63b06e595e feat(access-control): render principals Add button in host holder slot 2026-07-20 20:24:28 +08:00
jialinandjialin 27210da13a style(alert): tighten with-description padding, icon size, and title line-height 2026-07-20 20:24:28 +08:00
jialinandjialin 831a760246 style: adjust table row bg/height, container color, auto table layout 2026-07-20 15:55:39 +08:00
jialinandjialin ae06ad7aae feat(workers): add tooltip hint on VRAM index, merge index+bar trigger
Blue dashed underline on the VRAM GPU index; index and ProgressBar share one Tooltip trigger showing the memory detail.
2026-07-20 15:55:39 +08:00
jialinandjialin b3b819a080 refactor: rename gpu-usage-cell to dual-arc-gauge, refine colors
Fixed blue for allocated (var(--ant-blue-6), theme-independent), gray dot when allocated is 0, dashed underline interaction hint.
2026-07-20 15:55:39 +08:00
jialinandjialin aa47c626ba feat: add GpuUsageCell component
Semicircle dual-arc gauge (used/allocated) with hover Popover for GPU memory usage in table cells.
2026-07-20 15:55:39 +08:00
hibigandjialin 79e4a60d52 chore: bump @gpustack/core-ui to v1.0.40 2026-07-20 14:39:18 +08:00
jialinandjialin d9a467f9f9 chore(llmodels): fix createTime column width, tidy imports
CI / deps (push) Has been cancelled
CI / build (push) Has been cancelled
CI / build-publish (push) Has been cancelled
CI / trigger-backend (push) Has been cancelled
2026-07-17 17:04:57 +08:00
jialinandjialin c6b89f688e refactor(tags): use TextAttribute/ThemeTag, drop redundant fit-content 2026-07-17 17:04:57 +08:00
jialinandjialin 2164a8198b feat(usage): use StatusDot for event type column
Replace antd Tag with core-ui StatusDot (dot + text), mapping event types to StatusType semantic colors.
2026-07-17 17:04:57 +08:00
hibigandjialin 0f0e92379b chore: bump @gpustack/core-ui to v1.0.39 2026-07-17 16:56:51 +08:00
jialinandjialin d8cb5261b9 feat(user-models): fall back to category icon when no brand logo matches 2026-07-16 18:00:27 +08:00
jialinandjialin e30dd10fe4 style(cluster-management): child-row text color and created_at width 2026-07-16 18:00:27 +08:00
jialinandjialin 67e2d914f4 refactor(tables): share parent column grid in expandable child rows
- llmodels/model-routes/cluster child rows use ExpandedRowGrid + Cell
  (auto-flow spans, no parent-key/grid-line math)
- define --seal-table-row-min-height token in global.less
- routes created_at column: span -> fixed width 180
2026-07-16 13:09:18 +08:00
hibigandjialin 938aef6c26 chore: bump @gpustack/core-ui to v1.0.38 2026-07-16 13:05:16 +08:00
jialinandjialin eb029a3e2a fix(table): smooth list empty/loading transitions, no layout jump on entry
Reserve a stable block for the empty/loading state (matching the NoResult
height) and fade the empty state in, so entering a list page no longer jumps
between spinner, empty result, and rows. Apply scroll-table to the antd main
lists and pass emptyMinHeight to the SealTable-based lists.
2026-07-15 14:05:19 +08:00
hibigandjialin 2dd287bbc1 chore: bump @gpustack/core-ui to v1.0.37 2026-07-15 13:43:46 +08:00
jialinandjialin 3c00317aeb fix(models): cancel model-route-targets watch on inactive tab 2026-07-14 20:26:47 +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
138 changed files with 3120 additions and 990 deletions
+12 -1
View File
@@ -124,13 +124,24 @@ Always check `@gpustack/core-ui` first. Frequently reused:
- **Form fields**: `BaseSelect`, `Input` (labeled).
- **Text overflow**: `AutoTooltip`.
- **Icons**: `IconFont`.
- **Status display** (success/failed/processing/warning): `StatusTag`.
- **Tags & status** (4 variants): see the section below.
- **Permission-gated visibility**: `Access` / `useAccess`.
- **Request hooks**: `useRequest` / `useQueryData` / `useQueryDataList`.
- **Table data fetching**: `useTableFetch`.
- **Submit guard** (prevent double-submit): `useSubmitLock`.
- **Tabbed forms**: `ScrollSpyTabs`.
# Tags & status indicators
Four core-ui components cover tag/status display in tables and lists. Pick by **what the value means**, not by how it looks — don't reach for a generic antd `Tag`:
- **`StatusTag`** — semantic status with a **dynamic message/detail** (tooltip, download, extra content). Use when a row's status carries variable text, e.g. a failed job with an error message. Colors come from `StatusColorMap` (error/warning/transitioning/success/inactive).
- **`StatusDot`** — colored dot + short label, **no message**. Use for a plain status/type cell where the value is a fixed enum (e.g. an event-type or log column). Same `StatusColorMap` palette; `inactive` dot is quaternary. If the status needs dynamic text, use `StatusTag` instead.
- **`ThemeTag`** — a **standalone category label** (independent content, e.g. a permission scope or a model name). Default neutral; wraps antd `Tag`.
- **`TextAttribute`** — a small neutral pill that is a **subordinate annotation following a primary text** (e.g. `key-name [custom]`), not a standalone tag. Manages its own leading margin. Two variants: `filled` (default) and `outlined`. Ref the name column in `src/pages/api-keys/hooks/use-keys-columns.tsx`.
Rule of thumb: semantic + dynamic text → `StatusTag`; semantic + fixed enum → `StatusDot`; independent category → `ThemeTag`; annotation of nearby text → `TextAttribute`.
# Dynamic add-item form fields
When building a form, select the add-item component from the **shape of the field's data** (its schema). Match the schema, don't hand-roll a list UI:
+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.41",
"@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.41
version: 1.0.41(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.41':
resolution: {integrity: sha512-Q4mgdjZpvuE5XDioV7MHoVVHK6k7LNzVkq0zGVln1IzAYW7ldpzQPoj/zARXqGWQLAvHLiO03PfEjiP8cE9fdQ==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.41.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.41(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)
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<!-- 顶面填充(亮面,高透明度) -->
<linearGradient id="cube-top-grad" x1="12" y1="2" x2="12" y2="12" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#f759ab" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#c41d7f" stop-opacity="0.1"/>
</linearGradient>
<!-- 左侧面填充(暗面,低透明度) -->
<linearGradient id="cube-left-grad" x1="2" y1="7" x2="12" y2="17" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#c41d7f" stop-opacity="0.12"/>
<stop offset="100%" stop-color="#c41d7f" stop-opacity="0.04"/>
</linearGradient>
<linearGradient id="cube-stroke-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#f759ab"/>
<stop offset="100%" stop-color="#c41d7f"/>
</linearGradient>
</defs>
<!-- 顶面填充 -->
<polygon points="12,2 21.5,6.7 12,11.5 2.5,6.7" fill="url(#cube-top-grad)" />
<!-- 左侧面填充 -->
<polygon points="2.5,6.7 12,11.5 12,21.3 2.5,16.5" fill="url(#cube-left-grad)" />
<!-- 立方体全纯线外骨架(细化为圆角衔接) -->
<path d="M12 2L2.5 6.7M12 2l9.5 4.7M21.5 6.7L12 11.5M2.5 6.7L12 11.5M2.5 6.7v9.8l9.5 4.8M21.5 6.7v9.8l-9.5 4.8M12 11.5v9.8" stroke="url(#cube-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<!-- 1. 定义专属微通透渐变填充 -->
<linearGradient id="img-fill-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#d46b08" stop-opacity="0.12"/>
<stop offset="100%" stop-color="#d46b08" stop-opacity="0.04"/>
</linearGradient>
<!-- 2. 定义边框高精度渐变(亮橙到深橙,拉开层次) -->
<linearGradient id="img-stroke-grad" x1="4" y1="4" x2="20" y2="20" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#fa8c16"/>
<stop offset="100%" stop-color="#d46b08"/>
</linearGradient>
</defs>
<!-- 3. 精装底色充填层 -->
<rect x="3" y="3" width="18" height="18" rx="4" fill="url(#img-fill-grad)" />
<!-- 4. 高级柔和微圆角边框层 -->
<rect x="3" y="3" width="18" height="18" rx="4" stroke="url(#img-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 内部几何现代山脉线条 -->
<path d="M3 16l4-4a2 2 0 0 1 2.8 0l5.2 5.2M13 15l2.5-2.5a2 2 0 0 1 2.8 0l2.7 2.7" stroke="url(#img-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 标志性通透小钻石 -->
<rect x="14" y="6" width="4" height="4" rx="1.5" transform="rotate(45 16 8)" fill="#fa8c16" fill-opacity="0.3" stroke="url(#img-stroke-grad)" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id="chat-fill-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#389e0d" stop-opacity="0.1"/>
<stop offset="100%" stop-color="#389e0d" stop-opacity="0.02"/>
</linearGradient>
<linearGradient id="chat-stroke-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#73d13d"/>
<stop offset="100%" stop-color="#389e0d"/>
</linearGradient>
</defs>
<!-- 主现代对话框体(全部改为圆润R角,精装填充) -->
<path d="M18 4H6a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h7.5l3.5 3.5a1 1 0 0 0 1.5-.5V17a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3z" fill="url(#chat-fill-grad)" stroke="url(#chat-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 内部通透对话线条(细化为圆角代码采样块感) -->
<rect x="7" y="8" width="8" height="1.5" rx="0.75" fill="#73d13d" fill-opacity="0.3"/>
<rect x="7" y="11.5" width="10" height="1.5" rx="0.75" fill="#73d13d" fill-opacity="0.2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id="rank-fill-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#08979c" stop-opacity="0.1"/>
<stop offset="100%" stop-color="#08979c" stop-opacity="0.01"/>
</linearGradient>
<linearGradient id="rank-stroke-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#36cfc9"/>
<stop offset="100%" stop-color="#08979c"/>
</linearGradient>
</defs>
<!-- 数据权重条(全部改为高级圆角) -->
<rect x="11" y="4" width="10" height="2.5" rx="1.25" fill="#36cfc9" fill-opacity="0.1" stroke="url(#rank-stroke-grad)" stroke-width="1.5"/>
<rect x="11" y="9" width="7.5" height="2.5" rx="1.25" fill="#36cfc9" fill-opacity="0.05" stroke="url(#rank-stroke-grad)" stroke-width="1.5"/>
<rect x="11" y="14" width="5" height="2.5" rx="1.25" stroke="url(#rank-stroke-grad)" stroke-width="1.5"/>
<!-- 基准线与立体双向指引箭头(优化为圆角) -->
<path d="M3 17l3 3 3-3M6 4v16" stroke="url(#rank-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.5 5.5L6 4l1.5 1.5" stroke="url(#rank-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id="stt-fill-grad" x1="12" y1="3" x2="12" y2="14" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#40a9ff" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#1677ff" stop-opacity="0.05"/>
</linearGradient>
<linearGradient id="stt-stroke-grad" x1="12" y1="3" x2="12" y2="21" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#40a9ff"/>
<stop offset="100%" stop-color="#1677ff"/>
</linearGradient>
</defs>
<!-- 麦克风核心体:精装通透填充 -->
<rect x="8.5" y="3" width="7" height="11" rx="3.5" fill="url(#stt-fill-grad)" stroke="url(#stt-stroke-grad)" stroke-width="1.8" />
<!-- 内部音膜立体结构线(细化为点状) -->
<line x1="10" y1="8" x2="14" y2="8" stroke="url(#stt-stroke-grad)" stroke-width="1" stroke-dasharray="1 2"/>
<!-- 悬挂外托架与底座(全部改为高级圆角) -->
<path d="M5 10a7 7 0 0 0 14 0M12 17v4M8 21h8" stroke="url(#stt-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<defs>
<!-- 1. 核心高通透深蓝渐变充填 -->
<linearGradient id="tts-v2-fill" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#2f54eb" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#1d39c4" stop-opacity="0.03"/>
</linearGradient>
<!-- 2. 精准调校的专属蓝色渐变边框 -->
<linearGradient id="tts-v2-stroke" x1="4" y1="4" x2="20" y2="20" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#2f54eb"/>
<stop offset="100%" stop-color="#1d39c4"/>
</linearGradient>
</defs>
<!-- 左侧:低频辅助声波(大间距,带现代圆角) -->
<rect x="4" y="8" width="2.2" height="8" rx="1.1" stroke="url(#tts-v2-stroke)" stroke-width="1.8" stroke-linecap="round"/>
<!-- 中央:核心高频声波主体(拉大宽度,注入通透水晶质感) -->
<rect x="10.4" y="2" width="3.2" height="20" rx="1.6" fill="url(#tts-v2-fill)" stroke="url(#tts-v2-stroke)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 右侧:高频衰减声波(保持几何对称与呼吸感) -->
<rect x="17.8" y="5" width="2.2" height="14" rx="1.1" stroke="url(#tts-v2-stroke)" stroke-width="1.8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+3
View File
@@ -0,0 +1,3 @@
.ant-alert-with-description .ant-alert-title {
line-height: 1;
}
+1
View File
@@ -1,4 +1,5 @@
@import './table.less';
@import './alert.less';
.m-b-20 {
margin-bottom: 20px;
+31
View File
@@ -9,6 +9,15 @@
}
}
@keyframes tableEmptyFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.scroll-table {
.ant-table {
.ant-table-container {
@@ -18,5 +27,27 @@
scrollbar-color: var(--color-scrollbar-thumb) transparent;
}
}
// Reserve a stable block for the empty/loading state so the first-load
// spinner and the empty result occupy the same height as eventual data —
// this removes the layout jump when entering the page. Scoped to
// `.ant-table-content` so it only targets x-scroll tables (whose empty
// row lives here) and leaves fixed-height `scroll.y` tables untouched.
// Height must match the `minHeight` passed to <NoResult> in
// use-no-resource-result.
.ant-table-content {
.ant-table-placeholder {
> .ant-table-cell {
height: calc(100vh - 300px);
}
// NoResult renders nothing while loading and mounts an <Empty> only
// once the request settles, so this fires exactly when the empty
// state appears — a seamless fade-in instead of a hard pop.
.ant-empty {
animation: tableEmptyFadeIn 0.3s ease-in-out;
}
}
}
}
}
+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">
+4
View File
@@ -43,6 +43,10 @@ export default {
DatePicker: {
fontSizeLG: 14
},
Alert: {
withDescriptionPadding: '12px 16px',
withDescriptionIconSize: 18
},
Menu: {
iconSize: 16,
iconMarginInlineEnd: 12,
+7 -3
View File
@@ -26,8 +26,8 @@ export default {
cellPaddingInline: 16,
cellPaddingBlock: 6,
cellFontSize: 14,
rowSelectedHoverBg: 'rgb(249 249 249)',
rowHoverBg: 'rgb(249 249 249)',
rowSelectedHoverBg: 'rgb(247 247 247)',
rowHoverBg: 'rgb(247 247 247)',
rowSelectedBg: 'transparent',
headerSortActiveBg: 'transparent',
headerSortHoverBg: 'transparent',
@@ -47,6 +47,10 @@ export default {
DatePicker: {
fontSizeLG: 14
},
Alert: {
withDescriptionPadding: '12px 16px',
withDescriptionIconSize: 18
},
Menu: {
iconSize: 16,
iconMarginInlineEnd: 12,
@@ -116,7 +120,7 @@ export default {
borderRadiusLG: 6,
borderRadius: 4,
borderRadiusSM: 3,
colorBgContainer: '#fff',
colorBgContainer: '#fdfdfd',
fontSize: 14,
motion: true
}
+2 -15
View File
@@ -21,8 +21,6 @@ html {
--color-fill-sider: #f4f5f4;
--color-bg-1: #f4f5f4;
--color-scroll-bg: #d9d9d9;
--color-fill-2: #fff;
--color-fill-3: #f3f6fa;
--color-logs-bg: #1e1e1e;
--color-logs-text: #d4d4d4;
--layout-content-blockpadding: 24px;
@@ -76,7 +74,6 @@ html {
--ant-rate-star-color: #fadb14;
--color-fill-spin-bg: rgba(255, 255, 255, 15%);
--width-tooltip-max: 420px;
--color-bg-tooltip: '#fff';
--color-modal-content-bg: rgba(255, 255, 255, 90%);
--color-modal-box-shadow: 0 4px 16px rgba(0, 0, 0, 10%);
--color-spotlight-bg: rgba(255, 255, 255, 100%);
@@ -91,6 +88,7 @@ html {
// ======== container ============
--color-border-container: #ededed;
--color-text-table-header: #71717a;
--seal-table-row-min-height: 68px;
}
html[data-theme='realDark'] {
@@ -100,7 +98,6 @@ html[data-theme='realDark'] {
--color-editor-dark: #00101f;
--color-editor-light: #fafafa;
--color-fill-spin-bg: rgba(55, 55, 55, 50%);
--color-bg-tooltip: #424242;
--color-editor-header-bg: #292929;
--color-progress-text: rgba(255, 255, 255, 80%);
--color-modal-content-bg: #1f1f1f;
@@ -115,19 +112,9 @@ html[data-theme='realDark'] {
.ant-result-image {
opacity: 0.9;
}
.ant-pro-page-container-affix .ant-pro-page-container-warp {
background-color: #292929 !important;
transition: none !important;
}
}
html[data-theme='light'] {
.ant-pro-page-container-affix .ant-pro-page-container-warp {
background-color: #fff !important;
transition: none !important;
}
background-color: #f4f5f6;
}
@@ -210,7 +197,7 @@ body {
tr > td {
border-bottom: none;
height: 68px;
height: var(--seal-table-row-min-height);
}
}
+8 -2
View File
@@ -40,6 +40,11 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
}
});
const cancelWatch = useMemoizedFn(() => {
chunkRequestRef.current?.current?.cancel?.();
listRequestTokenRef.current?.cancel?.();
});
const queryAllDataList = async (
params: Global.SearchParams,
options?: any
@@ -78,14 +83,15 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
useEffect(() => {
createWatchChunkRequest();
return () => {
chunkRequestRef.current?.cancel?.();
listRequestTokenRef.current?.cancel?.();
cancelWatch();
};
}, []);
return {
watchDataList,
setWatchDataList,
startWatch: createWatchChunkRequest,
cancelWatch,
deleteItemFromCache: handleDeleteItemFromCache
};
}
+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>
+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': '提供商',
@@ -0,0 +1,208 @@
import { convertFileSize } from '@/utils';
import { useIntl } from '@umijs/max';
import { Flex, Popover, theme } from 'antd';
import { createStyles } from 'antd-style';
import type { GlobalToken } from 'antd/es/theme';
import React from 'react';
export interface DualArcGaugeItem {
index: number;
label?: string;
memory: {
total: number; // bytes
used: number; // bytes — outer arc (semantic color)
allocated: number; // bytes — inner arc (blue)
};
}
interface DualArcGaugeProps {
data: DualArcGaugeItem[];
}
// guage external radius: 20, internal radius: 16, stroke width: 2
const CX = 25;
const CY = 24;
const R_OUT = 20;
const R_IN = 16;
const SW = 2.5;
const L_OUT = Math.PI * R_OUT;
const L_IN = Math.PI * R_IN;
const arcPath = (r: number) =>
`M ${CX - r} ${CY} A ${r} ${r} 0 0 1 ${CX + r} ${CY}`;
const P_OUT = arcPath(R_OUT);
const P_IN = arcPath(R_IN);
const clamp01 = (n: number) => Math.min(Math.max(n, 0), 1);
// Used color: green < 75%, yellow < 90%, red >= 90%
const usedColor = (ratio: number, token: GlobalToken) => {
if (ratio >= 0.9) return token.colorError;
if (ratio >= 0.75) return token.colorWarning;
return token.colorSuccess;
};
const useStyles = createStyles(({ css, token }) => ({
row: css`
width: fit-content;
padding: 3px 6px;
border-radius: ${token.borderRadius}px;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: ${token.colorFillTertiary};
}
`,
index: css`
font-size: 12px;
font-weight: 500;
color: ${token.colorTextSecondary};
border-bottom: 1px dashed var(--ant-blue-6);
padding-bottom: 3px;
line-height: 1.2;
`,
popTitle: css`
font-size: 12px;
font-weight: 500;
color: ${token.colorText};
margin-bottom: 6px;
`,
popList: css`
font-size: 12px;
font-variant-numeric: tabular-nums;
`,
metric: css`
white-space: nowrap;
`,
dot: css`
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
`,
value: css`
min-width: 64px;
text-align: right;
font-weight: 500;
color: ${token.colorText};
`,
label: css`
color: ${token.colorTextTertiary};
`
}));
const DualArcGauge: React.FC<DualArcGaugeProps> = ({ data }) => {
const intl = useIntl();
const { styles } = useStyles();
const { token } = theme.useToken();
const trackColor = token.colorFillSecondary;
const allocColor = 'var(--ant-blue-6)';
return (
<Flex vertical gap={2} style={{ width: 'fit-content' }}>
{data.map((item) => {
const { total = 0, used = 0, allocated = 0 } = item.memory || {};
const usedR = total > 0 ? clamp01(used / total) : 0;
const allocR = total > 0 ? clamp01(allocated / total) : 0;
const pct = Math.round(usedR * 100);
const uc = usedColor(usedR, token);
// set dim color for allocated if used is 0, otherwise use the same color as used
const allocDotColor =
allocated > 0 ? allocColor : token.colorTextQuaternary;
const content = (
<div>
<div className={styles.popTitle}>
[{item.index}]{item.label ? ` ${item.label}` : ''} ·{' '}
{convertFileSize(total)}
</div>
<Flex vertical gap={4} className={styles.popList}>
<Flex align="center" gap={6} className={styles.metric}>
<span className={styles.dot} style={{ background: uc }} />
<span className={styles.value}>{convertFileSize(used)}</span>
<span className={styles.label}>
{intl.formatMessage({ id: 'resources.table.used' })}
</span>
</Flex>
<Flex align="center" gap={6} className={styles.metric}>
<span
className={styles.dot}
style={{ background: allocDotColor }}
/>
<span className={styles.value}>
{convertFileSize(allocated)}
</span>
<span className={styles.label}>
{intl.formatMessage({ id: 'resources.table.allocated' })}
</span>
</Flex>
</Flex>
</div>
);
return (
<Popover
key={item.index}
content={content}
placement="right"
trigger={['hover', 'click']}
>
<Flex align="center" gap={6} className={styles.row}>
<svg
viewBox="0 0 50 28"
width={54}
height={30}
style={{ overflow: 'visible', flexShrink: 0 }}
>
<path
d={P_OUT}
fill="none"
strokeWidth={SW}
stroke={trackColor}
/>
<path
d={P_IN}
fill="none"
strokeWidth={SW}
stroke={trackColor}
/>
{usedR > 0 && (
<path
d={P_OUT}
fill="none"
strokeWidth={SW}
stroke={uc}
strokeDasharray={`${(usedR * L_OUT).toFixed(1)} ${L_OUT.toFixed(1)}`}
/>
)}
{allocR > 0 && (
<path
d={P_IN}
fill="none"
strokeWidth={SW}
style={{ stroke: allocColor }}
strokeDasharray={`${(allocR * L_IN).toFixed(1)} ${L_IN.toFixed(1)}`}
/>
)}
<text
x={CX}
y={CY - 0.5}
textAnchor="middle"
fontSize={9.5}
fontWeight={500}
fill={uc}
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{pct}%
</text>
</svg>
{<span className={styles.index}>[{item.index}]</span>}
</Flex>
</Popover>
);
})}
</Flex>
);
};
export default DualArcGauge;
+2 -1
View File
@@ -60,7 +60,8 @@ const ModelTag: React.FC<ModelTagProps> = ({ categoryKey, size }) => {
opacity: 1,
paddingInline: 8,
borderRadius: 12,
transform: 'scale(0.9)'
transform: 'scale(0.9)',
backgroundColor: 'transparent'
}}
color={config.color}
>
+9 -29
View File
@@ -6,10 +6,12 @@ import {
AutoTooltip,
DropdownButtons,
IconFont,
icons
icons,
TextAttribute,
ThemeTag
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { MenuProps, Tag, Tooltip } from 'antd';
import { MenuProps, Tooltip } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
@@ -139,23 +141,14 @@ const useModelsColumns = ({
key: 'name',
sorter: tableSorter(1),
render: (text: string, record: ListItem) => (
<span className="flex items-center">
<span className="flex items-center gap-8">
<AutoTooltip ghost style={{ maxWidth: 400 }} title={text}>
<span className="text-primary">{text}</span>
</AutoTooltip>
{record.is_custom && (
<Tag
style={{
marginLeft: 8,
borderRadius: 12,
color: 'var(--ant-color-text-tertiary)',
borderColor: 'var(--ant-color-split)',
backgroundColor: 'transparent'
}}
variant="outlined"
>
<TextAttribute>
{intl.formatMessage({ id: 'playground.params.custom' })}
</Tag>
</TextAttribute>
)}
</span>
)
@@ -203,26 +196,13 @@ const useModelsColumns = ({
)}
{(record.scope?.includes('inference') ||
record.scope?.includes('*')) && (
<div
style={{
border: '1px solid var(--ant-color-split)',
color: 'var(--ant-color-text-tertiary)',
backgroundColor: 'var(--ant-color-fill-quaternary)',
borderRadius: 4,
fontSize: 13,
paddingInline: 8,
flexGrow: 0,
maxWidth: '100%',
width: 'max-content',
display: 'flex'
}}
>
<ThemeTag>
<AutoTooltip ghost>
{record.allowed_model_names?.length
? record.allowed_model_names.join(', ')
: intl.formatMessage({ id: 'apikeys.models.all' })}
</AutoTooltip>
</div>
</ThemeTag>
)}
</div>
)
+6 -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'
})}
@@ -228,6 +232,7 @@ const APIKeys: React.FC = () => {
></FilterBar>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
@@ -120,13 +120,19 @@ const CommunityBackends: React.FC<{
const renderItem = (item: ListItem) => {
return (
<BackendCard
active={currentData?.id === item.id}
data={item}
layout="community"
onClick={handleOnClickItem}
actionsRenderer={actionsRenderer}
/>
<div
style={{
backgroundColor: 'var(--ant-color-bg-container)'
}}
>
<BackendCard
active={currentData?.id === item.id}
data={item}
layout="community"
onClick={handleOnClickItem}
actionsRenderer={actionsRenderer}
/>
</div>
);
};
@@ -164,6 +170,7 @@ const CommunityBackends: React.FC<{
renderItem={renderItem}
></BackendCardList>
<NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
+1 -1
View File
@@ -45,7 +45,7 @@ const Title = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--ant-color-bg-container);
background-color: var(--ant-color-bg-elevated);
font-weight: 600;
padding-top: 8px;
padding-bottom: 8px;
+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={[]}
+14 -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 });
@@ -362,11 +358,14 @@ const Clusters: React.FC = () => {
dataList={list}
provider={options.parent?.provider}
clusterId={options.parent?.id}
gridTemplate={options.gridTemplate}
prefixWidth={options.prefixWidth}
columns={options.columns}
/>
);
};
const columns = useClusterColumns(handleSelect, handleOnCell);
const columns = useClusterColumns(handleSelect);
return (
<>
@@ -399,6 +398,7 @@ const Clusters: React.FC = () => {
>
<SealTable
rowKey="id"
emptyMinHeight="calc(100vh - 300px)"
loadChildren={getWorkerPoolList}
sortDirections={TABLE_SORT_DIRECTIONS}
expandedRowKeys={expandedRowKeys}
@@ -420,7 +420,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 +482,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;
}
@@ -2,13 +2,14 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import {
CellContent,
type ChildGridOptions,
DeleteModal,
RowChildren,
ExpandedRowGrid,
TableRowProvider
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { Col, message, Row } from 'antd';
import { message } from 'antd';
import _ from 'lodash';
import React, { useRef, useState } from 'react';
import { deleteWorkerPool, updateWorkerPool } from '../apis';
@@ -16,7 +17,10 @@ import { ProviderType } from '../config';
import { NodePoolFormData, NodePoolListItem } from '../config/types';
import usePoolsColumns from '../hooks/use-pools-columns';
import AddPool from './add-pool';
interface PoolRowsProps {
interface PoolRowsProps extends Pick<
ChildGridOptions,
'gridTemplate' | 'prefixWidth' | 'columns'
> {
dataList: NodePoolListItem[];
provider: ProviderType;
clusterId: number | string;
@@ -25,9 +29,35 @@ interface PoolRowsProps {
const PoolRows: React.FC<PoolRowsProps> = ({
dataList,
provider,
clusterId
clusterId,
gridTemplate,
prefixWidth = 0,
columns: parentColumns
}) => {
const intl = useIntl();
// The child row shares the parent's column grid; cells flow left-to-right and
// only declare a span, keyed on the pool column's OWN dataIndex — never on a
// parent cluster column key. Parent layout: name (1) | provider…state middle
// region | created_at (1) | operations (1). The three middle pool columns
// cover that region: `replicas`→state (last, 1 track), `image_name`→
// models+workers (2 tracks), `instance_type` absorbs the rest (plugins +
// provider + gpus).
const columnCount = parentColumns?.length ?? 0;
const middleSpan = Math.max(columnCount - 3, 1);
const spanFor = (dataIndex: string): number => {
switch (dataIndex) {
case 'instance_type':
return Math.max(middleSpan - 3, 1);
case 'image_name':
return 2;
case 'replicas':
return 1;
default:
// name / created_at / operations align 1:1 with their parent column.
return 1;
}
};
const modalRef = useRef<any>(null);
const [addPoolStatus, setAddPoolStatus] = useState<{
open: boolean;
@@ -62,8 +92,10 @@ const PoolRows: React.FC<PoolRowsProps> = ({
}
};
const handleOnCell = async (row: NodePoolListItem, dataIndex: string) => {
console.log('handleOncell===', row, dataIndex);
const handleOnCell = async (
row: NodePoolListItem,
_data: { dataIndex: string; newValue: any; oldValue: any }
) => {
try {
await updateWorkerPool({
data: row,
@@ -118,31 +150,29 @@ const PoolRows: React.FC<PoolRowsProps> = ({
<>
{dataList?.map((data: NodePoolListItem) => {
return (
<div
<TableRowProvider
key={data.id}
style={{ borderRadius: 'var(--ant-table-header-border-radius)' }}
value={{ row: data, onCell: handleOnCell }}
>
<TableRowProvider value={{ row: data, onCell: handleOnCell }}>
<RowChildren>
<Row style={{ width: '100%' }} align="middle">
{columns.map((col: Record<string, any>) => {
return (
<Col
key={col.dataIndex || col.key}
span={col.span}
style={{
paddingInline: 0,
...(col.style || {})
}}
>
<CellContent {..._.omit(col, ['key'])}></CellContent>
</Col>
);
})}
</Row>
</RowChildren>
</TableRowProvider>
</div>
<ExpandedRowGrid
gridTemplate={gridTemplate}
prefixWidth={prefixWidth}
>
{columns.map((col: Record<string, any>) => (
<ExpandedRowGrid.Cell
key={col.dataIndex || col.key}
span={spanFor(col.dataIndex)}
style={{
color: 'var(--ant-color-text-secondary)'
}}
>
<CellContent
{..._.omit(col, ['key', 'style', 'span'])}
></CellContent>
</ExpandedRowGrid.Cell>
))}
</ExpandedRowGrid>
</TableRowProvider>
);
})}
<AddPool
@@ -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>({});
+3 -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={[]}
@@ -199,6 +200,7 @@ const Credentials: React.FC = () => {
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
tableLayout="fixed"
columns={columns}
dataSource={dataSource.dataList}
@@ -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
@@ -237,7 +225,7 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
sorter: tableSorter(6),
span: spans.createTime,
width: 180,
render: (value: string) => (
<AutoTooltip ghost minWidth={20}>
{dayjs(value).format('YYYY-MM-DD HH:mm:ss')}
@@ -258,7 +246,7 @@ const useClusterColumns = (
)
}
];
}, [handleSelect, onCellClick, intl, pluginCols]);
}, [handleSelect, intl, pluginCols]);
};
export default useClusterColumns;
@@ -43,12 +43,9 @@ const usePoolsColumns = (
showTitle: false
},
span: 3,
style: {
paddingInline: 'var(--ant-table-cell-padding-inline)'
},
render: (text: string) => (
<AutoTooltip title={text} ghost minWidth={20}>
{text}
<span className="text-primary">{text}</span>
</AutoTooltip>
)
},
@@ -60,9 +57,6 @@ const usePoolsColumns = (
showTitle: false
},
span: 4,
style: {
paddingLeft: 62
},
render: (text: string, record: ListItem) => (
<AutoTooltip
title={
@@ -98,9 +92,6 @@ const usePoolsColumns = (
ellipsis: {
showTitle: false
},
style: {
paddingLeft: 56
},
render: (text: string) => (
<AutoTooltip
title={
@@ -125,9 +116,6 @@ const usePoolsColumns = (
dataIndex: 'replicas',
span: 6,
key: 'replicas',
style: {
paddingLeft: 50
},
editable: {
valueType: 'number',
title: intl.formatMessage({ id: 'models.table.replicas.edit' })
@@ -165,9 +153,6 @@ const usePoolsColumns = (
ellipsis: {
showTitle: false
},
style: {
paddingLeft: 42
},
render: (text: string) => (
<AutoTooltip ghost minWidth={20}>
{dayjs(text).format('YYYY-MM-DD HH:mm:ss')}
@@ -179,9 +164,6 @@ const usePoolsColumns = (
key: 'operations',
dataIndex: 'operations',
span: 3,
style: {
paddingLeft: 36
},
render: (text: string, record: ListItem) => (
<DropdownButtons
items={actionItems}
@@ -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 = (
@@ -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();
@@ -1,3 +1,4 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Tag } from 'antd';
@@ -271,6 +272,10 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
{cpuManufacturer}
</ThemeTag>
)}
<PluginExtraFields
name="InstanceTypeBillingBadge"
context={{ instanceType: item }}
/>
</Flex>
</Title>
<InstanceMetadataSection spec={specData}></InstanceMetadataSection>
@@ -1,3 +1,4 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton';
import { TemplateCard } from '@gpustack/core-ui';
import { Empty, Flex, Spin } from 'antd';
@@ -40,6 +41,10 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
return (
<Flex orientation="vertical" gap={16}>
<PluginExtraFields
name="InstanceTypeBillingProvider"
context={{ instanceTypes: dataList }}
/>
{dataList.map((item) => {
const name = item.name;
return (
@@ -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}
@@ -360,6 +362,7 @@ const GPUService: React.FC = () => {
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
@@ -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}
@@ -153,6 +154,7 @@ const GPUServicePublicKeys: React.FC = () => {
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
@@ -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 {
@@ -22,21 +28,13 @@ interface ColumnsHookProps {
const getKindLabel = (record: ListItem) => {
if (record.spec?.nfs)
return (
<ThemeTag
style={{ width: 'fit-content' }}
color="cyan"
icon={<FolderOutlined />}
>
<ThemeTag color="cyan" icon={<FolderOutlined />}>
{StorageTypeKindLabelMap.nfs}
</ThemeTag>
);
if (record.spec?.s3)
return (
<ThemeTag
style={{ width: 'fit-content' }}
color="green"
icon={<IconFont type="icon-database" />}
>
<ThemeTag color="green" icon={<IconFont type="icon-database" />}>
{StorageTypeKindLabelMap.s3}
</ThemeTag>
);
@@ -81,6 +79,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}
@@ -149,6 +150,7 @@ const GPUServiceStorageTypes: React.FC = () => {
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
@@ -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',
+2 -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}
@@ -150,6 +151,7 @@ const GPUServiceStorage: React.FC = () => {
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
className={'scroll-table'}
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
@@ -177,7 +179,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}
@@ -15,6 +15,7 @@ import {
Dropdown,
DropdownProps,
Empty,
Flex,
Form,
Radio,
RadioChangeEvent,
@@ -80,9 +81,8 @@ const Label = styled.div`
align-items: center;
gap: 4px;
font-weight: 500;
margin-block: 8px 12px;
margin-bottom: 16px;
font-size: 14px;
color: var(--ant-color-text-tertiary);
`;
interface AccessControlFormProps {
@@ -107,6 +107,14 @@ type AllowedUsersOverride = {
routeId?: number;
action: PageActionType;
}>;
// Optional trigger (e.g. an "Add" button) rendered by the host in its
// own layout slot while `Field` renders the body. Same props as Field
// so the plugin can gate visibility on action/routeId.
Action?: React.ComponentType<{
form: any;
routeId?: number;
action: PageActionType;
}>;
};
// Extra policy entries prepended to the radio list (rendered before
@@ -407,41 +415,56 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
</Tooltip>
</Label>
<Form.Item<AccessControlFormData> name="access_policy" noStyle>
<Radio.Group
onChange={handleOnPolicyChange}
style={{ marginBottom: 12 }}
options={[
...prependedPolicies.map((p) => ({
label: intl.formatMessage({ id: p.labelId }),
value: p.policyValue
})),
{
label: intl.formatMessage({ id: 'models.accessSettings.authed' }),
value: 'authed'
},
allowedUsersOverride
? {
label: intl.formatMessage({
id: allowedUsersOverride.labelId
}),
value: allowedUsersOverride.policyValue
}
: {
label: intl.formatMessage({
id: 'models.accessSettings.allowedUsers'
}),
value: ALLOWED_PRINCIPALS_POLICY
},
{
label: intl.formatMessage({
id: 'models.accessSettings.public'
}),
value: 'public'
}
]}
></Radio.Group>
</Form.Item>
<Flex
align="center"
justify="space-between"
style={{ marginBottom: 16, height: 40 }}
>
<Form.Item<AccessControlFormData> name="access_policy" noStyle>
<Radio.Group
onChange={handleOnPolicyChange}
options={[
...prependedPolicies.map((p) => ({
label: intl.formatMessage({ id: p.labelId }),
value: p.policyValue
})),
{
label: intl.formatMessage({
id: 'models.accessSettings.authed'
}),
value: 'authed'
},
allowedUsersOverride
? {
label: intl.formatMessage({
id: allowedUsersOverride.labelId
}),
value: allowedUsersOverride.policyValue
}
: {
label: intl.formatMessage({
id: 'models.accessSettings.allowedUsers'
}),
value: ALLOWED_PRINCIPALS_POLICY
},
{
label: intl.formatMessage({
id: 'models.accessSettings.public'
}),
value: 'public'
}
]}
></Radio.Group>
</Form.Item>
{allowedUsersOverride?.Action &&
accessPolicy === overridePolicyValue && (
<allowedUsersOverride.Action
form={form}
routeId={currentData?.id}
action={action}
/>
)}
</Flex>
{accessPolicy === 'public' && (
<div style={{ marginBlock: '16px 12px' }}>
<AlertBlockInfo
@@ -449,6 +472,13 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
message={intl.formatMessage({
id: 'models.accessSettings.public.tips'
})}
overlayScrollerProps={{
styles: {
wrapper: {
paddingLeft: 0
}
}
}}
></AlertBlockInfo>
</div>
)}
@@ -130,7 +130,9 @@ const AccessControlModal: React.FC<
<AlertBlockInfo
type="warning"
style={{ marginBottom: 16 }}
contentStyle={{ paddingInline: 0 }}
contentStyle={{
paddingInline: 0
}}
message={intl.formatMessage({
id: 'models.button.accessSettings.tips'
})}
+2 -5
View File
@@ -25,10 +25,7 @@ const Metric: React.FC<{ label: React.ReactNode; value: React.ReactNode }> = ({
value
}) => (
<span style={{ lineHeight: 1.2 }}>
{label}:{' '}
<span className="font-500" style={{ color: 'var(--ant-color-text)' }}>
{value}
</span>
{label}: <span>{value}</span>
</span>
);
@@ -70,7 +67,7 @@ const GPUCard: React.FC<{
}
description={
info || (
<Flex wrap gap={8} style={{ fontSize: 13 }}>
<Flex wrap gap={8} style={{ fontSize: 11 }}>
<Metric
label={intl.formatMessage({ id: 'resources.table.total' })}
value={convertFileSize(data?.memory?.total || 0)}
@@ -1,6 +1,5 @@
import { ListItem as WorkerListItem } from '@/pages/resources/config/types';
import { AutoTooltip, RowChildren } from '@gpustack/core-ui';
import { Col, Row } from 'antd';
import { AutoTooltip, ExpandedRowGrid } from '@gpustack/core-ui';
import dayjs from 'dayjs';
import React from 'react';
import { ModelInstanceListItem } from '../../config/types';
@@ -11,11 +10,17 @@ import DistributeInfoCell from '../instance-cells/distribute-info-cell';
import DownloadingStatusCell from '../instance-cells/downloading-status-cell';
import InstanceStatusCell from '../instance-cells/instance-status-cell';
import NameCell from '../instance-cells/name-cell';
interface InstanceItemProps {
instanceData: ModelInstanceListItem;
workerList: WorkerListItem[];
modelData?: any;
defaultOpenId: string;
// Column grid shared from the parent SealTable so this child row aligns
// its cells to the parent columns instead of guessing paddings.
gridTemplate?: string;
prefixWidth?: number;
columns?: any[];
handleChildSelect: (val: string, item: ModelInstanceListItem) => void;
}
@@ -24,79 +29,67 @@ const InstanceItem: React.FC<InstanceItemProps> = ({
workerList,
modelData,
defaultOpenId,
gridTemplate,
prefixWidth = 0,
columns,
handleChildSelect
}) => {
// The child row shares the parent's column grid; cells flow left-to-right and
// only declare a span, so there is no dependency on parent column keys.
// Parent layout: name (1) | middle plugin/source region | replicas,
// created_at, operation (last 3). The middle absorbs whatever columns sit
// between name and replicas (cluster_id, source, any plugin column).
const columnCount = columns?.length ?? 0;
const middleSpan = Math.max(columnCount - 4, 1);
return (
<div style={{ borderRadius: 'var(--ant-table-header-border-radius)' }}>
<RowChildren>
<Row
style={{ width: '100%', color: 'var(--ant-color-text-secondary)' }}
align="middle"
>
<Col
span={6}
style={{
paddingInline: 'var(--ant-table-cell-padding-inline)'
}}
>
<NameCell
record={instanceData}
modelData={modelData}
defaultOpenId={defaultOpenId}
></NameCell>
</Col>
<Col span={7}>
<span
style={{
paddingLeft: '58px',
flexWrap: 'wrap',
gap: '8px'
}}
className="flex align-center"
>
<CPUOffloadingCell record={instanceData}></CPUOffloadingCell>
<DistributeInfoCell
record={instanceData}
workerList={workerList}
></DistributeInfoCell>
</span>
</Col>
<Col span={4}>
<span
style={{ paddingLeft: '40px', gap: 4 }}
className="flex-center"
>
<InstanceStatusCell
record={instanceData}
onSelect={handleChildSelect}
/>
<DownloadingStatusCell
backend={modelData?.backend}
distributed_servers={instanceData.distributed_servers}
workerList={workerList}
record={instanceData}
></DownloadingStatusCell>
</span>
</Col>
<Col span={4}>
<span style={{ paddingLeft: 43 }} className="flex">
<AutoTooltip ghost>
{dayjs(instanceData.created_at).format('YYYY-MM-DD HH:mm:ss')}
</AutoTooltip>
</span>
</Col>
<Col span={3}>
<div style={{ paddingLeft: 36 }}>
<ActionsCell
record={instanceData}
modelData={modelData}
onSelect={handleChildSelect}
></ActionsCell>
</div>
</Col>
</Row>
</RowChildren>
</div>
<ExpandedRowGrid
gridTemplate={gridTemplate}
prefixWidth={prefixWidth}
style={{ color: 'var(--ant-color-text-secondary)' }}
>
<ExpandedRowGrid.Cell span={1}>
<NameCell
record={instanceData}
modelData={modelData}
defaultOpenId={defaultOpenId}
></NameCell>
</ExpandedRowGrid.Cell>
<ExpandedRowGrid.Cell
span={middleSpan}
style={{ flexWrap: 'wrap', gap: 8 }}
>
<CPUOffloadingCell record={instanceData}></CPUOffloadingCell>
<DistributeInfoCell
record={instanceData}
workerList={workerList}
></DistributeInfoCell>
</ExpandedRowGrid.Cell>
<ExpandedRowGrid.Cell span={1} style={{ gap: 4 }}>
<InstanceStatusCell
record={instanceData}
onSelect={handleChildSelect}
/>
<DownloadingStatusCell
backend={modelData?.backend}
distributed_servers={instanceData.distributed_servers}
workerList={workerList}
record={instanceData}
></DownloadingStatusCell>
</ExpandedRowGrid.Cell>
<ExpandedRowGrid.Cell span={1}>
<AutoTooltip ghost>
{dayjs(instanceData.created_at).format('YYYY-MM-DD HH:mm:ss')}
</AutoTooltip>
</ExpandedRowGrid.Cell>
<ExpandedRowGrid.Cell span={1}>
<ActionsCell
record={instanceData}
modelData={modelData}
onSelect={handleChildSelect}
></ActionsCell>
</ExpandedRowGrid.Cell>
</ExpandedRowGrid>
);
};
export default InstanceItem;
@@ -17,6 +17,9 @@ interface InstanceItemProps {
workerList: WorkerListItem[];
modelData?: any;
currentExpanded?: string;
gridTemplate?: string;
prefixWidth?: number;
columns?: any[];
handleChildSelect: (val: string, item: ModelInstanceListItem) => void;
}
@@ -25,6 +28,9 @@ const Instances: React.FC<InstanceItemProps> = ({
workerList,
modelData,
currentExpanded,
gridTemplate,
prefixWidth,
columns,
handleChildSelect
}) => {
const [firstLoad, setFirstLoad] = React.useState(true);
@@ -55,6 +61,9 @@ const Instances: React.FC<InstanceItemProps> = ({
instanceData={item}
defaultOpenId={firstLoad ? defaultOpenId : ''}
handleChildSelect={handleChildSelect}
gridTemplate={gridTemplate}
prefixWidth={prefixWidth}
columns={columns}
></InstanceItem>
);
})}
+90 -24
View File
@@ -1,11 +1,13 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import {
StatusTag,
IconFont,
StatusDot,
TagsWrapper,
TemplateCard,
ThemeTag
} from '@gpustack/core-ui';
import { useIntl, useNavigate } from '@umijs/max';
import { Button } from 'antd';
import { Button, Tooltip } from 'antd';
import _ from 'lodash';
import React, { useMemo } from 'react';
import styled from 'styled-components';
@@ -19,7 +21,11 @@ import {
MyModelsStatusValueMap
} from '../config';
import { categoryToPathMap } from '../config/button-actions';
import { getModelLogo } from '../utils/model-logo';
import {
defaultModelLogo,
getCategoryLogo,
getModelLogo
} from '../utils/model-logo';
const CardWrapper = styled.div`
&:hover {
@@ -44,7 +50,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 +91,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 +133,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 (
@@ -156,6 +170,11 @@ const ModelItem: React.FC<{
navigate(`/playground/chat?model=${modelName}`);
};
// Logo priority: brand logo matched from the name → tinted category
// icon (from model_icons) → generic default image.
const brandLogo = getModelLogo(model.name);
const categoryLogo = brandLogo ? null : getCategoryLogo(model.categories);
// context length
const maxToken = useMemo(() => {
const meta = model.meta || {};
@@ -174,29 +193,67 @@ const ModelItem: React.FC<{
return _.round(max_tokens / 1024);
}, [model]);
// Ready is the normal state — show just the dot (no label); other states
// (Not Ready / Stopped) keep their label so the problem is legible.
const statusNode = (
<StatusDot
statusValue={{
status: MyModelsStatusMap[model.status],
text:
model.status === MyModelsStatusValueMap.Ready ||
!MyModelsStatusLabelMap[model.status]
? ''
: intl.formatMessage({
id: MyModelsStatusLabelMap[model.status]
})
}}
/>
);
return (
<CardWrapper>
<TemplateCard
height={140}
clickable={false}
clickable={true}
hoverable={true}
ghost
onClick={handleCardClick}
header={
<Header>
<span className="text gap-8">
<ModelLogo src={getModelLogo(model.name)} alt="" />
<span>{model.name}</span>
<span className="text gap-16">
{brandLogo ? (
<ModelLogo src={brandLogo} alt="" />
) : categoryLogo ? (
<ModelLogo src={categoryLogo} alt="" />
) : (
<ModelLogo src={defaultModelLogo} alt="" />
)}
<span className="flex-center" style={{ gap: 8, minWidth: 0 }}>
<span>{model.name}</span>
{/* Status moved to a compact dot right after the name, freeing
the header's right side for the price summary. The dot keeps
the state message on hover (StatusDot has no built-in one). */}
<span
style={{
fontWeight: 400,
fontSize: 'var(--font-size-small)'
}}
>
{model.state_message ? (
<Tooltip title={model.state_message}>
<span style={{ display: 'inline-flex' }}>
{statusNode}
</span>
</Tooltip>
) : (
statusNode
)}
</span>
</span>
</span>
<span className="flex-center gap-8">
<PluginExtraFields name="ModelPriceSummary" context={{ model }} />
</span>
<StatusTag
maxTooltipWidth={400}
statusValue={{
status: MyModelsStatusMap[model.status],
text: intl.formatMessage({
id: MyModelsStatusLabelMap[model.status] || ''
}),
message: model.state_message
}}
></StatusTag>
</Header>
}
>
@@ -239,14 +296,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>
+7 -4
View File
@@ -5,9 +5,7 @@ import { PageActionType } from '@/config/types';
import useBodyScroll from '@/hooks/use-body-scroll';
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
import useTableRowSelection from '@/hooks/use-table-row-selection';
import useWatchList from '@/hooks/use-watch-list';
import useNoResourceResult from '@/pages/llmodels/hooks/use-no-resource-result';
import { MODEL_ROUTE_TARGETS } from '@/pages/model-routes/apis';
import { TargetStatusValueMap } from '@/pages/model-routes/config';
import useOpenPlayground from '@/pages/model-routes/hooks/use-open-playground';
import useGranfanaLink from '@/pages/resources/hooks/use-grafana-link';
@@ -92,6 +90,7 @@ interface ModelsProps {
loadend: boolean;
total: number;
filterValues?: Record<string, any>;
targetList?: any[];
}
const getFormattedData = (record: any, extraData = {}) => ({
@@ -129,7 +128,8 @@ const Models: React.FC<ModelsProps> = ({
queryParams,
loading,
loadend,
total
total,
targetList = []
}) => {
const { generateFormValues, clusterList, workerList } =
useDeploymentsContext();
@@ -155,7 +155,6 @@ const Models: React.FC<ModelsProps> = ({
expandedRowKeys
} = useExpandedRowKeys(expandAtom);
const { handleOpenPlayGround } = useOpenPlayground();
const { watchDataList: targetList } = useWatchList(MODEL_ROUTE_TARGETS);
const { openViewLogsModal, openViewLogsModalStatus, closeViewLogsModal } =
useViewInstanceLogs();
@@ -442,6 +441,9 @@ const Models: React.FC<ModelsProps> = ({
modelData={options.parent}
workerList={workerList}
handleChildSelect={handleChildSelect}
gridTemplate={options.gridTemplate}
prefixWidth={options.prefixWidth}
columns={options.columns}
></Instances>
);
},
@@ -614,6 +616,7 @@ const Models: React.FC<ModelsProps> = ({
></PageTools>
<SealTable
columns={columns}
emptyMinHeight="calc(100vh - 300px)"
sortDirections={TABLE_SORT_DIRECTIONS}
dataSource={dataSource}
rowSelection={rowSelection}
+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 = {
+10
View File
@@ -3,6 +3,8 @@ import useSetChunkRequest from '@/hooks/use-chunk-request';
import { usePaginationStatus } from '@/hooks/use-pagination-status';
import { useTableMultiSort } from '@/hooks/use-table-sort';
import useUpdateChunkedList from '@/hooks/use-update-chunk-list';
import useWatchList from '@/hooks/use-watch-list';
import { MODEL_ROUTE_TARGETS } from '@/pages/model-routes/apis';
import { TableOrder, TableProvider } from '@gpustack/core-ui';
import { useMemoizedFn } from 'ahooks';
import _ from 'lodash';
@@ -31,6 +33,11 @@ const Models = forwardRef((props, ref) => {
const { setChunkRequest, createAxiosToken } = useSetChunkRequest();
const { setChunkRequest: setModelInstanceChunkRequest } =
useSetChunkRequest();
const {
watchDataList: targetList,
startWatch: startTargetsWatch,
cancelWatch: cancelTargetsWatch
} = useWatchList(MODEL_ROUTE_TARGETS);
const [modelInstances, setModelInstances] = useState<any[]>([]);
const [dataSource, setDataSource] = useState<{
dataList: ListItem[];
@@ -255,6 +262,7 @@ const Models = forwardRef((props, ref) => {
cacheInsDataListRef.current = [];
chunkInstanceRequedtRef.current?.current?.cancel?.();
instancesToken.current?.cancel?.();
cancelTargetsWatch();
});
const resumeRequestsOnPageActive = useMemoizedFn(async () => {
@@ -265,6 +273,7 @@ const Models = forwardRef((props, ref) => {
await getAllModelInstances();
await createModelsInstanceChunkRequest();
await createModelsChunkRequest();
await startTargetsWatch();
});
const handleOnCancelViewLogs = useMemoizedFn(async () => {
@@ -483,6 +492,7 @@ const Models = forwardRef((props, ref) => {
total={dataSource.total}
deleteIds={dataSource.deletedIds}
filterValues={filterValues}
targetList={targetList}
></TableList>
</TableProvider>
);
+6 -2
View File
@@ -85,6 +85,7 @@ interface BasicFormProps {
}
>[];
handleClusterChange: (value: number) => void;
onClusterSeed: (value: number) => void;
onSourceChange?: (value: string) => void;
}
@@ -94,6 +95,7 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
clusterList,
sourceDisable,
handleClusterChange,
onClusterSeed,
onSourceChange
} = props;
const intl = useIntl();
@@ -148,6 +150,8 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
// 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 (!clusterOptions?.length) {
return;
@@ -167,8 +171,8 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
return;
}
form.setFieldValue('cluster_id', next);
handleClusterChange?.(next);
}, [clusterOptions, form, handleClusterChange]);
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
+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,
@@ -268,7 +268,7 @@ const useModelsColumns = ({
dataIndex: 'created_at',
key: 'created_at',
sorter: tableSorter(5),
span: spans.createTime,
width: 180,
render: (text: number) => (
<AutoTooltip ghost>
{dayjs(text).format('YYYY-MM-DD HH:mm:ss')}
@@ -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}
+158 -93
View File
@@ -1,21 +1,24 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
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 +56,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 +121,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 +152,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 +181,122 @@ 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>
<PluginExtraFields
name="ModelBillingProvider"
context={{ models: dataList }}
/>
<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>
</>
);
};
+33 -5
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'],
@@ -44,6 +44,7 @@ const MODEL_ICON_RULES: [string, string][] = [
// 其余厂商/系列 → 对应 logo(按子串命中)
['gemini', 'google'],
['gpt', 'openai'],
['hy', 'hunyuan'],
['openai', 'openai'],
['internvl', 'opengvlab'],
['minicpm', 'openbmb'],
@@ -71,13 +72,40 @@ const MODEL_ICON_RULES: [string, string][] = [
['alibaba', 'alibaba']
];
// Default llm logo, used as the ultimate fallback when neither a brand
// logo nor a model-category icon can be resolved.
export const defaultModelLogo = iconMap[DEFAULT_ICON];
// 模型类别 → model_icons 下的类别图标文件名。
const CATEGORY_ICON_MAP: Record<string, string> = {
llm: 'llm',
embedding: 'embedding',
reranker: 'reranker',
image: 'image',
text_to_speech: 'tts',
speech_to_text: 'stt'
};
/**
* Resolve a model logo from its name.
* Resolve a category icon (from model_icons) for a model's categories.
* Returns the first matching category icon url, or null when none match.
*/
export const getCategoryLogo = (categories?: string[]): string | null => {
const iconName = categories
?.map((category) => CATEGORY_ICON_MAP[category])
.find((name) => name && iconMap[name]);
return iconName ? iconMap[iconName] : null;
};
/**
* Resolve a brand logo from a model name.
* 1. Match against the keyword rules in order (first substring hit wins).
* 2. Fall back to a direct match where the name contains an icon filename.
* 3. Fall back to the default llm icon.
* 3. Return null when nothing matches — the caller then falls back to a
* category-based icon (see `categoryConfig`) rather than a default image.
*/
export const getModelLogo = (modelName?: string): string => {
export const getModelLogo = (modelName?: string): string | null => {
const name = (modelName || '').toLowerCase();
const rule = MODEL_ICON_RULES.find(([keyword]) => name.includes(keyword));
@@ -90,5 +118,5 @@ export const getModelLogo = (modelName?: string): string => {
.sort((a, b) => b.length - a.length)
.find((iconName) => name.includes(iconName));
return iconMap[directHit || DEFAULT_ICON] || iconMap[DEFAULT_ICON];
return directHit ? iconMap[directHit] : null;
};
-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;

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