Compare commits

...
74 Commits
Author SHA1 Message Date
jialinandjialin 29b965ebbd style(cluster): center pool replicas cell
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-23 20:49:23 +08:00
jialinandjialin aca6490da1 fix(llmodels): stale instances lingering after missed DELETE watch events 2026-07-23 15:53:21 +08:00
jialinandjialin 04511a63a3 fix(models): cluster auto-selection mismatch when org scope settles late 2026-07-23 15:33:42 +08:00
jialinandjialin 28da5d52e8 style(cluster): col width 2026-07-22 16:29:04 +08:00
jialinandjialin 730cbc9b64 style(cluster): cell width 2026-07-22 15:50:32 +08:00
hibigandjialin 2a69ba65cb chore: bump @gpustack/core-ui to v1.0.42 2026-07-22 15:41:25 +08:00
jialinandjialin aefb8eadce chore(theme): add Card headerHeight token 2026-07-22 10:56:06 +08:00
jialinandjialin 49c80b2ccb feat(llmodels): use CardRadioGroup for access scope selection 2026-07-22 10:56:06 +08:00
hibigandjialin 654eca7544 chore: bump @gpustack/core-ui to v1.0.41 2026-07-22 10:39:55 +08: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
jialinandjialin dc41b61d69 fix(deployments): display gpu allocated value in gpu selector 2026-07-06 17:48:22 +08:00
143 changed files with 3262 additions and 1049 deletions
+12 -1
View File
@@ -124,13 +124,24 @@ Always check `@gpustack/core-ui` first. Frequently reused:
- **Form fields**: `BaseSelect`, `Input` (labeled). - **Form fields**: `BaseSelect`, `Input` (labeled).
- **Text overflow**: `AutoTooltip`. - **Text overflow**: `AutoTooltip`.
- **Icons**: `IconFont`. - **Icons**: `IconFont`.
- **Status display** (success/failed/processing/warning): `StatusTag`. - **Tags & status** (4 variants): see the section below.
- **Permission-gated visibility**: `Access` / `useAccess`. - **Permission-gated visibility**: `Access` / `useAccess`.
- **Request hooks**: `useRequest` / `useQueryData` / `useQueryDataList`. - **Request hooks**: `useRequest` / `useQueryData` / `useQueryDataList`.
- **Table data fetching**: `useTableFetch`. - **Table data fetching**: `useTableFetch`.
- **Submit guard** (prevent double-submit): `useSubmitLock`. - **Submit guard** (prevent double-submit): `useSubmitLock`.
- **Tabbed forms**: `ScrollSpyTabs`. - **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 # 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: 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', path: '/models',
redirect: '/models/deployments' 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', name: 'modelCatalog',
path: '/models/catalog', path: '/models/catalog',
@@ -110,16 +119,6 @@ const baseRoutes = [
access: 'canSeeOrgAdmin', access: 'canSeeOrgAdmin',
component: './llmodels/catalog' 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', name: 'deployment',
path: '/models/deployments', path: '/models/deployments',
@@ -274,7 +273,7 @@ const baseRoutes = [
selectedIcon: 'icon-cluster2-filled', selectedIcon: 'icon-cluster2-filled',
defaultIcon: 'icon-cluster2-outline', defaultIcon: 'icon-cluster2-outline',
component: './cluster-management/clusters', component: './cluster-management/clusters',
subMenu: ['/resources/clusters/detail', '/resources/clusters/create'] subMenu: ['/resources/clusters/create']
}, },
{ {
name: 'workers', name: 'workers',
@@ -302,16 +301,6 @@ const baseRoutes = [
selectedIcon: 'icon-credential-filled', selectedIcon: 'icon-credential-filled',
defaultIcon: 'icon-credential-outline', defaultIcon: 'icon-credential-outline',
component: './cluster-management/credentials' 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", "@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51", "@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1", "@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.35", "@gpustack/core-ui": "^1.0.42",
"@huggingface/gguf": "^0.1.7", "@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1", "@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6", "@huggingface/tasks": "^0.11.6",
+5 -5
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1 specifier: ^7.1.1
version: 7.1.2 version: 7.1.2
'@gpustack/core-ui': '@gpustack/core-ui':
specifier: ^1.0.35 specifier: ^1.0.42
version: 1.0.35(czdvzceysqw7iv6pct2ucnb23e) version: 1.0.42(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf': '@huggingface/gguf':
specifier: ^0.1.7 specifier: ^0.1.7
version: 0.1.18 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} resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz}
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
'@gpustack/core-ui@1.0.35': '@gpustack/core-ui@1.0.42':
resolution: {integrity: sha512-MaEmCM3FikeKZdUgDSyUtIfXtNMbNOLeFiTynl6DcKEraUOpk6tgQXPDginBY3uXuisG4hzoIQbpHHkzaYiQew==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.35.tgz} resolution: {integrity: sha512-upMClTHU+xAqd8dlx0w1S9XWHlog5g1hcOCulTk2rmMXqgl66QHfqhEFhJnWIGe1xc8tEj6rW3r5Sirif+qswA==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.42.tgz}
peerDependencies: peerDependencies:
'@ant-design/icons': ^6.1.0 '@ant-design/icons': ^6.1.0
'@ant-design/pro-components': 3.1.0-0 '@ant-design/pro-components': 3.1.0-0
@@ -10802,7 +10802,7 @@ snapshots:
'@formatjs/intl-utils@2.3.0': {} '@formatjs/intl-utils@2.3.0': {}
'@gpustack/core-ui@1.0.35(czdvzceysqw7iv6pct2ucnb23e)': '@gpustack/core-ui@1.0.42(czdvzceysqw7iv6pct2ucnb23e)':
dependencies: dependencies:
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+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 './table.less';
@import './alert.less';
.m-b-20 { .m-b-20 {
margin-bottom: 20px; margin-bottom: 20px;
+31
View File
@@ -9,6 +9,15 @@
} }
} }
@keyframes tableEmptyFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.scroll-table { .scroll-table {
.ant-table { .ant-table {
.ant-table-container { .ant-table-container {
@@ -18,5 +27,27 @@
scrollbar-color: var(--color-scrollbar-thumb) transparent; 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 { GPUStackVersionAtom } from '@/atoms/user';
import { getAtomStorage } from '@/atoms/utils';
import VersionInfo, { modalConfig } from '@/components/version-info'; import VersionInfo, { modalConfig } from '@/components/version-info';
import externalLinks from '@/constants/external-links'; import externalLinks from '@/constants/external-links';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button, Divider, Modal, Typography } from 'antd'; import { Button, Divider, Modal, Typography } from 'antd';
import { createStyles } from 'antd-style'; import { createStyles } from 'antd-style';
import { useAtomValue } from 'jotai';
import styled from 'styled-components'; import styled from 'styled-components';
const CompanyWrapper = styled.div` const CompanyWrapper = styled.div`
@@ -35,6 +35,7 @@ const Footer: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const [modal, contextHolder] = Modal.useModal(); const [modal, contextHolder] = Modal.useModal();
const { styles } = useStyles(); const { styles } = useStyles();
const version = useAtomValue(GPUStackVersionAtom);
const showVersion = () => { const showVersion = () => {
modal.info({ modal.info({
@@ -73,7 +74,7 @@ const Footer: React.FC = () => {
</Button> </Button>
<Divider orientation="vertical" /> <Divider orientation="vertical" />
<Button type="link" size="small" onClick={showVersion}> <Button type="link" size="small" onClick={showVersion}>
{getAtomStorage(GPUStackVersionAtom)?.version} {version?.version}
</Button> </Button>
</div> </div>
</div> </div>
+3 -2
View File
@@ -1,6 +1,7 @@
import Logo from '@/assets/images/gpustack-logo.png'; import Logo from '@/assets/images/gpustack-logo.png';
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user'; import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
import externalLinks from '@/constants/external-links'; import externalLinks from '@/constants/external-links';
import { useLogo } from '@/hooks/use-logo';
import { Button } from 'antd'; import { Button } from 'antd';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import React from 'react'; import React from 'react';
@@ -18,7 +19,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
isProd, isProd,
isDev isDev
} = gpuStackVersionAtom; } = gpuStackVersionAtom;
const { sidebarLogo } = useLogo();
// user info // user info
const { is_admin } = userDataAtom || {}; const { is_admin } = userDataAtom || {};
@@ -30,7 +31,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
return ( return (
<div className="version-box"> <div className="version-box">
<div className="img"> <div className="img">
<img src={Logo} alt="logo" /> <img src={sidebarLogo || Logo} alt="logo" />
</div> </div>
<div className="ver"> <div className="ver">
+4
View File
@@ -43,6 +43,10 @@ export default {
DatePicker: { DatePicker: {
fontSizeLG: 14 fontSizeLG: 14
}, },
Alert: {
withDescriptionPadding: '12px 16px',
withDescriptionIconSize: 18
},
Menu: { Menu: {
iconSize: 16, iconSize: 16,
iconMarginInlineEnd: 12, iconMarginInlineEnd: 12,
+10 -3
View File
@@ -26,8 +26,8 @@ export default {
cellPaddingInline: 16, cellPaddingInline: 16,
cellPaddingBlock: 6, cellPaddingBlock: 6,
cellFontSize: 14, cellFontSize: 14,
rowSelectedHoverBg: 'rgb(249 249 249)', rowSelectedHoverBg: 'rgb(247 247 247)',
rowHoverBg: 'rgb(249 249 249)', rowHoverBg: 'rgb(247 247 247)',
rowSelectedBg: 'transparent', rowSelectedBg: 'transparent',
headerSortActiveBg: 'transparent', headerSortActiveBg: 'transparent',
headerSortHoverBg: 'transparent', headerSortHoverBg: 'transparent',
@@ -47,6 +47,13 @@ export default {
DatePicker: { DatePicker: {
fontSizeLG: 14 fontSizeLG: 14
}, },
Alert: {
withDescriptionPadding: '12px 16px',
withDescriptionIconSize: 18
},
Card: {
headerHeight: 50
},
Menu: { Menu: {
iconSize: 16, iconSize: 16,
iconMarginInlineEnd: 12, iconMarginInlineEnd: 12,
@@ -116,7 +123,7 @@ export default {
borderRadiusLG: 6, borderRadiusLG: 6,
borderRadius: 4, borderRadius: 4,
borderRadiusSM: 3, borderRadiusSM: 3,
colorBgContainer: '#fff', colorBgContainer: '#fdfdfd',
fontSize: 14, fontSize: 14,
motion: true motion: true
} }
+2 -15
View File
@@ -21,8 +21,6 @@ html {
--color-fill-sider: #f4f5f4; --color-fill-sider: #f4f5f4;
--color-bg-1: #f4f5f4; --color-bg-1: #f4f5f4;
--color-scroll-bg: #d9d9d9; --color-scroll-bg: #d9d9d9;
--color-fill-2: #fff;
--color-fill-3: #f3f6fa;
--color-logs-bg: #1e1e1e; --color-logs-bg: #1e1e1e;
--color-logs-text: #d4d4d4; --color-logs-text: #d4d4d4;
--layout-content-blockpadding: 24px; --layout-content-blockpadding: 24px;
@@ -76,7 +74,6 @@ html {
--ant-rate-star-color: #fadb14; --ant-rate-star-color: #fadb14;
--color-fill-spin-bg: rgba(255, 255, 255, 15%); --color-fill-spin-bg: rgba(255, 255, 255, 15%);
--width-tooltip-max: 420px; --width-tooltip-max: 420px;
--color-bg-tooltip: '#fff';
--color-modal-content-bg: rgba(255, 255, 255, 90%); --color-modal-content-bg: rgba(255, 255, 255, 90%);
--color-modal-box-shadow: 0 4px 16px rgba(0, 0, 0, 10%); --color-modal-box-shadow: 0 4px 16px rgba(0, 0, 0, 10%);
--color-spotlight-bg: rgba(255, 255, 255, 100%); --color-spotlight-bg: rgba(255, 255, 255, 100%);
@@ -91,6 +88,7 @@ html {
// ======== container ============ // ======== container ============
--color-border-container: #ededed; --color-border-container: #ededed;
--color-text-table-header: #71717a; --color-text-table-header: #71717a;
--seal-table-row-min-height: 68px;
} }
html[data-theme='realDark'] { html[data-theme='realDark'] {
@@ -100,7 +98,6 @@ html[data-theme='realDark'] {
--color-editor-dark: #00101f; --color-editor-dark: #00101f;
--color-editor-light: #fafafa; --color-editor-light: #fafafa;
--color-fill-spin-bg: rgba(55, 55, 55, 50%); --color-fill-spin-bg: rgba(55, 55, 55, 50%);
--color-bg-tooltip: #424242;
--color-editor-header-bg: #292929; --color-editor-header-bg: #292929;
--color-progress-text: rgba(255, 255, 255, 80%); --color-progress-text: rgba(255, 255, 255, 80%);
--color-modal-content-bg: #1f1f1f; --color-modal-content-bg: #1f1f1f;
@@ -115,19 +112,9 @@ html[data-theme='realDark'] {
.ant-result-image { .ant-result-image {
opacity: 0.9; opacity: 0.9;
} }
.ant-pro-page-container-affix .ant-pro-page-container-warp {
background-color: #292929 !important;
transition: none !important;
}
} }
html[data-theme='light'] { html[data-theme='light'] {
.ant-pro-page-container-affix .ant-pro-page-container-warp {
background-color: #fff !important;
transition: none !important;
}
background-color: #f4f5f6; background-color: #f4f5f6;
} }
@@ -210,7 +197,7 @@ body {
tr > td { tr > td {
border-bottom: none; border-bottom: none;
height: 68px; height: var(--seal-table-row-min-height);
} }
} }
+27 -13
View File
@@ -8,24 +8,38 @@ const findValidJSONStrings = (inputStr: string) => {
const openingBraceIndex = inputStr.indexOf('{', startIndex); const openingBraceIndex = inputStr.indexOf('{', startIndex);
if (openingBraceIndex === -1) break; // No more opening braces if (openingBraceIndex === -1) break; // No more opening braces
let closingBraceIndex = openingBraceIndex; // find the matching closing brace, ignoring braces inside string
// literals (e.g. a state_message containing `{`/`}`)
let closingBraceIndex = -1;
let braceCount = 0; let braceCount = 0;
let inString = false;
let escaped = false;
// find couple of braces for (let i = openingBraceIndex; i < inputStr.length; i++) {
while (closingBraceIndex < inputStr.length) { const char = inputStr[i];
if (inputStr[closingBraceIndex] === '{') { if (inString) {
braceCount++; if (escaped) {
} else if (inputStr[closingBraceIndex] === '}') { escaped = false;
braceCount--; } else if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = false;
} }
} else if (char === '"') {
inString = true;
} else if (char === '{') {
braceCount++;
} else if (char === '}') {
braceCount--;
if (braceCount === 0) { if (braceCount === 0) {
closingBraceIndex = i;
break; break;
} }
closingBraceIndex++; }
} }
if (braceCount !== 0) { if (closingBraceIndex === -1) {
// no matching closing brace // no matching closing brace yet, wait for more data
break; break;
} }
@@ -37,11 +51,11 @@ const findValidJSONStrings = (inputStr: string) => {
try { try {
const parsedData = JSON.parse(jsonString); const parsedData = JSON.parse(jsonString);
validJSONStrings.push(parsedData); validJSONStrings.push(parsedData);
startIndex = closingBraceIndex + 1;
} catch (error) { } catch (error) {
// mabye invalid JSON // skip the malformed segment instead of breaking, otherwise it jams
break; // the buffer and every later event on this stream is lost
} }
startIndex = closingBraceIndex + 1;
} }
return { return {
+4 -1
View File
@@ -43,8 +43,11 @@ export const createAxiosToken = (): CancelTokenSource => {
}; };
export const sliceData = (data: string, loaded: number, loadedSize: any) => { export const sliceData = (data: string, loaded: number, loadedSize: any) => {
// `loaded` is a byte count while `data` is a UTF-16 string; with any
// non-ASCII payload the two drift apart, so track consumed characters by
// string length only
const result = data.slice(loadedSize.current); const result = data.slice(loadedSize.current);
loadedSize.current = loaded; loadedSize.current = data.length;
return result; return result;
}; };
+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 ( const queryAllDataList = async (
params: Global.SearchParams, params: Global.SearchParams,
options?: any options?: any
@@ -78,14 +83,15 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
useEffect(() => { useEffect(() => {
createWatchChunkRequest(); createWatchChunkRequest();
return () => { return () => {
chunkRequestRef.current?.cancel?.(); cancelWatch();
listRequestTokenRef.current?.cancel?.();
}; };
}, []); }, []);
return { return {
watchDataList, watchDataList,
setWatchDataList, setWatchDataList,
startWatch: createWatchChunkRequest,
cancelWatch,
deleteItemFromCache: handleDeleteItemFromCache deleteItemFromCache: handleDeleteItemFromCache
}; };
} }
+4
View File
@@ -4,6 +4,7 @@ import VersionInfo, { modalConfig } from '@/components/version-info';
import externalLinks from '@/constants/external-links'; import externalLinks from '@/constants/external-links';
import useBodyScroll from '@/hooks/use-body-scroll'; import useBodyScroll from '@/hooks/use-body-scroll';
import { logout } from '@/pages/login/apis'; import { logout } from '@/pages/login/apis';
import { getGPUStackPlugin } from '@/plugins';
import { useModel } from '@@/plugin-model'; import { useModel } from '@@/plugin-model';
import { import {
DiscordOutlined, DiscordOutlined,
@@ -98,6 +99,7 @@ const CustomItem = styled.div`
export const ExtraContent = (props: { isDarkTheme?: boolean }) => { export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
const { isDarkTheme } = props; const { isDarkTheme } = props;
const plugin = getGPUStackPlugin();
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll(); const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [modal, contextHolder] = Modal.useModal(); const [modal, contextHolder] = Modal.useModal();
const [version] = useAtom(GPUStackVersionAtom); const [version] = useAtom(GPUStackVersionAtom);
@@ -286,6 +288,7 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
</NewLabel> </NewLabel>
)} )}
</div> </div>
{!plugin && (
<DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}> <DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}>
<IconWrapper> <IconWrapper>
<IconFont <IconFont
@@ -295,6 +298,7 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
/> />
</IconWrapper> </IconWrapper>
</DropdownActions> </DropdownActions>
)}
<PluginExtraField name="GlobalSettings" /> <PluginExtraField name="GlobalSettings" />
<DropdownActions menu={{ ...userMenu }} popupRender={userPopupRender}> <DropdownActions menu={{ ...userMenu }} popupRender={userPopupRender}>
<IconWrapper> <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.', 'On the Worker that needs to be added, run the following command to join it to the cluster.',
'clusters.create.addCommand.k8s.tips': '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.', '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': 'clusters.create.register.tips':
'On the Kubernetes cluster that needs to be added, run the following command to join its nodes to the cluster.', 'On the Kubernetes cluster that needs to be added, run the following command to join its nodes to the cluster.',
'cluster.create.checkEnv.tips': 'cluster.create.checkEnv.tips':
+1
View File
@@ -2,6 +2,7 @@ export default {
'gpuservice.template': 'GPU Instance Template', 'gpuservice.template': 'GPU Instance Template',
'gpuservice.template.add': 'Add Instance Template', 'gpuservice.template.add': 'Add Instance Template',
'gpuservice.template.edit': 'Edit Instance Template', 'gpuservice.template.edit': 'Edit Instance Template',
'gpuservice.template.clone': 'Clone Instance Template',
'gpuservice.template.filter.name': 'Filter by name', 'gpuservice.template.filter.name': 'Filter by name',
'gpuservice.template.filter.vendor': 'Filter by vendor', 'gpuservice.template.filter.vendor': 'Filter by vendor',
'gpuservice.template.image': 'Image', 'gpuservice.template.image': 'Image',
+1 -1
View File
@@ -13,7 +13,7 @@ export default {
'menu.models.modelCatalog': 'Catalog', 'menu.models.modelCatalog': 'Catalog',
'menu.models.catalog': 'Model Catalog', 'menu.models.catalog': 'Model Catalog',
'menu.models.deployment': 'Deployments', 'menu.models.deployment': 'Deployments',
'menu.models.userModels': 'My Models', 'menu.models.userModels': 'Models',
'menu.models.benchmark': 'Benchmarks', 'menu.models.benchmark': 'Benchmarks',
'menu.models.benchmarkDetail': 'Benchmark Details', 'menu.models.benchmarkDetail': 'Benchmark Details',
'menu.models.providers': 'Providers', '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.', 'On the Worker that needs to be added, run the following command to join it to the cluster.',
'clusters.create.addCommand.k8s.tips': 'clusters.create.addCommand.k8s.tips':
'登録する Kubernetes クラスターで以下のコマンドを実行し、Kubernetes リソースを作成してクラスターを登録します。', '登録する Kubernetes クラスターで以下のコマンドを実行し、Kubernetes リソースを作成してクラスターを登録します。',
'clusters.create.addCommand.k8s.version.warning':
'サポートされる Kubernetes の最小バージョンは 1.23 です。GPU Service 機能を使用する場合、サポートされる Kubernetes の最小バージョンは 1.27 です。',
'cluster.create.checkEnv.tips': 'cluster.create.checkEnv.tips':
'Use the following command to check if the environment is ready.', 'Use the following command to check if the environment is ready.',
'clusters.create.register.tips': 'clusters.create.register.tips':
+1
View File
@@ -2,6 +2,7 @@ export default {
'gpuservice.template': 'GPU インスタンステンプレート', 'gpuservice.template': 'GPU インスタンステンプレート',
'gpuservice.template.add': 'インスタンステンプレートを追加', 'gpuservice.template.add': 'インスタンステンプレートを追加',
'gpuservice.template.edit': 'インスタンステンプレートを編集', 'gpuservice.template.edit': 'インスタンステンプレートを編集',
'gpuservice.template.clone': 'インスタンステンプレートを複製',
'gpuservice.template.filter.name': '名前でフィルター', 'gpuservice.template.filter.name': '名前でフィルター',
'gpuservice.template.filter.vendor': 'ベンダーでフィルター', 'gpuservice.template.filter.vendor': 'ベンダーでフィルター',
'gpuservice.template.image': 'コンテナイメージ', 'gpuservice.template.image': 'コンテナイメージ',
+6 -6
View File
@@ -41,7 +41,7 @@ export default {
'menu.accessControl.organizations': 'Organizations', 'menu.accessControl.organizations': 'Organizations',
'menu.resources.clusters': 'Clusters', 'menu.resources.clusters': 'Clusters',
'menu.resources.credentials': 'Cloud Credentials', 'menu.resources.credentials': 'Cloud Credentials',
'menu.models.userModels': 'My Models', 'menu.models.userModels': 'Models',
'menu.resources.clusterDetail': 'Cluster Detail', 'menu.resources.clusterDetail': 'Cluster Detail',
'menu.resources.clusterCreate': 'Create Cluster', 'menu.resources.clusterCreate': 'Create Cluster',
'menu.models.backendsList': 'Inference Backends', 'menu.models.backendsList': 'Inference Backends',
@@ -62,11 +62,11 @@ export default {
// 6. 'menu.accessControl.apikeys': 'API Keys', // 6. 'menu.accessControl.apikeys': 'API Keys',
// 7. 'menu.accessControl.users': 'Users', // 7. 'menu.accessControl.users': 'Users',
// 8. 'menu.clusterManagement': 'Cluster Management', // 8. 'menu.clusterManagement': 'Cluster Management',
// 9. 'menu.clusterManagement.clusters': 'Clusters', // 9. 'menu.resources.clusters': 'Clusters',
// 10. 'menu.clusterManagement.credentials': 'Cloud Credentials', // 10. 'menu.resources.credentials': 'Cloud Credentials',
// 11. 'menu.models.userModels': 'My Models' // 11. 'menu.models.userModels': 'Models'
// 12. 'menu.clusterManagement.clusterDetail': 'Cluster Detail', // 12. 'menu.resources.clusterDetail': 'Cluster Detail',
// 13. 'menu.clusterManagement.clusterCreate': 'Create Cluster', // 13. 'menu.resources.clusterCreate': 'Create Cluster',
// 14. 'menu.models.backendsList': 'Inference Backends', // 14. 'menu.models.backendsList': 'Inference Backends',
// 15. 'menu.models.benchmark': 'Benchmarks', // 15. 'menu.models.benchmark': 'Benchmarks',
// 15. 'menu.models.provider': 'Provider', // 15. 'menu.models.provider': 'Provider',
+2
View File
@@ -44,6 +44,8 @@ export default {
'На воркере, который необходимо добавить, выполните следующую команду, чтобы присоединить его к кластеру.', 'На воркере, который необходимо добавить, выполните следующую команду, чтобы присоединить его к кластеру.',
'clusters.create.addCommand.k8s.tips': 'clusters.create.addCommand.k8s.tips':
'На Kubernetes-кластере, который необходимо зарегистрировать, выполните следующую команду, чтобы создать ресурсы Kubernetes и зарегистрировать этот кластер.', 'На Kubernetes-кластере, который необходимо зарегистрировать, выполните следующую команду, чтобы создать ресурсы Kubernetes и зарегистрировать этот кластер.',
'clusters.create.addCommand.k8s.version.warning':
'Минимальная поддерживаемая версия Kubernetes — 1.23. Для использования функции GPU Service минимальная поддерживаемая версия Kubernetes — 1.27.',
'cluster.create.checkEnv.tips': 'cluster.create.checkEnv.tips':
'Используйте следующую команду для проверки готовности окружения', 'Используйте следующую команду для проверки готовности окружения',
'clusters.create.register.tips': 'clusters.create.register.tips':
+1
View File
@@ -2,6 +2,7 @@ export default {
'gpuservice.template': 'Шаблон экземпляра GPU', 'gpuservice.template': 'Шаблон экземпляра GPU',
'gpuservice.template.add': 'Добавить шаблон экземпляра', 'gpuservice.template.add': 'Добавить шаблон экземпляра',
'gpuservice.template.edit': 'Редактировать шаблон экземпляра', 'gpuservice.template.edit': 'Редактировать шаблон экземпляра',
'gpuservice.template.clone': 'Клонировать шаблон экземпляра',
'gpuservice.template.filter.name': 'Фильтр по имени', 'gpuservice.template.filter.name': 'Фильтр по имени',
'gpuservice.template.filter.vendor': 'Фильтр по производителю', 'gpuservice.template.filter.vendor': 'Фильтр по производителю',
'gpuservice.template.image': 'Образ', 'gpuservice.template.image': 'Образ',
+1 -1
View File
@@ -40,7 +40,7 @@ export default {
'menu.accessControl.organizations': 'Организации', 'menu.accessControl.organizations': 'Организации',
'menu.resources.clusters': 'Кластеры', 'menu.resources.clusters': 'Кластеры',
'menu.resources.credentials': 'Облачные аккаунты', 'menu.resources.credentials': 'Облачные аккаунты',
'menu.models.userModels': 'Мои модели', 'menu.models.userModels': 'Модели',
'menu.resources.clusterDetail': 'Детали кластера', 'menu.resources.clusterDetail': 'Детали кластера',
'menu.resources.clusterCreate': 'Создать кластер', 'menu.resources.clusterCreate': 'Создать кластер',
'menu.models.backendsList': 'Бэкенды запуска', '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.', 'Eklenmesi gereken İşçi Düğümde, kümeye katılması için aşağıdaki komutu çalıştırın.',
'clusters.create.addCommand.k8s.tips': '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.', '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': '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.', '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': 'cluster.create.checkEnv.tips':
+1
View File
@@ -2,6 +2,7 @@ export default {
'gpuservice.template': 'GPU Örnek Şablonu', 'gpuservice.template': 'GPU Örnek Şablonu',
'gpuservice.template.add': 'Örnek Şablonu Ekle', 'gpuservice.template.add': 'Örnek Şablonu Ekle',
'gpuservice.template.edit': 'Örnek Şablonunu Düzenle', 'gpuservice.template.edit': 'Örnek Şablonunu Düzenle',
'gpuservice.template.clone': 'Örnek Şablonunu Klonla',
'gpuservice.template.filter.name': 'Ada göre filtrele', 'gpuservice.template.filter.name': 'Ada göre filtrele',
'gpuservice.template.filter.vendor': 'Tedarikçiye göre filtrele', 'gpuservice.template.filter.vendor': 'Tedarikçiye göre filtrele',
'gpuservice.template.image': 'İmaj', 'gpuservice.template.image': 'İmaj',
+2 -2
View File
@@ -13,12 +13,12 @@ export default {
'menu.models.modelCatalog': 'Katalog', 'menu.models.modelCatalog': 'Katalog',
'menu.models.catalog': 'Model Kataloğu', 'menu.models.catalog': 'Model Kataloğu',
'menu.models.deployment': 'Dağıtımlar', 'menu.models.deployment': 'Dağıtımlar',
'menu.models.userModels': 'Modellerim', 'menu.models.userModels': 'Modeller',
'menu.models.benchmark': 'Kıyaslamalar', 'menu.models.benchmark': 'Kıyaslamalar',
'menu.models.benchmarkDetail': 'Kıyaslama Detayları', 'menu.models.benchmarkDetail': 'Kıyaslama Detayları',
'menu.models.providers': 'Sağlayıcılar', 'menu.models.providers': 'Sağlayıcılar',
'menu.models.routes': 'Yönlendirmeler', 'menu.models.routes': 'Yönlendirmeler',
'menu.models.usage': 'Usage', 'menu.models.usage': 'Kullanım',
'menu.modelCatalog': 'Katalog', 'menu.modelCatalog': 'Katalog',
'menu.resources': 'Kaynaklar', 'menu.resources': 'Kaynaklar',
'menu.apikeys': 'API Anahtarları', 'menu.apikeys': 'API Anahtarları',
+2
View File
@@ -43,6 +43,8 @@ export default {
'在需要添加的节点上运行以下命令,将其加入到集群中。', '在需要添加的节点上运行以下命令,将其加入到集群中。',
'clusters.create.addCommand.k8s.tips': 'clusters.create.addCommand.k8s.tips':
'在需要注册的 Kubernetes 集群中运行以下命令,创建 Kubernetes 资源,注册该集群。', '在需要注册的 Kubernetes 集群中运行以下命令,创建 Kubernetes 资源,注册该集群。',
'clusters.create.addCommand.k8s.version.warning':
'Kubernetes 版本最低支持 1.23,如果要使用 GPU Service 功能,Kubernetes 版本最低支持 1.27。',
'clusters.create.register.tips': 'clusters.create.register.tips':
'在需要添加的 Kubernetes 集群上运行以下命令,将其中的节点加入到集群中。', '在需要添加的 Kubernetes 集群上运行以下命令,将其中的节点加入到集群中。',
'cluster.create.checkEnv.tips': '使用以下命令检查环境是否准备妥当。', 'cluster.create.checkEnv.tips': '使用以下命令检查环境是否准备妥当。',
+1
View File
@@ -2,6 +2,7 @@ export default {
'gpuservice.template': 'GPU 实例模板', 'gpuservice.template': 'GPU 实例模板',
'gpuservice.template.add': '添加实例模板', 'gpuservice.template.add': '添加实例模板',
'gpuservice.template.edit': '编辑实例模板', 'gpuservice.template.edit': '编辑实例模板',
'gpuservice.template.clone': '克隆实例模板',
'gpuservice.template.filter.name': '按名称过滤', 'gpuservice.template.filter.name': '按名称过滤',
'gpuservice.template.filter.vendor': '按厂商过滤', 'gpuservice.template.filter.vendor': '按厂商过滤',
'gpuservice.template.image': '镜像', 'gpuservice.template.image': '镜像',
+1 -1
View File
@@ -12,7 +12,7 @@ export default {
'menu.models.modelList': '部署与管理', 'menu.models.modelList': '部署与管理',
'menu.models.modelCatalog': '模型库', 'menu.models.modelCatalog': '模型库',
'menu.models.deployment': '部署', 'menu.models.deployment': '部署',
'menu.models.userModels': '我的模型', 'menu.models.userModels': '模型广场',
'menu.models.benchmark': '基准测试', 'menu.models.benchmark': '基准测试',
'menu.models.benchmarkDetail': '基准测试详情', 'menu.models.benchmarkDetail': '基准测试详情',
'menu.models.providers': '提供商', '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, opacity: 1,
paddingInline: 8, paddingInline: 8,
borderRadius: 12, borderRadius: 12,
transform: 'scale(0.9)' transform: 'scale(0.9)',
backgroundColor: 'transparent'
}} }}
color={config.color} color={config.color}
> >
+9 -29
View File
@@ -6,10 +6,12 @@ import {
AutoTooltip, AutoTooltip,
DropdownButtons, DropdownButtons,
IconFont, IconFont,
icons icons,
TextAttribute,
ThemeTag
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { MenuProps, Tag, Tooltip } from 'antd'; import { MenuProps, Tooltip } from 'antd';
import { ColumnsType } from 'antd/lib/table'; import { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -139,23 +141,14 @@ const useModelsColumns = ({
key: 'name', key: 'name',
sorter: tableSorter(1), sorter: tableSorter(1),
render: (text: string, record: ListItem) => ( render: (text: string, record: ListItem) => (
<span className="flex items-center"> <span className="flex items-center gap-8">
<AutoTooltip ghost style={{ maxWidth: 400 }} title={text}> <AutoTooltip ghost style={{ maxWidth: 400 }} title={text}>
<span className="text-primary">{text}</span> <span className="text-primary">{text}</span>
</AutoTooltip> </AutoTooltip>
{record.is_custom && ( {record.is_custom && (
<Tag <TextAttribute>
style={{
marginLeft: 8,
borderRadius: 12,
color: 'var(--ant-color-text-tertiary)',
borderColor: 'var(--ant-color-split)',
backgroundColor: 'transparent'
}}
variant="outlined"
>
{intl.formatMessage({ id: 'playground.params.custom' })} {intl.formatMessage({ id: 'playground.params.custom' })}
</Tag> </TextAttribute>
)} )}
</span> </span>
) )
@@ -203,26 +196,13 @@ const useModelsColumns = ({
)} )}
{(record.scope?.includes('inference') || {(record.scope?.includes('inference') ||
record.scope?.includes('*')) && ( record.scope?.includes('*')) && (
<div <ThemeTag>
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'
}}
>
<AutoTooltip ghost> <AutoTooltip ghost>
{record.allowed_model_names?.length {record.allowed_model_names?.length
? record.allowed_model_names.join(', ') ? record.allowed_model_names.join(', ')
: intl.formatMessage({ id: 'apikeys.models.all' })} : intl.formatMessage({ id: 'apikeys.models.all' })}
</AutoTooltip> </AutoTooltip>
</div> </ThemeTag>
)} )}
</div> </div>
) )
+6 -1
View File
@@ -183,11 +183,15 @@ const APIKeys: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
image={<IconFont type="icon-key" />} 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({ noFoundText={intl.formatMessage({
id: 'noresult.keys.nofound' id: 'noresult.keys.nofound'
})} })}
@@ -228,6 +232,7 @@ const APIKeys: React.FC = () => {
></FilterBar> ></FilterBar>
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
rowSelection={rowSelection} rowSelection={rowSelection}
@@ -120,6 +120,11 @@ const CommunityBackends: React.FC<{
const renderItem = (item: ListItem) => { const renderItem = (item: ListItem) => {
return ( return (
<div
style={{
backgroundColor: 'var(--ant-color-bg-container)'
}}
>
<BackendCard <BackendCard
active={currentData?.id === item.id} active={currentData?.id === item.id}
data={item} data={item}
@@ -127,6 +132,7 @@ const CommunityBackends: React.FC<{
onClick={handleOnClickItem} onClick={handleOnClickItem}
actionsRenderer={actionsRenderer} actionsRenderer={actionsRenderer}
/> />
</div>
); );
}; };
@@ -164,6 +170,7 @@ const CommunityBackends: React.FC<{
renderItem={renderItem} renderItem={renderItem}
></BackendCardList> ></BackendCardList>
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
+1 -1
View File
@@ -45,7 +45,7 @@ const Title = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: var(--ant-color-bg-container); background-color: var(--ant-color-bg-elevated);
font-weight: 600; font-weight: 600;
padding-top: 8px; padding-top: 8px;
padding-bottom: 8px; padding-bottom: 8px;
+1
View File
@@ -282,6 +282,7 @@ const BackendList = () => {
onSelect={handleOnSelect} onSelect={handleOnSelect}
></BackendCardList> ></BackendCardList>
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
+1
View File
@@ -177,6 +177,7 @@ const Benchmark: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={[]} dataSource={[]}
+14 -13
View File
@@ -5,6 +5,7 @@ import type { PageActionType } from '@/config/types';
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys'; import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
import useTableFetch from '@/hooks/use-table-fetch'; import useTableFetch from '@/hooks/use-table-fetch';
import useWatchList from '@/hooks/use-watch-list'; import useWatchList from '@/hooks/use-watch-list';
import { getGPUStackPlugin } from '@/plugins';
import { import {
DeleteModal, DeleteModal,
FilterBar, FilterBar,
@@ -14,7 +15,7 @@ import {
TableOrder, TableOrder,
TableProvider TableProvider
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl, useNavigate } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { message } from 'antd'; import { message } from 'antd';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
@@ -73,7 +74,7 @@ const Clusters: React.FC = () => {
deleteAPI: deleteCluster, deleteAPI: deleteCluster,
watch: true, watch: true,
API: CLUSTERS_API, API: CLUSTERS_API,
contentForDelete: 'menu.clusterManagement.clusters', contentForDelete: 'menu.resources.clusters',
defaultQueryParams: { defaultQueryParams: {
// Management view: drop cross-Org cluster_access grants. Org // Management view: drop cross-Org cluster_access grants. Org
// Owner only sees the clusters they own here. Pickers that // Owner only sees the clusters they own here. Pickers that
@@ -82,10 +83,13 @@ const Clusters: React.FC = () => {
mine: true mine: true
} }
}); });
const navigate = useNavigate();
const { goToGrafana, ActionButton } = useGranfanaLink({ const { goToGrafana, ActionButton } = useGranfanaLink({
type: 'cluster' 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 { watchDataList: allWorkerPoolList } = useWatchList(WORKER_POOLS_API);
const [expandAtom] = useAtom(expandKeysAtom); const [expandAtom] = useAtom(expandKeysAtom);
const [clusterSession, setClusterSession] = useAtom(clusterSessionAtom); 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(() => { useEffect(() => {
const fetchCredentialList = async () => { const fetchCredentialList = async () => {
const data = await queryCredentialList({ page: -1 }); const data = await queryCredentialList({ page: -1 });
@@ -362,11 +358,14 @@ const Clusters: React.FC = () => {
dataList={list} dataList={list}
provider={options.parent?.provider} provider={options.parent?.provider}
clusterId={options.parent?.id} clusterId={options.parent?.id}
gridTemplate={options.gridTemplate}
prefixWidth={options.prefixWidth}
columns={options.columns}
/> />
); );
}; };
const columns = useClusterColumns(handleSelect, handleOnCell); const columns = useClusterColumns(handleSelect);
return ( return (
<> <>
@@ -399,6 +398,7 @@ const Clusters: React.FC = () => {
> >
<SealTable <SealTable
rowKey="id" rowKey="id"
emptyMinHeight="calc(100vh - 300px)"
loadChildren={getWorkerPoolList} loadChildren={getWorkerPoolList}
sortDirections={TABLE_SORT_DIRECTIONS} sortDirections={TABLE_SORT_DIRECTIONS}
expandedRowKeys={expandedRowKeys} expandedRowKeys={expandedRowKeys}
@@ -420,7 +420,7 @@ const Clusters: React.FC = () => {
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
image={<IconFont type="icon-cluster-outline" />} image={<IconFont type="icon-cluster-outline" />}
filters={_.omit(queryParams, ['sort_by'])} filters={_.omit(queryParams, ['sort_by', 'mine'])}
noFoundText={intl.formatMessage({ noFoundText={intl.formatMessage({
id: 'noresult.cluster.nofound' id: 'noresult.cluster.nofound'
})} })}
@@ -482,6 +482,7 @@ const Clusters: React.FC = () => {
onClose={handleClusterModalClose} onClose={handleClusterModalClose}
></ClusterModal> ></ClusterModal>
{AddWorkerModal} {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 { useIntl } from '@umijs/max';
import { Typography } from 'antd'; import { Typography } from 'antd';
import RegisterClusterInner from '../register-cluster-inner'; import RegisterClusterInner from '../register-cluster-inner';
@@ -34,6 +36,14 @@ const K8sRunCommand: React.FC<AddWorkerStepProps> = ({ disabled }) => {
id: 'clusters.create.addCommand.k8s.tips' id: 'clusters.create.addCommand.k8s.tips'
})} })}
</Typography.Paragraph> </Typography.Paragraph>
<AlertBlockInfo
type="warning"
style={{ marginBottom: 8 }}
icon={<ExclamationCircleFilled />}
message={intl.formatMessage({
id: 'clusters.create.addCommand.k8s.version.warning'
})}
></AlertBlockInfo>
<RegisterClusterInner <RegisterClusterInner
registrationInfo={registrationInfo} registrationInfo={registrationInfo}
currentGPU={currentGPU} currentGPU={currentGPU}
@@ -59,6 +59,12 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const intl = useIntl(); const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]); const [activeKey, setActiveKey] = React.useState<string[]>([]);
const [submitAttempted, setSubmitAttempted] = useState(false); 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 advanceConfigRef = React.useRef<any>(null);
const systemConfig = useAtomValue(systemConfigAtom); const systemConfig = useAtomValue(systemConfigAtom);
@@ -87,6 +93,20 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const next: any = { ...opts }; 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; const creds = opts.imageCredentials;
if (Array.isArray(creds)) { if (Array.isArray(creds)) {
next.imageCredentials = creds.map((c: any) => ({ next.imageCredentials = creds.map((c: any) => ({
@@ -220,7 +240,9 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
}; };
return ( return (
<FormContext.Provider value={{ submitAttempted }}> <FormContext.Provider
value={{ submitAttempted, clusterType, setClusterType }}
>
<Form <Form
name="clusterForm" name="clusterForm"
form={form} form={form}
@@ -2,10 +2,12 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { Input as CInput, LabelSelector } from '@gpustack/core-ui'; import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { Form } from 'antd'; import { Form } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useEffect, useId, useMemo } from 'react'; import React, { useEffect, useId, useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useFormContext } from '../config/form-context';
import { useStepsContext } from '../config/steps-context'; import { useStepsContext } from '../config/steps-context';
import { ClusterListItem as ListItem } from '../config/types'; import { ClusterListItem as ListItem } from '../config/types';
import ImageCredential from './image-credential'; 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 // Visual parity with @gpustack/core-ui's SwitchCard so the selector blends
// in with surrounding form fields: same border, radius, padding, and // in with surrounding form fields: same border, radius, padding, and
// typography. The only differences are the two-column grid layout and an // 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 }>` const ClusterTypeCard = styled.div<{ $active: boolean }>`
position: relative;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 10px; 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 }>` const RadioDot = styled.span<{ $active: boolean }>`
position: relative; position: relative;
flex-shrink: 0; 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 // Card-based selector for cluster type. The two options are mutually exclusive
// and the choice maps directly to the presence/absence of `gpuInstanceOptions` // and the choice maps directly to the presence/absence of `gpuInstanceOptions`
// on the form — "model" clears it, "gpu" seeds it to {} (preserving any // on the submitted payload. No standalone form field is registered; the click
// already-entered static address). No standalone form field is registered; // only updates the shared `clusterType` state (see FormContext) — the payload's
// state is read via useWatch with `preserve: true` so it tracks updates made // `gpuInstanceOptions` shape is derived from it at submit (see cluster-form's
// through setFieldValue. // normalizeOutgoing), and the static-address field mounts/unmounts off it.
export const ClusterTypeSelector: React.FC = () => { export const ClusterTypeSelector: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance();
const { presetClusterType } = useStepsContext(); const { presetClusterType } = useStepsContext();
const labelId = useId(); const labelId = useId();
const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, { // Cluster type is shared, explicit state (see FormContext): the click is the
form, // source of truth. This replaced a Form.useWatch on an unregistered path that
preserve: true // did not re-render reliably when cleared to undefined.
}); const { clusterType, setClusterType } = useFormContext();
const value: 'model' | 'gpu' = gpuInstanceOptions ? 'gpu' : 'model'; const value: 'model' | 'gpu' = clusterType ?? 'model';
const handleSelect = (next: 'model' | 'gpu') => { const handleSelect = useMemoizedFn((next: 'model' | 'gpu') => {
if (!form || next === value) return; if (next === value) return;
if (next === 'gpu') { setClusterType?.(next);
form.setFieldValue( });
GPU_INSTANCE_OPTIONS_PATH,
form.getFieldValue(GPU_INSTANCE_OPTIONS_PATH) ?? {}
);
} else {
form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined);
}
};
const options: { const options: {
key: 'model' | 'gpu'; key: 'model' | 'gpu';
title: string; title: string;
description: string; description: string;
experimental?: boolean;
}[] = [ }[] = [
{ {
key: 'model', key: 'model',
@@ -209,7 +209,8 @@ export const ClusterTypeSelector: React.FC = () => {
{ {
key: 'gpu', key: 'gpu',
title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }), 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} /> <RadioDot $active={active} />
{opt.experimental && (
<ExperimentalTag>
{intl.formatMessage({ id: 'common.tag.experimental' })}
</ExperimentalTag>
)}
<div className="body"> <div className="body">
<div className="title">{opt.title}</div> <div className="title">{opt.title}</div>
<div className="description">{opt.description}</div> <div className="description">{opt.description}</div>
@@ -261,14 +267,13 @@ export const ClusterTypeSelector: React.FC = () => {
// default container registry and the worker config (节点配置). // default container registry and the worker config (节点配置).
export const GpuInstancesStaticAddressForm: React.FC = () => { export const GpuInstancesStaticAddressForm: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
// See note in ClusterTypeSelector: watch the full store so this field's // Visibility tracks the shared cluster-type state (see FormContext), so this
// visibility tracks the selector even before it has mounted its own // field mounts/unmounts deterministically with the selector. Its Form.Item is
// Form.Item. // the only thing keeping gpuInstanceOptions alive, so unmounting it here (with
const enabled = !!Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, { // the form's preserve={false}) also clears that path from the store.
preserve: true const { clusterType } = useFormContext();
});
if (!enabled) { if (clusterType !== 'gpu') {
return null; return null;
} }
@@ -2,13 +2,14 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { import {
CellContent, CellContent,
type ChildGridOptions,
DeleteModal, DeleteModal,
RowChildren, ExpandedRowGrid,
TableRowProvider TableRowProvider
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { Col, message, Row } from 'antd'; import { message } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { deleteWorkerPool, updateWorkerPool } from '../apis'; import { deleteWorkerPool, updateWorkerPool } from '../apis';
@@ -16,7 +17,10 @@ import { ProviderType } from '../config';
import { NodePoolFormData, NodePoolListItem } from '../config/types'; import { NodePoolFormData, NodePoolListItem } from '../config/types';
import usePoolsColumns from '../hooks/use-pools-columns'; import usePoolsColumns from '../hooks/use-pools-columns';
import AddPool from './add-pool'; import AddPool from './add-pool';
interface PoolRowsProps { interface PoolRowsProps extends Pick<
ChildGridOptions,
'gridTemplate' | 'prefixWidth' | 'columns'
> {
dataList: NodePoolListItem[]; dataList: NodePoolListItem[];
provider: ProviderType; provider: ProviderType;
clusterId: number | string; clusterId: number | string;
@@ -25,9 +29,35 @@ interface PoolRowsProps {
const PoolRows: React.FC<PoolRowsProps> = ({ const PoolRows: React.FC<PoolRowsProps> = ({
dataList, dataList,
provider, provider,
clusterId clusterId,
gridTemplate,
prefixWidth = 0,
columns: parentColumns
}) => { }) => {
const intl = useIntl(); 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 modalRef = useRef<any>(null);
const [addPoolStatus, setAddPoolStatus] = useState<{ const [addPoolStatus, setAddPoolStatus] = useState<{
open: boolean; open: boolean;
@@ -62,8 +92,10 @@ const PoolRows: React.FC<PoolRowsProps> = ({
} }
}; };
const handleOnCell = async (row: NodePoolListItem, dataIndex: string) => { const handleOnCell = async (
console.log('handleOncell===', row, dataIndex); row: NodePoolListItem,
_data: { dataIndex: string; newValue: any; oldValue: any }
) => {
try { try {
await updateWorkerPool({ await updateWorkerPool({
data: row, data: row,
@@ -118,31 +150,33 @@ const PoolRows: React.FC<PoolRowsProps> = ({
<> <>
{dataList?.map((data: NodePoolListItem) => { {dataList?.map((data: NodePoolListItem) => {
return ( return (
<div <TableRowProvider
key={data.id} key={data.id}
style={{ borderRadius: 'var(--ant-table-header-border-radius)' }} value={{ row: data, onCell: handleOnCell }}
> >
<TableRowProvider value={{ row: data, onCell: handleOnCell }}> <ExpandedRowGrid
<RowChildren> gridTemplate={gridTemplate}
<Row style={{ width: '100%' }} align="middle"> prefixWidth={prefixWidth}
{columns.map((col: Record<string, any>) => { >
return ( {columns.map((col: Record<string, any>) => (
<Col <ExpandedRowGrid.Cell
key={col.dataIndex || col.key} key={col.dataIndex || col.key}
span={col.span} span={spanFor(col.dataIndex)}
style={{ style={{
paddingInline: 0, color: 'var(--ant-color-text-secondary)',
...(col.style || {}) // CellContent shrinks to its content inside the flex
// cell, so its own align class can't center it —
// center at the cell level instead.
justifyContent: col.align
}} }}
> >
<CellContent {..._.omit(col, ['key'])}></CellContent> <CellContent
</Col> {..._.omit(col, ['key', 'style', 'span'])}
); ></CellContent>
})} </ExpandedRowGrid.Cell>
</Row> ))}
</RowChildren> </ExpandedRowGrid>
</TableRowProvider> </TableRowProvider>
</div>
); );
})} })}
<AddPool <AddPool
@@ -5,6 +5,12 @@ import { ClusterListItem } from './types';
interface FormContextProps { interface FormContextProps {
currentData?: ClusterListItem; currentData?: ClusterListItem;
submitAttempted?: boolean; 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>({}); export const FormContext = createContext<FormContextProps>({});
+3 -1
View File
@@ -58,7 +58,7 @@ const Credentials: React.FC = () => {
key: PaginationKey.Credentials, key: PaginationKey.Credentials,
fetchAPI: queryCredentialList, fetchAPI: queryCredentialList,
deleteAPI: deleteCredential, deleteAPI: deleteCredential,
contentForDelete: 'menu.clusterManagement.credentials' contentForDelete: 'menu.resources.credentials'
}); });
const [, setClusterSession] = useAtom(clusterSessionAtom); const [, setClusterSession] = useAtom(clusterSessionAtom);
const [isFromCluster, setIsFromCluster] = useAtom(fromClusterCreationAtom); const [isFromCluster, setIsFromCluster] = useAtom(fromClusterCreationAtom);
@@ -157,6 +157,7 @@ const Credentials: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={[]} dataSource={[]}
@@ -199,6 +200,7 @@ const Credentials: React.FC = () => {
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
tableLayout="fixed" tableLayout="fixed"
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -13,7 +13,7 @@ import {
type TableColumnProps as SealColumnProps type TableColumnProps as SealColumnProps
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Tooltip, Typography } from 'antd'; import { Tooltip } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -85,21 +85,15 @@ const clusterActionList = [
]; ];
const useClusterColumns = ( const useClusterColumns = (
handleSelect: (val: string, record: ClusterListItem, item?: any) => void, handleSelect: (val: string, record: ClusterListItem, item?: any) => void
onCellClick?: (record: ClusterListItem, dataIndex: string) => void
): SealColumnProps[] => { ): SealColumnProps[] => {
const intl = useIntl(); const intl = useIntl();
const systemConfig = useAtomValue(systemConfigAtom); const systemConfig = useAtomValue(systemConfigAtom);
const pluginCols = usePluginListColumns('clusters'); const pluginCols = usePluginListColumns('clusters');
// The cluster-detail page is shipped in OSS source, but OSS keeps // The cluster name is plain text: there is no cluster-detail page
// it unreachable from the cluster list — the link is only // to route into. A plugin may still contribute extra row actions
// surfaced when a plugin opts in via // (topology, Cluster Access) via `clusterDetail.useGenerateActions`.
// `clusterDetail.linkableName`. Without a plugin we render the const { useGenerateActions } = getGPUStackPlugin()?.clusterDetail || {};
// 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 || {};
const actionList = const actionList =
useGenerateActions?.({ actions: clusterActionList }) || clusterActionList; useGenerateActions?.({ actions: clusterActionList }) || clusterActionList;
@@ -159,14 +153,8 @@ const useClusterColumns = (
span: 3, span: 3,
render: (text: string, record: ClusterListItem) => ( render: (text: string, record: ClusterListItem) => (
<> <>
<AutoTooltip ghost title={text}> <AutoTooltip ghost title={text} minWidth={20}>
{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> </AutoTooltip>
{record.is_default && ( {record.is_default && (
<Tooltip <Tooltip
@@ -188,6 +176,7 @@ const useClusterColumns = (
dataIndex: 'provider', dataIndex: 'provider',
sorter: tableSorter(2), sorter: tableSorter(2),
span: spans.provider, span: spans.provider,
minWidth: 110,
render: (value: string) => ( render: (value: string) => (
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{ProviderLabelMap[value]} {ProviderLabelMap[value]}
@@ -197,7 +186,7 @@ const useClusterColumns = (
{ {
title: intl.formatMessage({ id: 'dashboard.totalgpus' }), title: intl.formatMessage({ id: 'dashboard.totalgpus' }),
dataIndex: 'gpus', dataIndex: 'gpus',
span: 2, width: 100,
sorter: tableSorter(3), sorter: tableSorter(3),
render: (value: number) => <span>{value}</span> render: (value: number) => <span>{value}</span>
}, },
@@ -206,13 +195,15 @@ const useClusterColumns = (
dataIndex: 'models', dataIndex: 'models',
sorter: tableSorter(4), sorter: tableSorter(4),
span: spans.deployments, span: spans.deployments,
maxWidth: 150,
render: (value: number) => <span>{value}</span> render: (value: number) => <span>{value}</span>
}, },
{ {
title: intl.formatMessage({ id: 'resources.nodes' }), title: intl.formatMessage({ id: 'resources.nodes' }),
dataIndex: 'workers', dataIndex: 'workers',
minWidth: 100,
maxWidth: 120,
sorter: tableSorter(5), sorter: tableSorter(5),
span: spans.workers,
render: (value: number, record: ClusterListItem) => ( render: (value: number, record: ClusterListItem) => (
<span> <span>
{record.ready_workers} / {record.workers} {record.ready_workers} / {record.workers}
@@ -223,6 +214,8 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'common.table.status' }), title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: 'state', dataIndex: 'state',
span: spans.status, span: spans.status,
minWidth: 80,
align: 'center',
render: (value: number, record: ClusterListItem) => ( render: (value: number, record: ClusterListItem) => (
<StatusTag <StatusTag
statusValue={{ statusValue={{
@@ -237,7 +230,7 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'common.table.createTime' }), title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at', dataIndex: 'created_at',
sorter: tableSorter(6), sorter: tableSorter(6),
span: spans.createTime, width: 180,
render: (value: string) => ( render: (value: string) => (
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{dayjs(value).format('YYYY-MM-DD HH:mm:ss')} {dayjs(value).format('YYYY-MM-DD HH:mm:ss')}
@@ -258,7 +251,7 @@ const useClusterColumns = (
) )
} }
]; ];
}, [handleSelect, onCellClick, intl, pluginCols]); }, [handleSelect, intl, pluginCols]);
}; };
export default useClusterColumns; export default useClusterColumns;
@@ -43,12 +43,9 @@ const usePoolsColumns = (
showTitle: false showTitle: false
}, },
span: 3, span: 3,
style: {
paddingInline: 'var(--ant-table-cell-padding-inline)'
},
render: (text: string) => ( render: (text: string) => (
<AutoTooltip title={text} ghost minWidth={20}> <AutoTooltip title={text} ghost minWidth={20}>
{text} <span className="text-primary">{text}</span>
</AutoTooltip> </AutoTooltip>
) )
}, },
@@ -60,9 +57,6 @@ const usePoolsColumns = (
showTitle: false showTitle: false
}, },
span: 4, span: 4,
style: {
paddingLeft: 62
},
render: (text: string, record: ListItem) => ( render: (text: string, record: ListItem) => (
<AutoTooltip <AutoTooltip
title={ title={
@@ -98,9 +92,6 @@ const usePoolsColumns = (
ellipsis: { ellipsis: {
showTitle: false showTitle: false
}, },
style: {
paddingLeft: 56
},
render: (text: string) => ( render: (text: string) => (
<AutoTooltip <AutoTooltip
title={ title={
@@ -125,9 +116,7 @@ const usePoolsColumns = (
dataIndex: 'replicas', dataIndex: 'replicas',
span: 6, span: 6,
key: 'replicas', key: 'replicas',
style: { align: 'center',
paddingLeft: 50
},
editable: { editable: {
valueType: 'number', valueType: 'number',
title: intl.formatMessage({ id: 'models.table.replicas.edit' }) title: intl.formatMessage({ id: 'models.table.replicas.edit' })
@@ -165,9 +154,6 @@ const usePoolsColumns = (
ellipsis: { ellipsis: {
showTitle: false showTitle: false
}, },
style: {
paddingLeft: 42
},
render: (text: string) => ( render: (text: string) => (
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{dayjs(text).format('YYYY-MM-DD HH:mm:ss')} {dayjs(text).format('YYYY-MM-DD HH:mm:ss')}
@@ -179,9 +165,6 @@ const usePoolsColumns = (
key: 'operations', key: 'operations',
dataIndex: 'operations', dataIndex: 'operations',
span: 3, span: 3,
style: {
paddingLeft: 36
},
render: (text: string, record: ListItem) => ( render: (text: string, record: ListItem) => (
<DropdownButtons <DropdownButtons
items={actionItems} items={actionItems}
@@ -22,7 +22,12 @@ export const useQueryClusterList = (options?: { useStateData?: boolean }) => {
loading, loading,
cancel cancel
} = useRequest( } = useRequest(
async (params: { page: number; perPage?: number }) => { async (params: {
page: number;
perPage?: number;
mine?: boolean;
gpu_instance_enabled?: boolean;
}) => {
axiosTokenRef.current?.cancel(); axiosTokenRef.current?.cancel();
axiosTokenRef.current = createAxiosToken(); axiosTokenRef.current = createAxiosToken();
const res = await queryClusterList(params, { const res = await queryClusterList(params, {
@@ -18,10 +18,17 @@ const BasicForm = forwardRef((props: BasicFormProps, ref) => {
console.log(values); 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, () => ({ useImperativeHandle(ref, () => ({
validateFields: formRef.current?.validateFields, validateFields: (...args: any[]) =>
getFieldsValue: formRef.current?.getFieldsValue, formRef.current?.validateFields(...args),
submit: formRef.current?.submit getFieldsValue: (...args: any[]) =>
formRef.current?.getFieldsValue(...args),
submit: (...args: any[]) => formRef.current?.submit(...args)
})); }));
return ( return (
+33 -16
View File
@@ -2,6 +2,20 @@ import {
BreakdownItem, BreakdownItem,
UsageBreakdownResponse UsageBreakdownResponse
} from '@/pages/usage/config/types'; } 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 = [ export const overviewConfigs = [
{ {
@@ -88,6 +102,7 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
return groupItem; return groupItem;
} }
let baseLabel: string;
if (groupBy === 'model') { if (groupBy === 'model') {
const providerName = const providerName =
identityValue?.provider_name || groupValue?.provider_name; identityValue?.provider_name || groupValue?.provider_name;
@@ -97,23 +112,18 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
rawItem.model_name || rawItem.model_name ||
rawItem.model; rawItem.model;
if (providerName && modelName) { baseLabel =
return `${providerName}/${modelName}`; providerName && modelName
} ? `${providerName}/${modelName}`
: groupItem?.label || modelName || '-';
return groupItem?.label || modelName || '-'; } else if (groupBy === 'route') {
} baseLabel =
if (groupBy === 'route') {
return (
groupItem?.label || groupItem?.label ||
identityValue?.route_name || identityValue?.route_name ||
groupValue?.route_name || groupValue?.route_name ||
'-' '-';
); } else {
} baseLabel =
return (
groupItem?.label || groupItem?.label ||
identityValue?.user_name || identityValue?.user_name ||
identityValue?.api_key_name || identityValue?.api_key_name ||
@@ -125,8 +135,15 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
rawItem.api_key_name || rawItem.api_key_name ||
rawItem.access_key || rawItem.access_key ||
rawItem[groupBy] || 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 = ( export const getUsageResponseItems = (
@@ -77,7 +77,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
}, [resourceAtom, loadingStatus]); }, [resourceAtom, loadingStatus]);
const contentInfo = useMemo(() => { const contentInfo = useMemo(() => {
if (!resourceCount.cluster_count) { if (!resourceCount?.cluster_count) {
return { return {
title: intl.formatMessage({ id: 'noresult.cluster.title' }), title: intl.formatMessage({ id: 'noresult.cluster.title' }),
subTitle: intl.formatMessage({ id: 'noresult.resources.cluster' }), subTitle: intl.formatMessage({ id: 'noresult.resources.cluster' }),
@@ -98,7 +98,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
const handleCreate = () => { const handleCreate = () => {
setHideModalTemporarily(true); setHideModalTemporarily(true);
onCreated?.(); onCreated?.();
if (!resourceCount.cluster_count) { if (!resourceCount?.cluster_count) {
setClusterSession({ setClusterSession({
firstAddWorker: false, firstAddWorker: false,
firstAddCluster: true firstAddCluster: true
@@ -6,8 +6,8 @@ import useUserDirectory from '../hooks/use-user-directory';
/** /**
* Owner tag for template cards, disambiguating same-name templates in * Owner tag for template cards, disambiguating same-name templates in
* the admin's cross-tenant view. Renders nothing for non-admin callers * the admin's cross-tenant view. Renders nothing for non-admin callers
* (the management page is `mine`-scoped for them) and when a plugin * (gated on `canSeeAdmin`) and when a plugin provides its own
* provides its own `OwnerScopeTag` slot. * `OwnerScopeTag` slot.
*/ */
const OwnerTag: React.FC<{ ownerId?: number | null }> = ({ ownerId }) => { const OwnerTag: React.FC<{ ownerId?: number | null }> = ({ ownerId }) => {
const intl = useIntl(); const intl = useIntl();
@@ -1,3 +1,4 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui'; import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Flex, Tag } from 'antd'; import { Flex, Tag } from 'antd';
@@ -271,6 +272,10 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
{cpuManufacturer} {cpuManufacturer}
</ThemeTag> </ThemeTag>
)} )}
<PluginExtraFields
name="InstanceTypeBillingBadge"
context={{ instanceType: item }}
/>
</Flex> </Flex>
</Title> </Title>
<InstanceMetadataSection spec={specData}></InstanceMetadataSection> <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 { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton';
import { TemplateCard } from '@gpustack/core-ui'; import { TemplateCard } from '@gpustack/core-ui';
import { Empty, Flex, Spin } from 'antd'; import { Empty, Flex, Spin } from 'antd';
@@ -40,6 +41,10 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
return ( return (
<Flex orientation="vertical" gap={16}> <Flex orientation="vertical" gap={16}>
<PluginExtraFields
name="InstanceTypeBillingProvider"
context={{ instanceTypes: dataList }}
/>
{dataList.map((item) => { {dataList.map((item) => {
const name = item.name; const name = item.name;
return ( return (
@@ -10,12 +10,18 @@ import useOverlayLayout from '../hooks/use-overlay-layout';
interface StorageOverlayProps { interface StorageOverlayProps {
open: boolean; 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; onCancel: () => void;
onSubmit: (values: StorageFormData) => Promise<void> | void; onSubmit: (values: StorageFormData) => Promise<void> | void;
} }
const StorageOverlay: React.FC<StorageOverlayProps> = ({ const StorageOverlay: React.FC<StorageOverlayProps> = ({
open, open,
scopeOrgId,
onCancel, onCancel,
onSubmit onSubmit
}) => { }) => {
@@ -28,9 +34,14 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
fetchStorageClass({ page: -1 }); fetchStorageClass(
{ page: -1 },
scopeOrgId != null
? { headers: { 'X-Organization-Id': String(scopeOrgId) } }
: undefined
);
} }
}, [open]); }, [open, scopeOrgId]);
const handleSubmit = () => { const handleSubmit = () => {
formRef.current?.submit(); formRef.current?.submit();
@@ -75,6 +86,7 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
ref={formRef} ref={formRef}
action={PageAction.CREATE} action={PageAction.CREATE}
open={open} open={open}
showOrgScope={false}
onFinish={handleFinish} onFinish={handleFinish}
/> />
</FormContext.Provider> </FormContext.Provider>
@@ -30,6 +30,10 @@ const StorageVolume = ({
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const form = Form.useFormInstance<FormData>(); const form = Form.useFormInstance<FormData>();
const storageMode = Form.useWatch('storageMode', form); 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 { fetchData: createStorage } = useCreateStorage();
const { detailData: storageData, fetchData: fetchStorage } = const { detailData: storageData, fetchData: fetchStorage } =
useQueryStorage(); useQueryStorage();
@@ -232,6 +236,7 @@ const StorageVolume = ({
<StorageOverlay <StorageOverlay
open={overlayOpen} open={overlayOpen}
scopeOrgId={scopeOrgId}
onCancel={() => setOverlayOpen(false)} onCancel={() => setOverlayOpen(false)}
onSubmit={handleCreateStorage} onSubmit={handleCreateStorage}
/> />
@@ -271,6 +271,7 @@ const GPUService: React.FC = () => {
if (!clusterLoading && !hasK8sCluster) { if (!clusterLoading && !hasK8sCluster) {
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading || clusterLoading} loading={dataSource.loading || clusterLoading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={[]} dataSource={[]}
@@ -294,6 +295,7 @@ const GPUService: React.FC = () => {
} }
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -360,6 +362,7 @@ const GPUService: React.FC = () => {
/> />
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
rowSelection={rowSelection} rowSelection={rowSelection}
@@ -110,6 +110,7 @@ const GPUServicePublicKeys: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -153,6 +154,7 @@ const GPUServicePublicKeys: React.FC = () => {
/> />
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
rowSelection={rowSelection} rowSelection={rowSelection}
@@ -1,6 +1,23 @@
import { StatusMaps } from '@/config';
import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui'; import { icons } from '@gpustack/core-ui';
import { StorageTypeKind } from './types'; 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> = { export const StorageTypeKindValueMap: Record<string, StorageTypeKind> = {
NFS: 'nfs', NFS: 'nfs',
S3: 's3' S3: 's3'
@@ -44,4 +44,8 @@ export interface ListItem {
nfs?: StorageTypeNFS | null; nfs?: StorageTypeNFS | null;
s3?: Omit<StorageTypeS3, 'secretKey'> | null; s3?: Omit<StorageTypeS3, 'secretKey'> | null;
}; };
status?: {
phase?: string | null;
phaseMessage?: string | null;
} | null;
} }
@@ -5,13 +5,19 @@ import {
AutoTooltip, AutoTooltip,
DropdownButtons, DropdownButtons,
IconFont, IconFont,
StatusTag,
ThemeTag ThemeTag
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table'; import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { rowActionList, StorageTypeKindLabelMap } from '../config'; import {
rowActionList,
status,
StorageTypeKindLabelMap,
StorageTypePhaseLabelMap
} from '../config';
import { ListItem } from '../config/types'; import { ListItem } from '../config/types';
interface ColumnsHookProps { interface ColumnsHookProps {
@@ -22,21 +28,13 @@ interface ColumnsHookProps {
const getKindLabel = (record: ListItem) => { const getKindLabel = (record: ListItem) => {
if (record.spec?.nfs) if (record.spec?.nfs)
return ( return (
<ThemeTag <ThemeTag color="cyan" icon={<FolderOutlined />}>
style={{ width: 'fit-content' }}
color="cyan"
icon={<FolderOutlined />}
>
{StorageTypeKindLabelMap.nfs} {StorageTypeKindLabelMap.nfs}
</ThemeTag> </ThemeTag>
); );
if (record.spec?.s3) if (record.spec?.s3)
return ( return (
<ThemeTag <ThemeTag color="green" icon={<IconFont type="icon-database" />}>
style={{ width: 'fit-content' }}
color="green"
icon={<IconFont type="icon-database" />}
>
{StorageTypeKindLabelMap.s3} {StorageTypeKindLabelMap.s3}
</ThemeTag> </ThemeTag>
); );
@@ -81,6 +79,24 @@ const useStorageTypeColumns = ({
sorter: false, sorter: false,
render: (_text, record) => getKindLabel(record) 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, ...creatorCols,
// { // {
// title: intl.formatMessage({ id: 'common.table.description' }), // title: intl.formatMessage({ id: 'common.table.description' }),
@@ -104,6 +104,7 @@ const GPUServiceStorageTypes: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -149,6 +150,7 @@ const GPUServiceStorageTypes: React.FC = () => {
/> />
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
rowSelection={rowSelection} rowSelection={rowSelection}
@@ -51,6 +51,11 @@ export async function queryStorageClass(
return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, { return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, {
method: 'GET', method: 'GET',
params, 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 cancelToken: options?.token
}); });
} }
@@ -1,10 +1,11 @@
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock'; import useSubmitLock from '@/hooks/use-submit-lock';
import { FormDrawer, ModalFooter } from '@gpustack/core-ui'; import { FormDrawer, ModalFooter } from '@gpustack/core-ui';
import { useRef } from 'react'; import { useEffect, useRef } from 'react';
import { FormContext } from '../config/form-context'; import { FormContext } from '../config/form-context';
import { FormData, ListItem } from '../config/types'; import { FormData, ListItem } from '../config/types';
import GPUServiceStorageForm from '../forms'; import GPUServiceStorageForm from '../forms';
import useQueryStorageClass from '../services/use-query-storage-class';
type AddModalProps = { type AddModalProps = {
title: string; title: string;
@@ -13,7 +14,6 @@ type AddModalProps = {
onOk: (values: FormData) => void; onOk: (values: FormData) => void;
data?: ListItem | null; data?: ListItem | null;
onCancel: () => void; onCancel: () => void;
storageClassList: Global.BaseOption<string>[];
}; };
const AddModal: React.FC<AddModalProps> = ({ const AddModal: React.FC<AddModalProps> = ({
@@ -22,11 +22,33 @@ const AddModal: React.FC<AddModalProps> = ({
open, open,
onOk, onOk,
data, data,
onCancel, onCancel
storageClassList
}) => { }) => {
const form = useRef<any>(null); const form = useRef<any>(null);
const { loading, guard, run, release } = useSubmitLock(); 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 = () => { const handleSubmit = () => {
guard(() => form.current?.submit()); guard(() => form.current?.submit());
@@ -70,6 +92,7 @@ const AddModal: React.FC<AddModalProps> = ({
ref={form} ref={form}
action={action} action={action}
currentData={data} currentData={data}
onScopeChange={handleScopeChange}
onFinish={onFinish} onFinish={onFinish}
onFinishFailed={release} onFinishFailed={release}
open={open} open={open}
@@ -3,21 +3,18 @@ import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui'; import { icons } from '@gpustack/core-ui';
export const StoragePhaseValueMap = { export const StoragePhaseValueMap = {
Available: 'Available', Ready: 'Ready',
Unavailable: 'Unavailable', Deleting: 'Deleting'
Pending: 'Pending'
}; };
export const StoragePhaseLabelMap: Record<string, string> = { export const StoragePhaseLabelMap: Record<string, string> = {
[StoragePhaseValueMap.Available]: 'Available', [StoragePhaseValueMap.Ready]: 'Ready',
[StoragePhaseValueMap.Unavailable]: 'Unavailable', [StoragePhaseValueMap.Deleting]: 'Deleting'
[StoragePhaseValueMap.Pending]: 'Pending'
}; };
export const status: Record<string, StatusType> = { export const status: Record<string, StatusType> = {
[StoragePhaseValueMap.Available]: StatusMaps.success, [StoragePhaseValueMap.Ready]: StatusMaps.success,
[StoragePhaseValueMap.Unavailable]: StatusMaps.error, [StoragePhaseValueMap.Deleting]: StatusMaps.warning
[StoragePhaseValueMap.Pending]: StatusMaps.transitioning
}; };
export const rowActionList = [ export const rowActionList = [
@@ -33,6 +33,7 @@ export interface ListItem {
}; };
status?: { status?: {
phase?: string | null; phase?: string | null;
phaseMessage?: string | null;
} | null; } | null;
} }
+23 -2
View File
@@ -12,7 +12,23 @@ import { useContext } from 'react';
import { FormContext } from '../config/form-context'; import { FormContext } from '../config/form-context';
import { FormData } from '../config/types'; 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 intl = useIntl();
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const { storageClassList } = useContext(FormContext); const { storageClassList } = useContext(FormContext);
@@ -44,7 +60,12 @@ const Basic = ({ action, open }: { action: string; open: boolean }) => {
label={intl.formatMessage({ id: 'common.table.displayName' })} label={intl.formatMessage({ id: 'common.table.displayName' })}
/> />
</Form.Item> </Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} /> {showOrgScope && (
<PluginExtraFields
name="CreateOrgScopeField"
context={{ action, onChange: onOrgScopeChange }}
/>
)}
<Flex gap={16}> <Flex gap={16}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Form.Item<FormData> <Form.Item<FormData>
+62 -3
View File
@@ -1,7 +1,8 @@
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { useMemoizedFn } from 'ahooks';
import { Form } from 'antd'; import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react'; import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { FormData, ListItem } from '../config/types'; import { FormData, ListItem } from '../config/types';
import Basic from './basic'; import Basic from './basic';
@@ -10,14 +11,67 @@ interface StorageFormProps {
open: boolean; open: boolean;
action: PageActionType; action: PageActionType;
currentData?: ListItem | null; 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>; onFinish: (values: FormData) => Promise<void>;
onFinishFailed?: (errorInfo: any) => void; onFinishFailed?: (errorInfo: any) => void;
} }
const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef( const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
(props, ref) => { (props, ref) => {
const { action, currentData, open, onFinish, onFinishFailed } = props; const {
action,
currentData,
open,
showOrgScope = true,
onScopeChange,
onFinish,
onFinishFailed
} = props;
const [form] = Form.useForm<FormData>(); 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(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -56,7 +110,12 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
preserve={false} preserve={false}
initialValues={{}} initialValues={{}}
> >
<Basic action={action} open={open} /> <Basic
action={action}
open={open}
showOrgScope={showOrgScope}
onOrgScopeChange={handleOrgScopeChange}
/>
</Form> </Form>
); );
} }
@@ -1,11 +1,11 @@
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column'; import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
import { usePluginListColumns } from '@/plugins/list-extra-columns'; 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 { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table'; import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { rowActionList } from '../config'; import { rowActionList, status, StoragePhaseLabelMap } from '../config';
import { ListItem } from '../config/types'; import { ListItem } from '../config/types';
interface ColumnsHookProps { interface ColumnsHookProps {
@@ -70,24 +70,25 @@ const useStorageColumns = ({
sorter: false, sorter: false,
render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-') 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, ...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' }), title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at', dataIndex: 'created_at',
+2 -1
View File
@@ -109,6 +109,7 @@ const GPUServiceStorage: React.FC = () => {
if (type !== 'Table') return; if (type !== 'Table') return;
return ( return (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -150,6 +151,7 @@ const GPUServiceStorage: React.FC = () => {
/> />
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
className={'scroll-table'}
columns={columns} columns={columns}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
rowSelection={rowSelection} rowSelection={rowSelection}
@@ -177,7 +179,6 @@ const GPUServiceStorage: React.FC = () => {
action={openStorageModalStatus.action} action={openStorageModalStatus.action}
title={openStorageModalStatus.title} title={openStorageModalStatus.title}
data={openStorageModalStatus.currentData} data={openStorageModalStatus.currentData}
storageClassList={storageClassList}
onCancel={closeStorageModal} onCancel={closeStorageModal}
onOk={handleModalOk} onOk={handleModalOk}
/> />
@@ -28,7 +28,7 @@ import {
TemplateCard, TemplateCard,
ThemeTag ThemeTag
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useAccess, useIntl } from '@umijs/max';
import { Button, Tag } from 'antd'; import { Button, Tag } from 'antd';
import { useMemo } from 'react'; import { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -144,8 +144,26 @@ interface TemplateCardProps {
const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => { const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
const intl = useIntl(); const intl = useIntl();
const access = useAccess();
const { isDarkTheme } = useUserSettings(); 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(() => { const manufacturerLabelMap: Record<string, string> = useMemo(() => {
return Object.values(GPUsConfigs).reduce( return Object.values(GPUsConfigs).reduce(
(acc, item) => { (acc, item) => {
@@ -238,7 +256,7 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
<span onClick={handleonClickAction} className="operations"> <span onClick={handleonClickAction} className="operations">
<DropdownActions <DropdownActions
menu={{ menu={{
items: templateActions, items: actions,
onClick: handleOnSelect onClick: handleOnSelect
}} }}
> >
@@ -114,6 +114,12 @@ export const templateActions: Array<{
locale: true, locale: true,
icon: icons.EditOutlined icon: icons.EditOutlined
}, },
{
label: 'common.button.clone',
key: 'clone',
locale: true,
icon: icons.CopyOutlined
},
{ {
label: 'common.button.delete', label: 'common.button.delete',
key: 'delete', key: 'delete',
@@ -1,4 +1,3 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { Form } from 'antd'; import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react'; import { forwardRef, useEffect, useImperativeHandle } from 'react';
@@ -26,11 +25,13 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
return; 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({ form.setFieldsValue({
...currentData ...currentData
}); });
return;
} }
}, [action, currentData, form, open]); }, [action, currentData, form, open]);
+46 -7
View File
@@ -45,13 +45,11 @@ const GPUServiceTemplates: React.FC = () => {
isInfiniteScroll: true, isInfiniteScroll: true,
contentForDelete: intl.formatMessage({ id: 'gpuservice.template' }), contentForDelete: intl.formatMessage({ id: 'gpuservice.template' }),
defaultQueryParams: { defaultQueryParams: {
perPage: 24, // Default (non-``mine``) scope: Global rows plus rows owned by the
// Management view: drop Global rows for non-admin callers — the // caller's current principal, matching the Inference Backend page.
// page is a CRUD surface, and admin-curated Global templates // Global rows a non-admin can't edit render read-only — the card
// they can't edit only add visual noise. The instance-create // gates Edit/Delete on ownership.
// picker (uses ``useQueryTemplates`` separately) doesn't set perPage: 24
// this and so still sees Global presets.
mine: true
} }
}); });
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } = 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) => { const handleModalOk = async (data: FormData) => {
try { try {
if (openTemplateModalStatus.action === PageAction.EDIT) { if (openTemplateModalStatus.action === PageAction.EDIT) {
@@ -114,6 +148,10 @@ const GPUServiceTemplates: React.FC = () => {
handleEditTemplate(item.data); handleEditTemplate(item.data);
return; return;
} }
if (item.action === 'clone') {
handleCloneTemplate(item.data);
return;
}
if (item.action === 'delete') { if (item.action === 'delete') {
handleDelete({ ...item.data, name: item.data.name }); handleDelete({ ...item.data, name: item.data.name });
} }
@@ -172,6 +210,7 @@ const GPUServiceTemplates: React.FC = () => {
onSelect={handleOnSelect} onSelect={handleOnSelect}
/> />
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
+1
View File
@@ -166,6 +166,7 @@ const Catalog: React.FC = () => {
isFirst={!dataSource.loadend} isFirst={!dataSource.loadend}
></CatalogList> ></CatalogList>
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={dataSource.loading} loading={dataSource.loading}
loadend={dataSource.loadend} loadend={dataSource.loadend}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
@@ -6,7 +6,7 @@ import { getGPUStackPlugin } from '@/plugins';
import { DownOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { DownOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { import {
AlertBlockInfo, AlertBlockInfo,
TooltipList, CardRadioGroup,
Transfer as TransferInner Transfer as TransferInner
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
@@ -15,9 +15,8 @@ import {
Dropdown, Dropdown,
DropdownProps, DropdownProps,
Empty, Empty,
Flex,
Form, Form,
Radio,
RadioChangeEvent,
Tooltip Tooltip
} from 'antd'; } from 'antd';
import { import {
@@ -80,9 +79,9 @@ const Label = styled.div`
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-weight: 500; font-weight: 500;
margin-block: 8px 12px; margin-bottom: 16px;
font-size: 14px; font-size: 13px;
color: var(--ant-color-text-tertiary); color: var(--ant-color-text-secondary);
`; `;
interface AccessControlFormProps { interface AccessControlFormProps {
@@ -107,6 +106,14 @@ type AllowedUsersOverride = {
routeId?: number; routeId?: number;
action: PageActionType; 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 // Extra policy entries prepended to the radio list (rendered before
@@ -223,9 +230,7 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
} }
}; };
const handleOnPolicyChange = async (e: RadioChangeEvent) => { const handleOnPolicyChange = async (policy: string) => {
console.log('policy changed:', e.target.value);
const policy = e.target.value;
if (policy === ALLOWED_PRINCIPALS_POLICY) { if (policy === ALLOWED_PRINCIPALS_POLICY) {
form.setFieldsValue({ users: formDataCacheRef.current?.users || [] }); form.setFieldsValue({ users: formDataCacheRef.current?.users || [] });
} else { } else {
@@ -391,33 +396,29 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
: undefined : undefined
}} }}
> >
<Label> <Label>{intl.formatMessage({ id: 'models.table.accessScope' })}</Label>
{intl.formatMessage({ id: 'models.table.accessScope' })}
<Tooltip
title={
<TooltipList
list={buildAccessScopeTips(
allowedUsersOverride,
prependedPolicies
)}
></TooltipList>
}
>
<QuestionCircleOutlined />
</Tooltip>
</Label>
<Form.Item<AccessControlFormData> name="access_policy" noStyle> <Form.Item<AccessControlFormData>
<Radio.Group name="access_policy"
style={{
marginBottom: 16
}}
>
<CardRadioGroup
onChange={handleOnPolicyChange} onChange={handleOnPolicyChange}
style={{ marginBottom: 12 }}
options={[ options={[
...prependedPolicies.map((p) => ({ ...prependedPolicies.map((p) => ({
label: intl.formatMessage({ id: p.labelId }), label: intl.formatMessage({ id: p.labelId }),
description: intl.formatMessage({ id: p.tipsId ?? p.labelId }),
value: p.policyValue value: p.policyValue
})), })),
{ {
label: intl.formatMessage({ id: 'models.accessSettings.authed' }), label: intl.formatMessage({
id: 'models.accessSettings.authed'
}),
description: intl.formatMessage({
id: 'models.accessSettings.authed.tips'
}),
value: 'authed' value: 'authed'
}, },
allowedUsersOverride allowedUsersOverride
@@ -425,23 +426,52 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
label: intl.formatMessage({ label: intl.formatMessage({
id: allowedUsersOverride.labelId id: allowedUsersOverride.labelId
}), }),
description: intl.formatMessage({
id:
allowedUsersOverride.tipsId ??
'models.accessSettings.allowedUsers.tips'
}),
value: allowedUsersOverride.policyValue value: allowedUsersOverride.policyValue
} }
: { : {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.accessSettings.allowedUsers' id: 'models.accessSettings.allowedUsers'
}), }),
description: intl.formatMessage({
id: 'models.accessSettings.allowedUsers.tips'
}),
value: ALLOWED_PRINCIPALS_POLICY value: ALLOWED_PRINCIPALS_POLICY
}, },
{ {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.accessSettings.public' id: 'models.accessSettings.public'
}), }),
description: intl.formatMessage({
id: 'models.accessSettings.public.desc'
}),
value: 'public' value: 'public'
} }
]} ]}
></Radio.Group> />
</Form.Item> </Form.Item>
{allowedUsersOverride?.Action && accessPolicy === overridePolicyValue && (
<Flex
justify="space-between"
align="center"
style={{ marginBottom: 8 }}
>
<Label style={{ marginBottom: 0 }}>
{intl.formatMessage({
id: 'models.accessSettings.grantedPrincipals'
})}
</Label>
<allowedUsersOverride.Action
form={form}
routeId={currentData?.id}
action={action}
/>
</Flex>
)}
{accessPolicy === 'public' && ( {accessPolicy === 'public' && (
<div style={{ marginBlock: '16px 12px' }}> <div style={{ marginBlock: '16px 12px' }}>
<AlertBlockInfo <AlertBlockInfo
@@ -449,6 +479,13 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
message={intl.formatMessage({ message={intl.formatMessage({
id: 'models.accessSettings.public.tips' id: 'models.accessSettings.public.tips'
})} })}
overlayScrollerProps={{
styles: {
wrapper: {
paddingLeft: 0
}
}
}}
></AlertBlockInfo> ></AlertBlockInfo>
</div> </div>
)} )}
@@ -130,7 +130,9 @@ const AccessControlModal: React.FC<
<AlertBlockInfo <AlertBlockInfo
type="warning" type="warning"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
contentStyle={{ paddingInline: 0 }} contentStyle={{
paddingInline: 0
}}
message={intl.formatMessage({ message={intl.formatMessage({
id: 'models.button.accessSettings.tips' id: 'models.button.accessSettings.tips'
})} })}
+23 -29
View File
@@ -1,6 +1,7 @@
import { convertFileSize } from '@/utils'; import { convertFileSize } from '@/utils';
import { AutoTooltip } from '@gpustack/core-ui'; import { AutoTooltip } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Flex } from 'antd';
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import '../style/gpu-card.less'; import '../style/gpu-card.less';
@@ -19,12 +20,14 @@ const Header = styled.div`
width: 100%; width: 100%;
`; `;
const ItemInfo = styled.div` const Metric: React.FC<{ label: React.ReactNode; value: React.ReactNode }> = ({
display: flex; label,
flex-wrap: wrap; value
white-space: break-spaces; }) => (
font-size: 13px; <span style={{ lineHeight: 1.2 }}>
`; {label}: <span>{value}</span>
</span>
);
const Description = styled.div` const Description = styled.div`
display: flex; display: flex;
@@ -64,29 +67,20 @@ const GPUCard: React.FC<{
} }
description={ description={
info || ( info || (
<> <Flex wrap gap={8} style={{ fontSize: 11 }}>
<ItemInfo> <Metric
{intl.formatMessage({ id: 'resources.table.vram' })}( label={intl.formatMessage({ id: 'resources.table.total' })}
{intl.formatMessage({ id: 'resources.table.used' })}/ value={convertFileSize(data?.memory?.total || 0)}
{intl.formatMessage({ id: 'resources.table.total' })}):{' '} />
<span> <Metric
{convertFileSize( label={intl.formatMessage({ id: 'resources.table.used' })}
data?.memory?.used || data?.memory?.allocated || 0 value={convertFileSize(data?.memory?.used || 0)}
)}{' '} />
/ {convertFileSize(data?.memory?.total || 0)} <Metric
</span> label={intl.formatMessage({ id: 'resources.table.allocated' })}
</ItemInfo> value={convertFileSize(data?.memory?.allocated || 0)}
{/* <ItemInfo> />
<span> </Flex>
{intl.formatMessage({ id: 'resources.table.gpuutilization' })}
:{' '}
</span>
{data?.memory?.used
? _.round(data?.memory?.utilization_rate || 0, 2)
: _.round(data.memory?.allocated / data.memory?.total, 2) * 100}
%
</ItemInfo> */}
</>
) )
} }
></CardContainer> ></CardContainer>
@@ -1,6 +1,5 @@
import { ListItem as WorkerListItem } from '@/pages/resources/config/types'; import { ListItem as WorkerListItem } from '@/pages/resources/config/types';
import { AutoTooltip, RowChildren } from '@gpustack/core-ui'; import { AutoTooltip, ExpandedRowGrid } from '@gpustack/core-ui';
import { Col, Row } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import React from 'react'; import React from 'react';
import { ModelInstanceListItem } from '../../config/types'; 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 DownloadingStatusCell from '../instance-cells/downloading-status-cell';
import InstanceStatusCell from '../instance-cells/instance-status-cell'; import InstanceStatusCell from '../instance-cells/instance-status-cell';
import NameCell from '../instance-cells/name-cell'; import NameCell from '../instance-cells/name-cell';
interface InstanceItemProps { interface InstanceItemProps {
instanceData: ModelInstanceListItem; instanceData: ModelInstanceListItem;
workerList: WorkerListItem[]; workerList: WorkerListItem[];
modelData?: any; modelData?: any;
defaultOpenId: string; 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; handleChildSelect: (val: string, item: ModelInstanceListItem) => void;
} }
@@ -24,48 +29,43 @@ const InstanceItem: React.FC<InstanceItemProps> = ({
workerList, workerList,
modelData, modelData,
defaultOpenId, defaultOpenId,
gridTemplate,
prefixWidth = 0,
columns,
handleChildSelect 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 ( return (
<div style={{ borderRadius: 'var(--ant-table-header-border-radius)' }}> <ExpandedRowGrid
<RowChildren> gridTemplate={gridTemplate}
<Row prefixWidth={prefixWidth}
style={{ width: '100%', color: 'var(--ant-color-text-secondary)' }} style={{ color: 'var(--ant-color-text-secondary)' }}
align="middle"
>
<Col
span={6}
style={{
paddingInline: 'var(--ant-table-cell-padding-inline)'
}}
> >
<ExpandedRowGrid.Cell span={1}>
<NameCell <NameCell
record={instanceData} record={instanceData}
modelData={modelData} modelData={modelData}
defaultOpenId={defaultOpenId} defaultOpenId={defaultOpenId}
></NameCell> ></NameCell>
</Col> </ExpandedRowGrid.Cell>
<Col span={7}> <ExpandedRowGrid.Cell
<span span={middleSpan}
style={{ style={{ flexWrap: 'wrap', gap: 8 }}
paddingLeft: '58px',
flexWrap: 'wrap',
gap: '8px'
}}
className="flex align-center"
> >
<CPUOffloadingCell record={instanceData}></CPUOffloadingCell> <CPUOffloadingCell record={instanceData}></CPUOffloadingCell>
<DistributeInfoCell <DistributeInfoCell
record={instanceData} record={instanceData}
workerList={workerList} workerList={workerList}
></DistributeInfoCell> ></DistributeInfoCell>
</span> </ExpandedRowGrid.Cell>
</Col> <ExpandedRowGrid.Cell span={1} style={{ gap: 4 }}>
<Col span={4}>
<span
style={{ paddingLeft: '40px', gap: 4 }}
className="flex-center"
>
<InstanceStatusCell <InstanceStatusCell
record={instanceData} record={instanceData}
onSelect={handleChildSelect} onSelect={handleChildSelect}
@@ -76,27 +76,20 @@ const InstanceItem: React.FC<InstanceItemProps> = ({
workerList={workerList} workerList={workerList}
record={instanceData} record={instanceData}
></DownloadingStatusCell> ></DownloadingStatusCell>
</span> </ExpandedRowGrid.Cell>
</Col> <ExpandedRowGrid.Cell span={1}>
<Col span={4}>
<span style={{ paddingLeft: 43 }} className="flex">
<AutoTooltip ghost> <AutoTooltip ghost>
{dayjs(instanceData.created_at).format('YYYY-MM-DD HH:mm:ss')} {dayjs(instanceData.created_at).format('YYYY-MM-DD HH:mm:ss')}
</AutoTooltip> </AutoTooltip>
</span> </ExpandedRowGrid.Cell>
</Col> <ExpandedRowGrid.Cell span={1}>
<Col span={3}>
<div style={{ paddingLeft: 36 }}>
<ActionsCell <ActionsCell
record={instanceData} record={instanceData}
modelData={modelData} modelData={modelData}
onSelect={handleChildSelect} onSelect={handleChildSelect}
></ActionsCell> ></ActionsCell>
</div> </ExpandedRowGrid.Cell>
</Col> </ExpandedRowGrid>
</Row>
</RowChildren>
</div>
); );
}; };
export default InstanceItem; export default InstanceItem;
@@ -17,6 +17,9 @@ interface InstanceItemProps {
workerList: WorkerListItem[]; workerList: WorkerListItem[];
modelData?: any; modelData?: any;
currentExpanded?: string; currentExpanded?: string;
gridTemplate?: string;
prefixWidth?: number;
columns?: any[];
handleChildSelect: (val: string, item: ModelInstanceListItem) => void; handleChildSelect: (val: string, item: ModelInstanceListItem) => void;
} }
@@ -25,6 +28,9 @@ const Instances: React.FC<InstanceItemProps> = ({
workerList, workerList,
modelData, modelData,
currentExpanded, currentExpanded,
gridTemplate,
prefixWidth,
columns,
handleChildSelect handleChildSelect
}) => { }) => {
const [firstLoad, setFirstLoad] = React.useState(true); const [firstLoad, setFirstLoad] = React.useState(true);
@@ -55,6 +61,9 @@ const Instances: React.FC<InstanceItemProps> = ({
instanceData={item} instanceData={item}
defaultOpenId={firstLoad ? defaultOpenId : ''} defaultOpenId={firstLoad ? defaultOpenId : ''}
handleChildSelect={handleChildSelect} handleChildSelect={handleChildSelect}
gridTemplate={gridTemplate}
prefixWidth={prefixWidth}
columns={columns}
></InstanceItem> ></InstanceItem>
); );
})} })}
+89 -23
View File
@@ -1,11 +1,13 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { import {
StatusTag, IconFont,
StatusDot,
TagsWrapper, TagsWrapper,
TemplateCard, TemplateCard,
ThemeTag ThemeTag
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl, useNavigate } from '@umijs/max'; import { useIntl, useNavigate } from '@umijs/max';
import { Button } from 'antd'; import { Button, Tooltip } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -19,7 +21,11 @@ import {
MyModelsStatusValueMap MyModelsStatusValueMap
} from '../config'; } from '../config';
import { categoryToPathMap } from '../config/button-actions'; import { categoryToPathMap } from '../config/button-actions';
import { getModelLogo } from '../utils/model-logo'; import {
defaultModelLogo,
getCategoryLogo,
getModelLogo
} from '../utils/model-logo';
const CardWrapper = styled.div` const CardWrapper = styled.div`
&:hover { &:hover {
@@ -44,7 +50,7 @@ const ModelItemContent = styled.div`
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
width: 100%; width: 100%;
cursor: default; cursor: pointer;
.content { .content {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -85,8 +91,8 @@ const ModelItemContent = styled.div`
`; `;
const ModelLogo = styled.img` const ModelLogo = styled.img`
width: 24px; width: 32px;
height: 24px; height: 32px;
border-radius: 4px; border-radius: 4px;
object-fit: contain; object-fit: contain;
flex: none; flex: none;
@@ -127,15 +133,23 @@ const renderTag = (item: any, index = 0) => {
const ModelItem: React.FC<{ const ModelItem: React.FC<{
model: Record<string, any>; model: Record<string, any>;
onClick?: (model: Record<string, any>) => void;
}> = (props) => { }> = (props) => {
const { model } = props; const { model, onClick } = props;
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigate(); const navigate = useNavigate();
const handleCardClick = () => {
onClick?.(model);
};
// ``model.name`` from ``/v2/my-models`` is the OpenAI-style id // ``model.name`` from ``/v2/my-models`` is the OpenAI-style id
// (org-prefixed for non-platform routes, bare for platform). Use it // (org-prefixed for non-platform routes, bare for platform). Use it
// verbatim — the playground / dispatcher both key off that exact id. // 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); const modelName = encodeURIComponent(model.name);
for (const [category, path] of Object.entries(categoryToPathMap)) { for (const [category, path] of Object.entries(categoryToPathMap)) {
if ( if (
@@ -156,6 +170,11 @@ const ModelItem: React.FC<{
navigate(`/playground/chat?model=${modelName}`); 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 // context length
const maxToken = useMemo(() => { const maxToken = useMemo(() => {
const meta = model.meta || {}; const meta = model.meta || {};
@@ -174,29 +193,67 @@ const ModelItem: React.FC<{
return _.round(max_tokens / 1024); return _.round(max_tokens / 1024);
}, [model]); }, [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 ( return (
<CardWrapper> <CardWrapper>
<TemplateCard <TemplateCard
height={140} height={140}
clickable={false} clickable={true}
hoverable={true} hoverable={true}
ghost ghost
onClick={handleCardClick}
header={ header={
<Header> <Header>
<span className="text gap-8"> <span className="text gap-16">
<ModelLogo src={getModelLogo(model.name)} alt="" /> {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> <span>{model.name}</span>
</span> {/* Status moved to a compact dot right after the name, freeing
<StatusTag the header's right side for the price summary. The dot keeps
maxTooltipWidth={400} the state message on hover (StatusDot has no built-in one). */}
statusValue={{ <span
status: MyModelsStatusMap[model.status], style={{
text: intl.formatMessage({ fontWeight: 400,
id: MyModelsStatusLabelMap[model.status] || '' fontSize: 'var(--font-size-small)'
}),
message: model.state_message
}} }}
></StatusTag> >
{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>
</Header> </Header>
} }
> >
@@ -239,14 +296,23 @@ const ModelItem: React.FC<{
</> </>
)} )}
</div> </div>
{[MyModelsStatusValueMap.Active].includes(model.status) && ( {[MyModelsStatusValueMap.Ready].includes(model.status) && (
<Button <Button
size="middle" size="middle"
type="default"
className="btn" className="btn"
type="primary" style={{
borderRadius: 6,
paddingInline: 12
}}
onClick={handleOpenPlayGroundClick} onClick={handleOpenPlayGroundClick}
> >
{intl.formatMessage({ id: 'models.openinplayground' })} {intl.formatMessage({ id: 'models.openinplayground' })}
<IconFont
type="icon-down2"
rotate={-90}
style={{ marginLeft: 4 }}
></IconFont>
</Button> </Button>
)} )}
</div> </div>
+7 -4
View File
@@ -5,9 +5,7 @@ import { PageActionType } from '@/config/types';
import useBodyScroll from '@/hooks/use-body-scroll'; import useBodyScroll from '@/hooks/use-body-scroll';
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys'; import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
import useTableRowSelection from '@/hooks/use-table-row-selection'; 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 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 { TargetStatusValueMap } from '@/pages/model-routes/config';
import useOpenPlayground from '@/pages/model-routes/hooks/use-open-playground'; import useOpenPlayground from '@/pages/model-routes/hooks/use-open-playground';
import useGranfanaLink from '@/pages/resources/hooks/use-grafana-link'; import useGranfanaLink from '@/pages/resources/hooks/use-grafana-link';
@@ -92,6 +90,7 @@ interface ModelsProps {
loadend: boolean; loadend: boolean;
total: number; total: number;
filterValues?: Record<string, any>; filterValues?: Record<string, any>;
targetList?: any[];
} }
const getFormattedData = (record: any, extraData = {}) => ({ const getFormattedData = (record: any, extraData = {}) => ({
@@ -129,7 +128,8 @@ const Models: React.FC<ModelsProps> = ({
queryParams, queryParams,
loading, loading,
loadend, loadend,
total total,
targetList = []
}) => { }) => {
const { generateFormValues, clusterList, workerList } = const { generateFormValues, clusterList, workerList } =
useDeploymentsContext(); useDeploymentsContext();
@@ -155,7 +155,6 @@ const Models: React.FC<ModelsProps> = ({
expandedRowKeys expandedRowKeys
} = useExpandedRowKeys(expandAtom); } = useExpandedRowKeys(expandAtom);
const { handleOpenPlayGround } = useOpenPlayground(); const { handleOpenPlayGround } = useOpenPlayground();
const { watchDataList: targetList } = useWatchList(MODEL_ROUTE_TARGETS);
const { openViewLogsModal, openViewLogsModalStatus, closeViewLogsModal } = const { openViewLogsModal, openViewLogsModalStatus, closeViewLogsModal } =
useViewInstanceLogs(); useViewInstanceLogs();
@@ -442,6 +441,9 @@ const Models: React.FC<ModelsProps> = ({
modelData={options.parent} modelData={options.parent}
workerList={workerList} workerList={workerList}
handleChildSelect={handleChildSelect} handleChildSelect={handleChildSelect}
gridTemplate={options.gridTemplate}
prefixWidth={options.prefixWidth}
columns={options.columns}
></Instances> ></Instances>
); );
}, },
@@ -614,6 +616,7 @@ const Models: React.FC<ModelsProps> = ({
></PageTools> ></PageTools>
<SealTable <SealTable
columns={columns} columns={columns}
emptyMinHeight="calc(100vh - 300px)"
sortDirections={TABLE_SORT_DIRECTIONS} sortDirections={TABLE_SORT_DIRECTIONS}
dataSource={dataSource} dataSource={dataSource}
rowSelection={rowSelection} rowSelection={rowSelection}
+9 -9
View File
@@ -196,21 +196,21 @@ export const status: any = {
}; };
export const MyModelsStatusValueMap = { export const MyModelsStatusValueMap = {
Inactive: 'stopped', Stopped: 'stopped',
Degrade: 'not_ready', NotReady: 'not_ready',
Active: 'ready' Ready: 'ready'
}; };
export const MyModelsStatusMap = { export const MyModelsStatusMap = {
[MyModelsStatusValueMap.Inactive]: StatusMaps.inactive, [MyModelsStatusValueMap.Stopped]: StatusMaps.inactive,
[MyModelsStatusValueMap.Degrade]: StatusMaps.error, [MyModelsStatusValueMap.NotReady]: StatusMaps.error,
[MyModelsStatusValueMap.Active]: StatusMaps.success [MyModelsStatusValueMap.Ready]: StatusMaps.success
}; };
export const MyModelsStatusLabelMap = { export const MyModelsStatusLabelMap = {
[MyModelsStatusValueMap.Inactive]: 'models.mymodels.status.inactive', [MyModelsStatusValueMap.Stopped]: 'models.mymodels.status.inactive',
[MyModelsStatusValueMap.Degrade]: 'models.mymodels.status.degrade', [MyModelsStatusValueMap.NotReady]: 'models.mymodels.status.degrade',
[MyModelsStatusValueMap.Active]: 'models.mymodels.status.active' [MyModelsStatusValueMap.Ready]: 'models.mymodels.status.active'
}; };
export const ScheduleValueMap = { export const ScheduleValueMap = {
+31 -1
View File
@@ -3,6 +3,8 @@ import useSetChunkRequest from '@/hooks/use-chunk-request';
import { usePaginationStatus } from '@/hooks/use-pagination-status'; import { usePaginationStatus } from '@/hooks/use-pagination-status';
import { useTableMultiSort } from '@/hooks/use-table-sort'; import { useTableMultiSort } from '@/hooks/use-table-sort';
import useUpdateChunkedList from '@/hooks/use-update-chunk-list'; 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 { TableOrder, TableProvider } from '@gpustack/core-ui';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import _ from 'lodash'; import _ from 'lodash';
@@ -31,6 +33,11 @@ const Models = forwardRef((props, ref) => {
const { setChunkRequest, createAxiosToken } = useSetChunkRequest(); const { setChunkRequest, createAxiosToken } = useSetChunkRequest();
const { setChunkRequest: setModelInstanceChunkRequest } = const { setChunkRequest: setModelInstanceChunkRequest } =
useSetChunkRequest(); useSetChunkRequest();
const {
watchDataList: targetList,
startWatch: startTargetsWatch,
cancelWatch: cancelTargetsWatch
} = useWatchList(MODEL_ROUTE_TARGETS);
const [modelInstances, setModelInstances] = useState<any[]>([]); const [modelInstances, setModelInstances] = useState<any[]>([]);
const [dataSource, setDataSource] = useState<{ const [dataSource, setDataSource] = useState<{
dataList: ListItem[]; dataList: ListItem[];
@@ -241,7 +248,13 @@ const Models = forwardRef((props, ref) => {
chunkInstanceRequedtRef.current = setModelInstanceChunkRequest({ chunkInstanceRequedtRef.current = setModelInstanceChunkRequest({
url: `${MODEL_INSTANCE_API}`, url: `${MODEL_INSTANCE_API}`,
params: {}, params: {},
handler: updateInstanceHandler handler: updateInstanceHandler,
beforeReconnect() {
// treat the reconnect snapshot as the new baseline, otherwise
// instances deleted while the stream was down linger in the cache
// (their DELETE events are never re-sent)
cacheInsDataListRef.current = [];
}
}); });
} catch (error) { } catch (error) {
// ignore // ignore
@@ -255,6 +268,7 @@ const Models = forwardRef((props, ref) => {
cacheInsDataListRef.current = []; cacheInsDataListRef.current = [];
chunkInstanceRequedtRef.current?.current?.cancel?.(); chunkInstanceRequedtRef.current?.current?.cancel?.();
instancesToken.current?.cancel?.(); instancesToken.current?.cancel?.();
cancelTargetsWatch();
}); });
const resumeRequestsOnPageActive = useMemoizedFn(async () => { const resumeRequestsOnPageActive = useMemoizedFn(async () => {
@@ -265,6 +279,7 @@ const Models = forwardRef((props, ref) => {
await getAllModelInstances(); await getAllModelInstances();
await createModelsInstanceChunkRequest(); await createModelsInstanceChunkRequest();
await createModelsChunkRequest(); await createModelsChunkRequest();
await startTargetsWatch();
}); });
const handleOnCancelViewLogs = useMemoizedFn(async () => { const handleOnCancelViewLogs = useMemoizedFn(async () => {
@@ -443,6 +458,20 @@ const Models = forwardRef((props, ref) => {
}; };
}, []); }, []);
// watch events can still be lost (stream hiccup, reconnect gap); a low
// frequency relist keeps the instance cache eventually consistent, so a
// missed DELETE event can't leave a stale instance behind for good
useEffect(() => {
const timer = setInterval(() => {
if (!isPageHidden.current) {
getAllModelInstances();
}
}, 60 * 1000);
return () => {
clearInterval(timer);
};
}, []);
const setDisableExpand = useMemoizedFn((record: any) => { const setDisableExpand = useMemoizedFn((record: any) => {
return !record?.replicas; return !record?.replicas;
}); });
@@ -483,6 +512,7 @@ const Models = forwardRef((props, ref) => {
total={dataSource.total} total={dataSource.total}
deleteIds={dataSource.deletedIds} deleteIds={dataSource.deletedIds}
filterValues={filterValues} filterValues={filterValues}
targetList={targetList}
></TableList> ></TableList>
</TableProvider> </TableProvider>
); );
+51 -24
View File
@@ -13,7 +13,7 @@ import {
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form } from 'antd'; import { Form } from 'antd';
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { DeployFormKeyMap, sourceOptions } from '../config'; import { DeployFormKeyMap, sourceOptions } from '../config';
import { useFormContext } from '../config/form-context'; import { useFormContext } from '../config/form-context';
@@ -24,6 +24,7 @@ import CustomBackend from './custom-backend';
import LocalPathSource from './local-path-source'; import LocalPathSource from './local-path-source';
import ModeField from './mode-field'; import ModeField from './mode-field';
import OnlineSource from './online-source'; import OnlineSource from './online-source';
const ClusterOption = styled.span` const ClusterOption = styled.span`
display: flex; display: flex;
padding: 8px 0; padding: 8px 0;
@@ -84,6 +85,7 @@ interface BasicFormProps {
} }
>[]; >[];
handleClusterChange: (value: number) => void; handleClusterChange: (value: number) => void;
onClusterSeed: (value: number) => void;
onSourceChange?: (value: string) => void; onSourceChange?: (value: string) => void;
} }
@@ -93,6 +95,7 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
clusterList, clusterList,
sourceDisable, sourceDisable,
handleClusterChange, handleClusterChange,
onClusterSeed,
onSourceChange onSourceChange
} = props; } = props;
const intl = useIntl(); const intl = useIntl();
@@ -116,7 +119,6 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
// cluster dropdown to that org's own clusters — the backend derives the // cluster dropdown to that org's own clusters — the backend derives the
// deployment's owner from the chosen cluster, so this keeps them aligned. // deployment's owner from the chosen cluster, so this keeps them aligned.
const scopeOrgId = Form.useWatch('organization_id', form); const scopeOrgId = Form.useWatch('organization_id', form);
const prevScopeRef = useRef<number | null | undefined>(undefined);
const clusterOptions = useMemo(() => { const clusterOptions = useMemo(() => {
return clusterList return clusterList
@@ -139,37 +141,62 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
}); });
}, [clusterList, scopeOrgId]); }, [clusterList, scopeOrgId]);
// On a genuine org change (not the initial value — the modal's open // Keep the cluster selection consistent with the available (scoped)
// handler seeds the first cluster), drop a now-out-of-scope cluster and // options. The modal's open handler seeds a cluster, but that seed can be
// re-pick within the new org so GPU/backend options refetch for it. // empty (options not loaded yet) or point outside the current scope (the
// scope id and the option list both settle after mount, and an org switch
// re-scopes the list) — leaving the field blank even though a valid option
// exists. Whenever the scope or the options change, drop an invalid/empty
// selection and fall back to the scope's default cluster (then a Ready one,
// then the first) so GPU/backend options refetch for it. A selection that's
// still valid is left untouched, so a user's (or edit's) choice is kept.
// Use the seed callback (not handleClusterChange) so this auto-pick refreshes
// options without firing an evaluate request before a model is selected.
useEffect(() => { useEffect(() => {
if (prevScopeRef.current === undefined) { // Options derive from clusterList: an empty source list means clusters
prevScopeRef.current = scopeOrgId; // are still loading — leave the field alone until they arrive.
if (!clusterList?.length) {
return; return;
} }
if (prevScopeRef.current === scopeOrgId) { // Scope off the live form value, not the `useWatch` snapshot: the scope
return; // field's default lands in a child effect that flushes before this one,
} // while the watch still reports the previous render's null — scoping off
prevScopeRef.current = scopeOrgId; // the watch would seed a cluster from the unscoped list here and only
// re-scope a render later.
const liveScopeOrgId = form.getFieldValue('organization_id') ?? null;
const scoped = clusterList.filter(
(item) =>
liveScopeOrgId == null || item.owner_principal_id === liveScopeOrgId
);
const current = form.getFieldValue('cluster_id'); const current = form.getFieldValue('cluster_id');
const stillValid = clusterOptions?.some((c) => c.value === current); if (!scoped.length) {
if (stillValid) { // Clusters are loaded but the picked org owns none. Any leftover
// selection points at another org's cluster (seeded before the scope
// settled) and would make requests fail with "Cluster not found" —
// clear it so the required rule surfaces instead. Create only: an
// edit's cluster is existing data, not a seed.
if (action === PageAction.CREATE && current != null) {
form.setFieldValue('cluster_id', undefined);
}
return;
}
const stillValid = scoped.some((c) => c.value === current);
if (current != null && stillValid) {
return; return;
} }
const next = const next =
clusterOptions?.find((c) => c.is_default)?.value ?? scoped.find((c) => c.is_default)?.value ??
clusterOptions?.find((c) => c.state === ClusterStatusValueMap.Ready) scoped.find((c) => c.state === ClusterStatusValueMap.Ready)?.value ??
?.value ?? scoped[0]?.value ??
clusterOptions?.[0]?.value ??
null; null;
form.setFieldValue('cluster_id', next ?? null); if (next == null || next === current) {
if (next != null) { return;
handleClusterChange?.(next as number);
} }
// The prevScopeRef guard above makes this a no-op unless scopeOrgId form.setFieldValue('cluster_id', next);
// actually changed, so listing the other deps is safe (no extra runs). onClusterSeed?.(next);
}, [scopeOrgId, clusterOptions, form, handleClusterChange]); // `clusterOptions` is the re-run trigger for scope changes: it recomputes
// whenever the watched org scope or the cluster list settles.
}, [clusterOptions, clusterList, action, form, onClusterSeed]);
const clusterOptionRender = (option: any) => { const clusterOptionRender = (option: any) => {
const { data } = option; const { data } = option;
+18 -2
View File
@@ -236,20 +236,35 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
onOk(allValues); onOk(allValues);
}; };
const handleClusterChange = async (value: number) => { // Shared work when the target cluster changes: refetch the GPU/backend
await onClusterChange?.(value); // options for the new cluster and reset schedule/gpu selection.
const applyClusterScopedOptions = (value: number) => {
getGPUOptionList({ clusterId: value }); getGPUOptionList({ clusterId: value });
getBackendOptions({ cluster_id: value }); getBackendOptions({ cluster_id: value });
form.setFieldsValue({ form.setFieldsValue({
scheduleType: ScheduleValueMap.Auto, scheduleType: ScheduleValueMap.Auto,
gpu_selector: null 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) => { await new Promise((resolve) => {
setTimeout(resolve, 150); setTimeout(resolve, 150);
}); });
onValuesChange?.({}, form.getFieldsValue()); 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 getFieldPaths = (obj: Record<string, any>, prefix = ''): string => {
const result = Object.entries(obj).flatMap(([key, value]) => { const result = Object.entries(obj).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key; const path = prefix ? `${prefix}.${key}` : key;
@@ -467,6 +482,7 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
clusterList={clusterList} clusterList={clusterList}
sourceDisable={sourceDisable} sourceDisable={sourceDisable}
handleClusterChange={handleClusterChange} handleClusterChange={handleClusterChange}
onClusterSeed={handleClusterSeed}
onSourceChange={onSourceChange} onSourceChange={onSourceChange}
></BasicForm> ></BasicForm>
<CollapsePanel <CollapsePanel
@@ -20,6 +20,7 @@ import {
import { useFormContext } from '../config/form-context'; import { useFormContext } from '../config/form-context';
import { FormData } from '../config/types'; import { FormData } from '../config/types';
import { backendOptionsMap } from '../constants/backend-parameters'; import { backendOptionsMap } from '../constants/backend-parameters';
import '../style/gpu-selector.less';
const InputWrapper = styled.div` const InputWrapper = styled.div`
padding: 8px 4px; padding: 8px 4px;
+19
View File
@@ -401,6 +401,24 @@ export const useCheckCompatibility = () => {
); );
}; };
// Evaluation needs a model reference. The basic form seeds a default cluster
// on open, which can fire onValuesChange before the user has picked a model —
// skip evaluation until the current source's model field is filled.
const noModelSelected = (allValues: any) => {
switch (allValues.source) {
case modelSourceMap.huggingface_value:
return !allValues.huggingface_repo_id;
case modelSourceMap.modelscope_value:
return !allValues.model_scope_model_id;
case modelSourceMap.ollama_library_value:
return !allValues.ollama_library_model_name;
case modelSourceMap.local_path_value:
return !allValues.local_path;
default:
return false;
}
};
const handleOnValuesChange = async (params: { const handleOnValuesChange = async (params: {
changedValues: any; changedValues: any;
allValues: any; allValues: any;
@@ -410,6 +428,7 @@ export const useCheckCompatibility = () => {
if ( if (
_.isEqual(cacheFormValuesRef.current, allValues) || _.isEqual(cacheFormValuesRef.current, allValues) ||
noLocalPathValue(allValues) || noLocalPathValue(allValues) ||
noModelSelected(allValues) ||
!allValues.replicas !allValues.replicas
) { ) {
console.log('No changes detected, skipping evaluation.'); console.log('No changes detected, skipping evaluation.');
@@ -24,7 +24,7 @@ const useFilterStatus = (options?: {
const statusOptions = [ const statusOptions = [
{ {
value: MyModelsStatusValueMap.Active, value: MyModelsStatusValueMap.Ready,
color: 'var(--ant-color-success)', color: 'var(--ant-color-success)',
icon: <Dot color="var(--ant-color-success)"></Dot>, icon: <Dot color="var(--ant-color-success)"></Dot>,
label: intl.formatMessage({ label: intl.formatMessage({
@@ -32,7 +32,7 @@ const useFilterStatus = (options?: {
}) })
}, },
{ {
value: MyModelsStatusValueMap.Inactive, value: MyModelsStatusValueMap.Stopped,
color: 'var(--ant-color-fill-secondary)', color: 'var(--ant-color-fill-secondary)',
icon: <Dot color="var(--ant-color-fill-secondary)"></Dot>, icon: <Dot color="var(--ant-color-fill-secondary)"></Dot>,
label: intl.formatMessage({ label: intl.formatMessage({
@@ -40,7 +40,7 @@ const useFilterStatus = (options?: {
}) })
}, },
{ {
value: MyModelsStatusValueMap.Degrade, value: MyModelsStatusValueMap.NotReady,
color: 'var(--ant-color-warning)', color: 'var(--ant-color-warning)',
icon: <Dot color="var(--ant-color-warning)"></Dot>, icon: <Dot color="var(--ant-color-warning)"></Dot>,
label: intl.formatMessage({ label: intl.formatMessage({
@@ -199,7 +199,13 @@ export const useGenerateWorkerOptions = () => {
page: -1 page: -1
}), }),
queryClusterList({ 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[]); const workerList = workerRes.items || ([] as WorkerListItem[]);
@@ -272,7 +278,13 @@ export default function useFormInitialValues() {
// Exclude clusters that opt in to GPU-instance handling // Exclude clusters that opt in to GPU-instance handling
// (k8s_options.gpu_instance_options set) — those are for the // (k8s_options.gpu_instance_options set) — those are for the
// GPU-service flow, not model deployment. // 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) => ({ const list = response.items.map((item) => ({
label: item.name, label: item.name,
@@ -268,7 +268,7 @@ const useModelsColumns = ({
dataIndex: 'created_at', dataIndex: 'created_at',
key: 'created_at', key: 'created_at',
sorter: tableSorter(5), sorter: tableSorter(5),
span: spans.createTime, width: 180,
render: (text: number) => ( render: (text: number) => (
<AutoTooltip ghost> <AutoTooltip ghost>
{dayjs(text).format('YYYY-MM-DD HH:mm:ss')} {dayjs(text).format('YYYY-MM-DD HH:mm:ss')}
@@ -95,6 +95,7 @@ const useNoResourceResult = (props: {
const noResourceResult = ( const noResourceResult = (
<NoResult <NoResult
minHeight="calc(100vh - 300px)"
loading={loading} loading={loading}
loadend={loadend} loadend={loadend}
dataSource={dataSource} 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;

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