Compare commits

..
83 Commits
Author SHA1 Message Date
gitlawr bda8264788 fix: playground model id alignment across org boundaries
Two QA-reported bugs on the "Open in Playground" path. Both come
down to the model id the playground submits not matching what
``/v1/models`` / the dispatcher key off:

* Routes page (OSS UI) emitted ``default/<name>`` for routes in the
  platform Org and 404'd. ``use-open-playground`` keyed off the
  ``is_platform`` flag alone; stale caches that drop the flag
  slipped through. Also accept the well-known ``name === 'default'``
  (``PLATFORM_PRINCIPAL_NAME`` on the backend) as a fallback signal.

* My Models page (enterprise UI, non-admin) emitted bare ``<name>``
  with no Org prefix for non-platform routes. Use ``model.name``
  from ``/v2/my-models`` verbatim — the backend ("fix: principal
  prefix in my-models") now rewrites that field to the OpenAI-style
  id server-side, which also closes the cross-Org grant gap a
  client-side cache lookup can't (the granting Org isn't in the
  caller's member list). Card title now shows the prefixed id, so
  users can tell apart same-named models from different Orgs.

Routes page keeps ``useOpenPlayground`` — that surface is always
scoped to the caller's own Org, the local cache is sufficient, and
``/model-routes`` still returns the raw ``name``.

Also drops the now-unused ``onClick`` prop on ``ModelItem``: the
card had ``clickable={false}`` so the parent-passed handler was
already dead code; the Button drives the playground navigation.
2026-06-07 15:45:49 +08:00
gitlawr ab8e792276 feat(cluster): card-based cluster type selector for K8s
Replace the GPU Service switch (with the small "cannot be used for
model service" caption) with a two-card radio selector that makes the
choice between Model Service and GPU Service explicit at a glance.

- Render two cards styled to match the existing SwitchCard (same
  border, radius, 12px/14px padding, and label/description typography)
  so the selector blends in with the surrounding form fields.
- Underlying form state is unchanged: picking GPU Service still seeds
  k8s_options.gpuInstanceOptions = {}, picking Model Service clears
  it — so the static-address field, the EDIT-mode change watcher, and
  the API payload all keep working as before.
- Align the GPU Service label with the top-level menu entry
  (menu.gpuService) — drop "Instance" / "实例" from the card title
  and the static access address label.
2026-06-07 13:44:50 +08:00
jialinandjialin 0e4be63b6b chore(gaas): add a unknown phase 2026-06-06 21:09:56 +08:00
jialinandjialin d3ebea9090 fix: locales, empty link in add worker 2026-06-06 14:29:49 +08:00
jialinandjialin 15a9e23243 fix: convert cpu unit to core 2026-06-06 14:29:49 +08:00
jialinandjialin eab7d9c77f style: usage chart 2026-06-06 14:29:49 +08:00
jialinandjialin 4a86b71018 fix: ignore request errors on setup 2026-06-06 14:29:49 +08:00
jialinandjialin 6350f7f11a chore(i18n): usage locales 2026-06-05 23:59:25 +08:00
jialinandLawrence Li ce6388253c fix: cpu init value failed 2026-06-05 23:08:45 +08:00
jialinandLawrence Li d8f6064d5e fix: usage locales 2026-06-05 23:08:45 +08:00
jialinandLawrence Li 9f5d95f565 fix: align instance type fields to api 2026-06-05 23:08:45 +08:00
jialinandjialin 0cabbeb81c fix(style): cluster detail locale 2026-06-05 22:29:39 +08:00
jialinandjialin 76618aa3a1 fix(style): cluster detail 2026-06-05 22:18:26 +08:00
gitlawrandjialin b19db0f67d fix(clusters): call out that GPU instance service blocks model service
The "GPU Instance Service" toggle on the cluster create form only said
what it enables. The exclusion side — once flipped, the cluster drops
out of the model-deploy picker — was invisible until the user went
looking for the cluster elsewhere. Extend the tip to spell that out
in all five locales, using the canonical "GPU instance service" /
"model service" product terms.
2026-06-05 18:56:42 +08:00
gitlawrandjialin 76f228bfe7 fix(llmodels): hide GPU-service clusters from deploy picker
The deploy form's cluster dropdown fetched every visible cluster, so
clusters configured for GPU-service (k8s_options.gpu_instance_options
set) showed up as deployable targets even though deployments cannot
run on them.

Pass gpu_instance_enabled=false on the cluster-list query so the
backend returns only model-deployment clusters. GPU-instance creation
does not pick a cluster directly (it is derived from the instance
type), so no symmetric change is needed there.
2026-06-05 18:56:42 +08:00
gitlawrandjialin be40692773 feat(login): add onUserFetched plugin hook
The host's access function is memoized on `initialState` and runs
exactly once per commit. Plugins that maintain identity-scoped
caches the access predicate reads from (e.g. an org context cache)
had no way to seed those caches synchronously before the caller's
`setInitialState({currentUser: ...})` fired — any post-commit hydrate
couldn't widen the predicate, leaving the sidebar in a stale view
until the next identity change.

Add `LoginPlugin.onUserFetched(userInfo, ctx)` and call it inside
`fetchUserInfo` after the server confirms identity but before
returning. Also commit the identity to `userAtom` storage here so
localStorage's identity marker is in lockstep with whatever caches
the plugin seeds — the predicate's first evaluation then sees a
consistent view rather than the prior session's data.

Errors thrown from the hook are swallowed and logged; they never
block fetchUserInfo.
2026-06-05 18:55:46 +08:00
gitlawrandjialin 42ac377c17 feat: tease enterprise multi-tenancy and API key controls in OSS
Add an Organizations menu entry (admin-only) that routes to an upsell
page explaining the multi-tenancy module, and surface disabled
IP Access Control / Quota Limit items in the API Key dropdown with a
tooltip pointing at the enterprise edition. Both placeholders are
shadowed by the enterprise plugin at build time: the route merger
removes the OSS Organizations entry by name, and the dropdown skips
each placeholder when the same key is contributed via configActions.
2026-06-05 16:41:34 +08:00
jialinandjialin 46f859cc3c fix: no need to show error in login for first 2026-06-05 16:24:46 +08:00
gitlawrandjialin f2bff0d1fb fix(storage): remove duplicate error toast on create failure 2026-06-05 16:20:49 +08:00
gitlawrandjialin 0bd98b9662 fix(llmodels): scope catalog deploy cluster seed by create-scope org
initClusterId picked the platform default cluster without considering
the form's organization_id, so opening the catalog deploy form in the
admin all-scope view could seed a cluster that the (org-filtered)
dropdown then hides — submit failed with "Cluster not found".

Mirror the same scope-aware selection deploy-modal already does:
filter clusterList by owner_principal_id when organization_id is set
before picking default/ready/first.

Return number | undefined honestly (the picked org may own no
clusters, or clusterList may still be loading) and guard the
open-handler caller so undefined doesn't flow into fetchSpecData /
getGPUOptionList — the user resolves the empty state by picking an
org that owns clusters.
2026-06-05 13:11:41 +08:00
micheliaandjialin 1ac2c02197 fix: change benchmark model name query condition to input 2026-06-05 12:30:25 +08:00
jialinandjialin 907187d53a fix: show worker added message 2026-06-05 10:20:55 +08:00
Yuxing Dengandjialin faf6246fe6 fix: remove worker config bg color 2026-06-05 10:19:08 +08:00
Yuxing Dengandjialin 0c16e2be7a fix: add notification when editing cluster's k8s_options 2026-06-05 10:19:08 +08:00
micheliaandjialin 3212882895 chore(menu): move Usage below Resources in the sidebar 2026-06-04 19:52:52 +08:00
micheliaandjialin 62f34ffcaf feat(usage): resource usage metering page
Add the Usage page with Summary / Tokens / GPU Instances / Storage / Resource
Events tabs over the new metering endpoints: per-resource breakdowns with
date / scope / user / resource filters, trend charts, server-side sortable
tables (GPU-Hours, Instance-Hours, GB-Days, GB-Hours), Excel export with an
in-dialog preview, and KPI cards with help tooltips explaining each metric.

MaaS-only users (no Kubernetes cluster and no resource events) get a
tokens-only view with the tab bar dropped; GPU Service / the full page unlock
for admins, cluster owners, or anyone who has run a resource. Instance-type
rows reuse the GPU Instances list styling, and deleted users / instances /
volumes are flagged in breakdowns and filters.
2026-06-04 19:52:52 +08:00
micheliaandjialin 50601b0aff feat(gpu-service): instance-type spec popover with per-card specs
Replace the raw flavor slug in the GPU Instances list with the product name
("<product> x <count>") plus an info-icon popover that breaks the spec down by
category (GPU / CPU / Memory / Disk): per-card VRAM, whole-instance CPU/RAM,
system / ephemeral / persistent disks (persistent size resolved from the
referenced PV). Extract the cell into a shared InstanceTypeCell and centralize
memory formatting in formatMemoryDisplay so the GPU Instances list and the
Usage tab render identical sizes.
2026-06-04 19:52:52 +08:00
gitlawrandjialin 4c5d42cb13 feat(version): respect GPUSTACK_UI_* env overrides in build info
Allow a wrapping build that checks this source tree out as a
sub-package to stamp its own release tag and commit id onto the UI
(otherwise the version panel reports the host tree's git HEAD,
which the wrapper doesn't control).

GPUSTACK_UI_VERSION overrides the release tag and GPUSTACK_UI_COMMIT_ID
overrides the short commit id; both fall back to the git tag /
commit at HEAD when unset, preserving existing behavior. Names are
namespaced to avoid colliding with the many tools and CI runners
that already set a generic VERSION.
2026-06-04 18:21:13 +08:00
gitlawrandjialin 85268b8917 fix(api-keys): scope creator filter to current org members
The "Filter by creator" dropdown was listing every user in the
system. For an Org owner viewing an Org-scoped key list, picking
a user outside the Org always produced an empty result. Pass
scope=current_org to `/user-directory` so the picker matches the
list's actual scope. BE drops the param when the request has no
Org context, so callers without an Org are unchanged.
2026-06-04 18:09:32 +08:00
gitlawrandjialin 973d2c4529 feat(login): add plugin seam for default landing path
Plugins can supply `LoginPlugin.resolveDefaultPath` to override the
post-login redirect target. checkDefaultPage consults the hook and
uses the returned path when non-null; otherwise falls back to the
existing admin/non-admin defaults. The lookup runs in parallel with
the IS_FIRST_LOGIN read. Applies to both first-login and
subsequent-login flows.
2026-06-04 15:56:24 +08:00
jialinandjialin ab766b8c54 style: menu collapse button 2026-06-04 15:48:44 +08:00
jialinandjialin 4945242c87 style: login page size 2026-06-04 15:48:44 +08:00
jialinandjialin c4a72eb375 style: collapse style 2026-06-04 15:48:44 +08:00
gitlawrandjialin b12d11728d fix: update model files menu access 2026-06-04 13:16:47 +08:00
Yuxing Dengandjialin 8f0e3a7576 fix: remove k8s options section and merge into advance
And re-order the configuration inputs. The current input order is:
- namespace
- volume mounts
- registry credentials
- node selector
- system-default-registry
- operator image
- gpu instance static access address
- worker config yaml
2026-06-03 23:17:38 +08:00
Yuxing Dengandjialin 4810ff2dbe fix: enhance cluster create form 2026-06-03 23:17:38 +08:00
jialinandjialin a221b22be9 style: instance type data format 2026-06-03 21:29:31 +08:00
jialinandjialin 24b458530d fix(style): sso button width 2026-06-03 21:29:31 +08:00
jialinandjialin caea43590a chore: useMemo deps 2026-06-03 18:28:31 +08:00
jialinandjialin c3d7a0b85e chore: add locales 2026-06-03 18:28:31 +08:00
jialinandjialin 5c6f28027d chore: add locales 2026-06-03 18:28:31 +08:00
jialinandjialin dc04529f5b fix: vendor options display by locales 2026-06-03 18:28:31 +08:00
jialinandjialin 2bb04a419d fix: sshkey, storage selection displayName 2026-06-03 18:28:31 +08:00
jialinandjialin 8e31b71668 chore: merge cluster menu to resource 2026-06-03 18:28:31 +08:00
micheliaandmichela feng f055453889 fix(usage): defensive optional-chaining on breakdown items + sku 2026-06-03 17:10:51 +08:00
micheliaandmichela feng 820a6cecdd fix(gpu-service): avoid stringifying unset cpu in the instance form
`${values.spec?.resources?.cpu}` turned an unset cpu into the literal
string "undefined", which fails k8s quantity validation. Omit cpu when
it has no value instead.
2026-06-03 17:10:51 +08:00
micheliaandmichela feng c3a779e4c8 feat(usage): register the Usage route and menu group
Add the /usage menu folder with its overview child pointing at the
tabbed Usage page.
2026-06-03 17:10:51 +08:00
micheliaandmichela feng 00008abf03 feat(usage): add the tabbed Usage page
A Tabs shell hosting Tokens / GPU Instances / Storage / Summary /
Resource Events:
- token-tab: the original token view, wrapped as a tab.
- gpu-instances-tab & storage-tab: per-instance / per-volume breakdowns
  with KPI cards, a metric+granularity trend chart, and grouped tables.
- summary-tab: three-domain (Tokens / Compute / Storage) overview with
  headline stats, donut, and trend.
- resource-events: the lifecycle audit log (Started / Stopped).
- resource-filter-bar + metric-chart-card: shared filter / chart controls
  mirroring the Tokens tab.
- resource-export-data: a preview modal (filter + paginated preview, then
  download) backing the Export Chart Data / Export Table Data actions.
2026-06-03 17:10:51 +08:00
micheliaandmichela feng d9c6f2f780 feat(usage): add i18n strings for the Usage page
Menu label (Usage) and the resource-tab strings (filters, export
chart/table, table headers) across all five locales.
2026-06-03 17:10:51 +08:00
micheliaandmichela feng 9a4d77e869 style(usage): tune shared bar/pie charts for the usage views
- bar-chart: barMinWidth so hour-granularity bars stay visible with gaps.
- pie-chart: single legend-beside-pie layout, value (percent%) tooltip,
  2-decimal rounding for the Summary donut.
2026-06-03 17:10:51 +08:00
micheliaandmichela feng 2407416e33 feat(usage): add resource-usage API client, meta hook, and shared utils
The data layer the resource tabs build on:
- apis/resource.ts: adapter over the unified metered_usage read API
  (resource/gpu-instances/storage/summary/events breakdowns), flattening
  the server's generic shape into the per-tab item shape.
- hooks/use-resource-meta.ts: loads creators/instances/volumes filter
  options for the current scope.
- utils/time-buckets.ts: day/week/month/hour bucket keys + range fill.
- utils/export-breakdown.ts: derive Excel columns from antd table specs.
2026-06-03 17:10:51 +08:00
jialinandjialin 234e42ccfa chore: remove comment 2026-06-03 15:50:06 +08:00
jialinandjialin 4426b8dc30 fix: request noop 2026-06-03 15:50:06 +08:00
jialinandjialin 9541f49f39 fix(style): table cell overflow 2026-06-03 15:50:06 +08:00
jialinandjialin 9f7a99f370 fix: refresh list after creating key 2026-06-03 15:50:06 +08:00
jialinandjialin 12f0bb2f98 chore: request.extensions 2026-06-03 15:50:06 +08:00
jialinandjialin ac45a72e0e fix: api acccess info model name 2026-06-03 15:50:06 +08:00
jialinandjialin 9705818b15 fix: wrap fetch for injecting headers 2026-06-03 15:50:06 +08:00
jialinandjialin 39374eafda fix: update params after switching model 2026-06-03 15:50:06 +08:00
Yuxing Dengandjialin 110bc3205e fix: failed to enable gpu instance while creating cluster 2026-06-03 15:33:33 +08:00
gitlawrandjialin 57fec89f2d feat(api-keys): show creator column and filter to org owners
Switch the gate from `currentUser.is_admin` to `access.canSeeOrgAdmin`
so Org owners get the same all-keys view (Creator column, creator
filter, `user_id: '*'` default) that platform admins have. Mirrors
the BE's "platform admin OR current-Org owner" gate on listing every
key in scope.

The user picker now fetches `/user-directory` instead of the
admin-only `/users` endpoint, which would 403 Org owners. Rename the
filter placeholder from the misnamed `models.table.filterByName` to
`common.filter.byCreator`.
2026-06-03 15:25:38 +08:00
gitlawrandjialin 72e794c281 fix: align playground default model id with org namespace
Opening an org-scoped deployment in the Playground pre-selected the bare
model name (e.g. `qwen3-0.6b`), which never matched the org-namespaced
option the `/v1/models` dropdown actually lists (`org1/qwen3-0.6b`), so
the selection showed an unmatched value.

Resolve the owning org from the route row's `owner_principal_id` (falling
back to the org the caller is currently acting under for the admin "All"
view) and reconstruct the same `{org}/{name}` id the server reports. The
platform org carries no prefix, matching the server's behaviour.

- user.ts: add `getOrgById`/`getCurrentOrg` that scan both org caches
  (`organizationList` + admin-only `allOrganizations`) with string-
  normalised id comparison; `getOrgNameById` is now a thin wrapper.
  Helpers accept `undefined` so optional row fields type-check.
- use-open-playground: build the prefix from the resolved org record,
  keying the skip-prefix decision off `is_platform`.
- RouteItem: declare the `owner_principal_id` the list API returns.
- RouteTargetFormItem: drop the duplicate `overridden_model_name`
  declaration that TS flagged as a duplicate identifier.
2026-06-03 10:59:43 +08:00
gitlawrandjialin fadbcc3e44 feat(benchmark): scope the benchmark create form by organization
Add the organization picker to the benchmark create form (platform-admin
"all organizations" view). Fetch the chosen org's clusters and keep only
those it owns; refetch the org-scoped model / instance list on org change;
clear a stale cluster or target when switching to an org with no clusters.
2026-06-03 10:14:50 +08:00
gitlawrandjialin 9e30e964e6 feat(models): scope deployment and model-file create by organization
Add the organization picker to the model deployment and model-file
download forms (platform-admin "all organizations" view). Filter the
cluster / worker pickers to clusters the chosen org owns, so the created
resource's owner stays aligned with where it runs and a cross-org cluster
can't be selected. Carry owner ids on the cluster / worker options to
drive the filter.
2026-06-03 10:14:50 +08:00
gitlawrandjialin fbb707d014 feat(gpu-service): scope create forms by the selected organization
In the platform-admin "all organizations" view, add an organization picker
(the CreateOrgScopeField slot) to the SSH public key, storage type,
storage, GPU instance and instance-template create forms, placed below the
name / display-name fields. Drop the hidden owner field and let the owner
derive from the request context, matching the model-route form.

For GPU instances, scope the instance-type list to clusters the chosen org
owns (client-side, by cluster owner) so an instance can't be scheduled onto
another org's cluster; when the org owns none, show "no instance type
available" and clear the selection, cluster and CPU/memory fields. Instance
templates gain a Global level (NULL owner) and an owner tag on the card.
2026-06-03 10:14:50 +08:00
gitlawrandjialin d2d82ee5ee feat(request): add a response-interceptor seam
Add `extraResponseInterceptors` next to the existing request-interceptor
seam and wire it into the request config, so build-time tooling can react
to responses (e.g. clear a one-shot request-scoped hint after a write
completes). Default is an empty list — no behavior change by default.
2026-06-03 10:14:50 +08:00
jialinandjialin 9061fa08ba chore(deps): update ui 1.0.22 2026-06-02 17:51:25 +08:00
jialinandjialin f29236518e style: selector max tag count 2026-06-02 17:51:25 +08:00
jialinandjialin 6537de5224 fix(style): worker step container 2026-06-02 17:51:25 +08:00
jialinandjialin 0413d470a8 fix(style): worker upgrade tips do not show 2026-06-02 17:51:25 +08:00
jialinandjialin c01f4d3ce8 style: table split 2026-06-02 15:18:00 +08:00
jialinandjialin dd9d359bb0 chore: storage label 2026-06-02 15:18:00 +08:00
jialinandjialin b444c35643 style: side menu scroll bar 2026-06-02 15:18:00 +08:00
jialinandjialin abd0d52fad chore: perferences route 2026-06-02 15:18:00 +08:00
yxfandjialin c8771dd3b9 fix(label): update LoRA Adapter label to plural form 2026-06-02 15:13:55 +08:00
jialinandjialin 6d16f56183 chore(deps): update ui 1.0.21 2026-06-01 21:06:56 +08:00
jialinandjialin 95ffe0350e fix(style): k8s form 2026-06-01 21:06:56 +08:00
jialinandjialin e314f25a77 style: gpu instance service 2026-06-01 21:06:56 +08:00
gitlawrandjialin 65ea65e9ae feat(api-keys): rename User Name column to Creator and move before Created
Rename the api-keys list column from User Name to Creator for clarity and reorder it to sit immediately before the Created column. Add the common.table.creator locale key across all supported languages.
2026-06-01 19:13:44 +08:00
jialinandjialin 4ce975562e chore(deps): update ui 1.0.20 2026-06-01 13:36:14 +08:00
jialinandjialin 07e625f2bc chore: add locales 2026-06-01 13:36:14 +08:00
jialinandjialin d95fd8dc90 fix: instance type format in list 2026-05-31 22:23:32 +08:00
jialinandjialin 55028cf3a3 fix: parse quality data 2026-05-31 22:23:32 +08:00
177 changed files with 7766 additions and 2041 deletions
+59 -42
View File
@@ -140,15 +140,6 @@ const baseRoutes = [
access: 'canSeeOrgAdmin',
component: './model-routes/index'
},
{
name: 'usage',
path: '/models/usage',
key: 'usage',
icon: 'icon-usage-outlined',
selectedIcon: 'icon-usage-filled',
defaultIcon: 'icon-usage-outlined',
component: './usage/index'
},
{
name: 'providers',
path: '/models/providers',
@@ -197,6 +188,7 @@ const baseRoutes = [
icon: 'icon-files',
selectedIcon: 'icon-files-filled',
defaultIcon: 'icon-files',
access: 'canSeeOrgAdmin',
component: './resources/components/model-files'
}
]
@@ -274,6 +266,16 @@ const baseRoutes = [
path: '/resources',
redirect: '/resources/workers'
},
{
name: 'clusters',
path: '/resources/clusters/list',
key: 'clusters',
icon: 'icon-cluster2-outline',
selectedIcon: 'icon-cluster2-filled',
defaultIcon: 'icon-cluster2-outline',
component: './cluster-management/clusters',
subMenu: ['/resources/clusters/detail', '/resources/clusters/create']
},
{
name: 'workers',
path: '/resources/workers',
@@ -291,50 +293,51 @@ const baseRoutes = [
selectedIcon: 'icon-gpu-filled',
defaultIcon: 'icon-gpu1',
component: './resources/components/gpus'
}
]
},
{
name: 'clusterManagement',
path: '/cluster-management',
key: 'clusterManagement',
access: 'canSeeOrgAdmin',
routes: [
{
path: '/cluster-management',
redirect: '/cluster-management/clusters/list'
},
{
name: 'clusters',
path: '/cluster-management/clusters/list',
key: 'clusters',
icon: 'icon-cluster2-outline',
selectedIcon: 'icon-cluster2-filled',
defaultIcon: 'icon-cluster2-outline',
component: './cluster-management/clusters',
subMenu: [
'/cluster-management/clusters/detail',
'/cluster-management/clusters/create'
]
name: 'credentials',
path: '/resources/credentials',
key: 'credentials',
icon: 'icon-credential-outline',
selectedIcon: 'icon-credential-filled',
defaultIcon: 'icon-credential-outline',
component: './cluster-management/credentials'
},
{
name: 'clusterDetail',
path: '/cluster-management/clusters/detail',
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'
}
]
},
{
// Cross-resource consumption (tokens + GPU/CPU instances + storage).
// A folder so it matches the other top-level groups; more usage views can
// graduate in here later.
name: 'billingAndUsage',
path: '/usage',
key: 'usageGroup',
icon: 'icon-usage-outlined',
selectedIcon: 'icon-usage-filled',
defaultIcon: 'icon-usage-outlined',
routes: [
{
path: '/usage',
redirect: '/usage/overview'
},
{
name: 'credentials',
path: '/cluster-management/credentials',
key: 'credentials',
icon: 'icon-credential-outline',
selectedIcon: 'icon-credential-filled',
defaultIcon: 'icon-credential-outline',
component: './cluster-management/credentials'
name: 'usage',
path: '/usage/overview',
key: 'usage',
icon: 'icon-usage-outlined',
selectedIcon: 'icon-usage-filled',
defaultIcon: 'icon-usage-outlined',
component: './usage/index'
}
]
},
@@ -347,6 +350,20 @@ const baseRoutes = [
path: '/access-control',
redirect: '/access-control/users'
},
{
name: 'organizations',
path: '/access-control/organizations',
key: 'organizations',
icon: 'icon-org-outlined',
selectedIcon: 'icon-org-filled',
defaultIcon: 'icon-org-outlined',
// OSS exposes the menu to platform admins as a teaser for the
// enterprise multi-tenancy module. The page itself just renders
// an upsell notice — the real CRUD UI lives in the enterprise
// plugin and shadows this route via `routes.extensions.ts`.
access: 'canSeeAdmin',
component: './organizations'
},
{
name: 'users',
path: '/access-control/users',
@@ -380,8 +397,8 @@ const baseRoutes = [
},
{
name: 'profile',
path: '/profile',
key: 'profile',
path: '/preferences',
key: 'preferences',
hideInMenu: true,
component: './profile',
icon: 'User'
+24 -10
View File
@@ -1,13 +1,27 @@
const child_process = require('child_process');
import { execSync } from 'child_process';
export const getBranchInfo = () => {
const latestCommit = child_process
.execSync('git rev-parse HEAD')
.toString()
.trim();
const versionTag = child_process
.execSync(`git tag --contains ${latestCommit}`)
.toString()
.trim();
return { version: versionTag || '', commitId: latestCommit.slice(0, 7) };
// git may be absent (source archive, bare container) or this tree may
// not be a git checkout. Swallow the failure and fall back to the env
// overrides below — losing build info shouldn't fail the build.
let latestCommit = '';
let versionTag = '';
try {
latestCommit = execSync('git rev-parse HEAD').toString().trim();
versionTag = execSync(`git tag --contains ${latestCommit}`)
.toString()
.trim();
} catch {
// Not a git checkout / git unavailable; rely on env overrides.
}
// Respect explicit GPUSTACK_UI_* overrides so a wrapping build that
// checks this source tree out as a sub-package can stamp its own
// release tag and commit id onto the UI (otherwise the panel reports
// the host tree's git HEAD, which the wrapper doesn't control).
const overrideVersion = process.env.GPUSTACK_UI_VERSION?.trim();
const overrideCommitId = process.env.GPUSTACK_UI_COMMIT_ID?.trim();
return {
version: overrideVersion || versionTag || '',
commitId: overrideCommitId || latestCommit.slice(0, 7)
};
};
+1 -1
View File
@@ -17,7 +17,7 @@
"@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.19",
"@gpustack/core-ui": "^1.0.22",
"@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6",
+5 -5
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1
version: 7.1.2
'@gpustack/core-ui':
specifier: ^1.0.19
version: 1.0.19(czdvzceysqw7iv6pct2ucnb23e)
specifier: ^1.0.22
version: 1.0.22(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf':
specifier: ^0.1.7
version: 0.1.18
@@ -1484,8 +1484,8 @@ packages:
resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz}
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
'@gpustack/core-ui@1.0.19':
resolution: {integrity: sha512-EI2inYoTDdWYdxXXm0BM2DQ7chysnbIXICivBMeUxxhc8IkEcwaQfNqo/KUtA/bkqjDieRxFC1JlsDIIhbcL3A==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.19.tgz}
'@gpustack/core-ui@1.0.22':
resolution: {integrity: sha512-uFLGalziwuojjMIeXMlCZn3tLRGA6PCa0JxLxa/+kKP+xAsy7Fcxgh4PerbTyk+P+hKnYv8cD+DPpkObDGqJgg==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.22.tgz}
peerDependencies:
'@ant-design/icons': '>=6.0.0'
'@ant-design/pro-components': 3.1.0-0
@@ -10808,7 +10808,7 @@ snapshots:
'@formatjs/intl-utils@2.3.0': {}
'@gpustack/core-ui@1.0.19(czdvzceysqw7iv6pct2ucnb23e)':
'@gpustack/core-ui@1.0.22(czdvzceysqw7iv6pct2ucnb23e)':
dependencies:
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+7 -1
View File
@@ -3,6 +3,7 @@ import { applyAccessExtensions } from './access.extensions';
export default (initialState: {
currentUser?: Global.UserInfo;
hasKubernetesCluster?: boolean;
hasResourceEvents?: boolean;
}) => {
const isPlatformAdmin = !!(
initialState &&
@@ -20,6 +21,10 @@ export default (initialState: {
// role-based default so a transient network blip can't lock anyone
// out of the menu.
const hasKubernetesCluster = initialState?.hasKubernetesCluster;
// Having run GPU/CPU instances or storage (any resource_events) also unlocks
// GPU Service / the full Usage page — a user who used it keeps seeing it even
// without a current cluster. MaaS-only users (no cluster, no events) don't.
const hasResourceEvents = !!initialState?.hasResourceEvents;
// Predicate roles, top-down by strictness:
// * `canSeeAdmin` — strictly platform admin (`users.is_admin`).
@@ -41,7 +46,8 @@ export default (initialState: {
return applyAccessExtensions({
canSeeAdmin: isPlatformAdmin,
canSeeOrgAdmin: isPlatformAdmin,
canSeeGpuService: isPlatformAdmin || hasKubernetesCluster !== false,
canSeeGpuService:
isPlatformAdmin || hasKubernetesCluster !== false || hasResourceEvents,
canManageCurrentOrg: false,
canSeeUser,
canDelete: true,
+87 -7
View File
@@ -1,10 +1,12 @@
import { userSettingsHelperAtom } from '@/atoms/settings';
import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user';
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
import { setAtomStorage } from '@/atoms/utils';
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
import { COLOR_PRIMARY } from '@/config/theme/constants';
import { queryClusterList } from '@/pages/cluster-management/apis';
import { ProviderValueMap } from '@/pages/cluster-management/config';
import { queryResourceEvents } from '@/pages/usage/apis/resource';
import { getGPUStackPlugin } from '@/plugins';
import { enterprisePluginReady } from '@/plugins/enterprise-ready';
import { GPUStackPluginManager } from '@/plugins/manager';
import { requestConfig } from '@/request-config';
@@ -15,6 +17,7 @@ import {
} from '@/services/profile/apis';
import { fetchSystemConfig } from '@/services/system/query-system-config';
import { isOnline } from '@/utils';
import { installTenantFetch } from '@/utils/install-fetch';
import {
IS_FIRST_LOGIN,
readState,
@@ -24,6 +27,8 @@ import '@gpustack/core-ui/style.css';
import { RequestConfig, history, request as umiRequest } from '@umijs/max';
import { message } from 'antd';
installTenantFetch();
// only for the first login and access from http://localhost
const checkDefaultPage = async (userInfo: any) => {
@@ -47,7 +52,12 @@ const checkDefaultPage = async (userInfo: any) => {
const HAS_K8S_CLUSTER_KEY = 'hasKubernetesCluster';
const probeHasKubernetesCluster = async (): Promise<boolean | undefined> => {
try {
const res = await queryClusterList({ page: -1 });
const res = await queryClusterList(
{ page: -1 },
{
skipErrorHandler: true
}
);
const value = (res?.items ?? []).some(
(c) => c?.provider === ProviderValueMap.Kubernetes
);
@@ -69,12 +79,49 @@ const probeHasKubernetesCluster = async (): Promise<boolean | undefined> => {
}
};
// Probes whether the caller has ANY resource-usage events (GPU/CPU instance or
// storage lifecycle). Used alongside the cluster probe so a user who has run
// GPU instances still sees GPU Service / the full Usage page even if they
// currently have no Kubernetes cluster. Mirrored into sessionStorage for the
// access extensions; any failure → undefined ("unknown — don't restrict").
const HAS_RESOURCE_EVENTS_KEY = 'hasResourceEvents';
const probeHasResourceEvents = async (): Promise<boolean | undefined> => {
try {
// No date range = "ever"; scope is clamped to the caller server-side.
const res = await queryResourceEvents(
{ perPage: 1 },
{
skipErrorHandler: true
}
);
const value = (res?.pagination?.total ?? 0) > 0;
try {
window.sessionStorage.setItem(
HAS_RESOURCE_EVENTS_KEY,
JSON.stringify(value)
);
} catch {
// sessionStorage may be unavailable; predicate treats missing as unknown.
}
return value;
} catch (error) {
console.error('probeHasResourceEvents error', error);
try {
window.sessionStorage.removeItem(HAS_RESOURCE_EVENTS_KEY);
} catch {
// ignore
}
return undefined;
}
};
// runtime configuration
export async function getInitialState(): Promise<{
fetchUserInfo: () => Promise<Global.UserInfo>;
currentUser?: Global.UserInfo;
pluginData?: Record<string, any>;
hasKubernetesCluster?: boolean;
hasResourceEvents?: boolean;
}> {
const { location } = history;
@@ -119,6 +166,36 @@ export async function getInitialState(): Promise<{
getUpdateCheck();
fetchSystemConfig();
}
// Only commit a substantive user object. A truthy-but-empty
// `data` (e.g. server responded 200 with an empty body) would
// otherwise look like "logged in" to every `currentUser`
// reader and the access seam — break out instead and let the
// caller treat the request as failed.
if (data && typeof data === 'object' && Object.keys(data).length > 0) {
// Commit the identity to atom storage (and so to localStorage)
// before returning. The access function — memoized on
// `initialState` and run once per commit — reads identity from
// localStorage; without this preemptive write the predicate
// sees the prior session's identity on its first evaluation
// after login, and stays stale until the next identity change
// (which usually doesn't come without a manual refresh).
try {
setAtomStorage(userAtom, data);
} catch (err) {
console.error('userAtom commit error:', err);
}
// Fire `onUserFetched` so plugins maintaining identity-scoped
// caches can seed them under the new identity before any
// caller commits this user to `initialState`. Errors here are
// swallowed and logged — fetchUserInfo must still return.
try {
await getGPUStackPlugin()?.login?.onUserFetched?.(data, {
request: umiRequest
});
} catch (err) {
console.error('onUserFetched plugin hook error:', err);
}
}
return data;
} catch (error: any) {
const data = error?.response?.data;
@@ -158,16 +235,19 @@ export async function getInitialState(): Promise<{
getAppVersionInfo();
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
const [userInfo, hasKubernetesCluster] = await Promise.all([
fetchUserInfo(),
probeHasKubernetesCluster()
]);
const [userInfo, hasKubernetesCluster, hasResourceEvents] =
await Promise.all([
fetchUserInfo(),
probeHasKubernetesCluster(),
probeHasResourceEvents()
]);
checkDefaultPage(userInfo);
return {
fetchUserInfo,
currentUser: userInfo,
pluginData,
hasKubernetesCluster
hasKubernetesCluster,
hasResourceEvents
};
}
return {
+1
View File
@@ -3,6 +3,7 @@
.ant-layout-sider-children {
border-inline: none;
border-radius: 0;
padding-inline-end: 0;
padding-block-end: 8px;
}
+38 -19
View File
@@ -90,20 +90,33 @@ const getStoredCurrentOrgId = (): number | null => {
// cluster-owner fallback.
const ORG_CACHE_KEYS = ['organizationList', 'allOrganizations'] as const;
const lookupOrgNamespace = (id: number | null): string | null => {
export interface CachedOrg {
id: number;
name?: string;
// The platform Org (single global tenant). Its models are NOT
// namespaced in ``/v1/models`` — they appear under their bare name.
is_platform?: boolean;
}
// Resolve the cached Org record for an owner/principal id by scanning both
// org caches. ``organizationList`` (the caller's member orgs) is checked
// alongside the admin-only ``allOrganizations`` so member sessions resolve
// too. Id types vary between localStorage payloads (some writers stringify,
// others persist as a JSON number), so compare as strings — strict equality
// would silently miss those cases.
export const getOrgById = (
id: number | string | null | undefined
): CachedOrg | null => {
if (id == null) return null;
// Normalise both sides to strings — the stored id type varies between
// localStorage payloads (some writers stringify, others persist as a
// JSON number); strict equality would silently miss those cases.
const target = String(id);
for (const key of ORG_CACHE_KEYS) {
try {
const raw = localStorage.getItem(key);
if (!raw) continue;
const list = JSON.parse(raw) as Array<{ id: number; name?: string }>;
const list = JSON.parse(raw) as CachedOrg[];
if (!Array.isArray(list)) continue;
const match = list.find((item) => String(item?.id) === target);
if (match?.name) return `gpustack-${match.name}`;
if (match) return match;
} catch {
// ignore malformed cache; continue checking other keys
}
@@ -111,17 +124,23 @@ const lookupOrgNamespace = (id: number | null): string | null => {
return null;
};
export const getAllOrganizations = (): Array<{ id: number; name?: string }> => {
const allOrganizations = localStorage.getItem('allOrganizations');
if (!allOrganizations) return [];
try {
const list = JSON.parse(allOrganizations) as Array<{
id: number;
name?: string;
}>;
if (!Array.isArray(list)) return [];
return list;
} catch {
return [];
}
// Bare Org *name* (e.g. ``org1``) for an owner/principal id, or null.
export const getOrgNameById = (
id: number | string | null | undefined
): string | null => {
return getOrgById(id)?.name ?? null;
};
const lookupOrgNamespace = (id: number | null): string | null => {
const name = getOrgNameById(id);
return name ? `gpustack-${name}` : null;
};
// The Org the caller is currently acting under, or null in the admin-"All"
// context. The org-switcher reloads the page on switch, so the list pages
// always show this org's resources — which is how ``/v1/models`` namespaces
// their model ids (``{org}/{name}``). Callers reconstructing that id use this
// as the fallback owner when a row carries no explicit ``owner_principal_id``.
export const getCurrentOrg = (): CachedOrg | null => {
return getOrgById(getStoredCurrentOrgId());
};
+17 -37
View File
@@ -171,8 +171,6 @@ body {
}
.ant-table .ant-table-container table {
// border-spacing: 0 20px;
.ant-table-thead th.ant-table-column-sort {
background-color: transparent;
@@ -241,8 +239,8 @@ body {
height: 100%;
.ant-pro-sider-footer {
padding-block: 2px 0;
padding-left: 6px;
padding-block: 4px 0;
padding-left: 8px;
}
.ant-pro-sider-actions-list-item {
@@ -259,46 +257,27 @@ body {
border-block-end: none;
}
.ant-pro-sider-logo-collapsed {
padding-left: 12px;
cursor: e-resize;
.collapse-wrap {
display: none;
position: absolute;
top: 12px;
&::after {
content: '';
position: absolute;
height: 48px;
width: 64px;
top: -16px;
left: -16px;
}
}
&:hover {
.collapse-wrap {
display: block;
.ant-pro-sider-logo {
.collapse-btn {
color: var(--ant-color-text-tertiary);
:hover {
color: var(--ant-color-text);
}
}
}
.ant-pro-sider .ant-layout-sider-children {
// border-right: 1px solid var(--ant-color-split);
.ant-pro-sider-logo-collapsed {
padding-left: 6px;
}
}
.ant-table-content table {
.ant-table-tbody {
.ant-table-row {
border-radius: var(--table-td-radius);
.ant-table .ant-table-tbody {
.ant-table-row {
border-radius: var(--table-td-radius);
> td {
background-color: unset;
border-bottom: 1px solid var(--ant-color-split);
}
> td {
background-color: unset !important;
border-bottom: 1px solid var(--ant-color-split);
}
}
}
@@ -347,7 +326,6 @@ body {
.ant-pro-layout-container {
overflow-x: auto;
min-height: 100vh;
// background-color: var(--ant-color-bg-container);
}
.ant-pro-sider {
@@ -812,6 +790,8 @@ body {
}
.ant-pro-sider-logo + div {
display: flex;
overflow: hidden;
&::-webkit-scrollbar {
width: 0;
}
+4 -1
View File
@@ -37,6 +37,7 @@ export default function useTableFetch<T>(
key?: (typeof PaginationKey)[keyof typeof PaginationKey];
fetchAPI: (params: any, options?: any) => Promise<Global.PageResponse<T>>;
deleteAPI?: (id: number, params?: any) => Promise<any>;
afterDelete?: (id?: number | number[]) => void;
contentForDelete?: string;
defaultData?: any[];
events?: EventsType[];
@@ -48,6 +49,7 @@ export default function useTableFetch<T>(
const {
fetchAPI,
deleteAPI,
afterDelete,
contentForDelete,
API,
polling = false,
@@ -363,7 +365,7 @@ export default function useTableFetch<T>(
// remove the deleted id from selected ids in row selection
rowSelection.removeSelectedKeys([row.id]);
afterDelete?.(row.id);
// ======== to avoid fetch data twice, because of debounceFetchData has been run =======
if (!updateManually) {
fetchData();
@@ -390,6 +392,7 @@ export default function useTableFetch<T>(
successIds.push(id);
}
);
afterDelete?.(successIds);
rowSelection.removeSelectedKeys(successIds);
fetchData();
return res;
+10 -18
View File
@@ -14,7 +14,6 @@ type EventsType = 'CREATE' | 'UPDATE' | 'DELETE' | 'INSERT';
export function useUpdateChunkedList(options: {
events?: EventsType[];
dataList?: any[];
triggerAt?: React.MutableRefObject<number>;
limit?: number;
onCreate?: (args: any) => void;
onUpdate?: (args: any) => void;
@@ -24,9 +23,9 @@ export function useUpdateChunkedList(options: {
filterFun?: (args: any) => boolean;
mapFun?: (args: any) => any;
computedID?: (d: object) => string;
isNewItem?: (item: any) => boolean;
}) {
const { events = ['CREATE', 'DELETE', 'UPDATE', 'INSERT'], triggerAt } =
options;
const { events = ['CREATE', 'DELETE', 'UPDATE', 'INSERT'] } = options;
const deletedIdsRef = useRef<Set<number | string>>(new Set());
const cacheDataListRef = useRef<any[]>(options.dataList || []);
const timerRef = useRef<any>(null);
@@ -71,17 +70,14 @@ export function useUpdateChunkedList(options: {
(sItem: any) => sItem.id === item.id
);
const updateItem = { ...item };
if (updateIndex === -1 && !triggerAt?.current) {
if (updateIndex === -1) {
acc.push(updateItem);
} else if (!triggerAt?.current) {
} else {
cacheDataListRef.current[updateIndex] = updateItem;
}
// only push items created after the watch started
// TODO only push items created after triggerAt
if (
triggerAt?.current &&
Date.parse(item.created_at) >= triggerAt.current
) {
if (options.isNewItem?.(item)) {
latestCreateList.push(updateItem);
}
@@ -103,10 +99,8 @@ export function useUpdateChunkedList(options: {
cacheDataListRef.current = cacheDataListRef.current?.filter(
(item: any) => {
// collect deleted items
if (triggerAt?.current) {
if (ids?.includes(item.id)) {
deletedList.push(item);
}
if (ids?.includes(item.id) && !options.isNewItem?.(item)) {
deletedList.push(item);
}
return !ids?.includes(item.id);
}
@@ -133,10 +127,8 @@ export function useUpdateChunkedList(options: {
updateItem,
...cacheDataListRef.current.slice(0, limit - 1)
];
if (options.onUpdate && triggerAt?.current) {
if (Date.parse(item.created_at) >= triggerAt.current) {
options.onUpdate?.([updateItem]);
}
if (options.onUpdate && options.isNewItem?.(item)) {
options.onUpdate?.([updateItem]);
}
}
});
+3 -3
View File
@@ -210,14 +210,14 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
key: 'settings',
label: (
<span className="flex flex-center">
<IconFont type="icon-settings-02" />
<IconFont type="icon-preferences" />
<span className="m-l-8" style={{ marginLeft: 8 }}>
{intl?.formatMessage?.({ id: 'common.button.settings' })}
{intl?.formatMessage?.({ id: 'common.preferences' })}
</span>
</span>
),
onClick: () => {
history.push('/profile');
history.push('/preferences');
}
}
]
+21 -37
View File
@@ -21,11 +21,7 @@ import {
import { useAccessMarkedRoutes } from '@@/plugin-access';
import { useModel } from '@@/plugin-model';
import { ProLayout } from '@ant-design/pro-components';
import {
CoreUIProvider,
IconFont,
useOverlayScroller
} from '@gpustack/core-ui';
import { CoreUIProvider, IconFont } from '@gpustack/core-ui';
import {
Access,
Outlet,
@@ -72,9 +68,9 @@ const NO_CONTAINER_PAGES = [
const CHECK_RESOURCE_PATH = [
'/resources/workers',
'/cluster-management/clusters/list',
'/cluster-management/credentials',
'/cluster-management/clusters/create'
'/resources/clusters/list',
'/resources/credentials',
'/resources/clusters/create'
];
type NewRoute = IRoute & {
@@ -135,9 +131,6 @@ const mapRoutes = (routes: IRoute[], role: string) => {
};
export default (props: any) => {
const { initialize: initialize } = useOverlayScroller({
defer: false
});
const [, contextHolder] = Modal.useModal();
const { themeData, setUserSettings, userSettings } = useUserSettings();
const [userInfo] = useAtom(userAtom);
@@ -241,30 +234,7 @@ export default (props: any) => {
}, [userSettings.collapsed]);
const renderMenuHeader = (logo: React.ReactNode, title: React.ReactNode) => {
return (
<>
{logo}
<div className="collapse-wrap" onClick={handleToggleCollapse}>
<Button
style={{
marginRight: collapsed ? 0 : -14,
border: 'none',
cursor: 'w-resize'
}}
size="small"
type={collapsed ? 'default' : 'text'}
>
<IconFont
type={collapsed ? 'icon-expand-left' : 'icon-expand-right'}
className="font-size-18 text-secondary"
style={{
display: 'block'
}}
/>
</Button>
</div>
</>
);
return <>{logo}</>;
};
const menuContentRender = (menuProps: any, defaultDom: React.ReactNode) => {
@@ -357,7 +327,7 @@ export default (props: any) => {
config={{
apiBaseUrl: GPUSTACK_API_BASE_URL,
theme: userSettings.theme,
iconUrl: '',
iconUrl: '//at.alicdn.com/t/c/font_4613488_rwchdketjg8.js',
isDarkTheme: userSettings.isDarkTheme,
defaultColorPrimary: COLOR_PRIMARY
}}
@@ -408,9 +378,23 @@ export default (props: any) => {
openKeys={false}
disableMobile={true}
siderWidth={220}
menuFooterRender={() => (
<Button
style={{
border: 'none'
}}
size="small"
type={'text'}
onClick={handleToggleCollapse}
>
<IconFont
type={collapsed ? 'icon-expand-left' : 'icon-expand-right'}
className="font-size-18"
/>
</Button>
)}
onCollapse={onCollapse}
onMenuHeaderClick={onMenuHeaderClick}
menuHeaderRender={renderMenuHeader}
collapsed={userSettings.collapsed}
onPageChange={onPageChange}
formatMessage={formatMessage}
+1 -1
View File
@@ -256,7 +256,7 @@ export const getRightRenderContent = (opts: {
</span>
),
onClick: () => {
history.push('/profile');
history.push('/preferences');
}
},
{
+54 -38
View File
@@ -1,5 +1,5 @@
import { CaretDownOutlined } from '@ant-design/icons';
import { IconFont } from '@gpustack/core-ui';
import { IconFont, OverlayScroller } from '@gpustack/core-ui';
import { Link, useLocation } from '@umijs/max';
import { Tooltip } from 'antd';
import { createStyles, type FullToken } from 'antd-style';
@@ -29,12 +29,17 @@ const useStyles = createStyles(
return {
siderMenu: css`
width: 100%;
&.sider-menu-collapsed {
.menu-item {
justify-content: center;
padding: 0;
}
}
.os-scrollbar-vertical .os-scrollbar-handle {
min-width: 4px;
max-width: 4px;
}
`,
groupTitle: css`
display: flex;
@@ -222,44 +227,55 @@ const SiderMenu: React.FC<SiderMenuProps> = (props) => {
'sider-menu-collapsed': collapsed
})}
>
{menuData.map((item: MenuItem, index: number) => (
<div key={item.key}>
{item.children && item.children.length > 0 ? (
<>
<div
className={cx(styles.groupTitle, {
'menu-item-group-title-collapsed': collapsed
})}
onClick={(e) => handleToggleGroup(e, item)}
>
{!collapsed ? (
<span className="group-title-text">
<span>{item.name}</span>
<CaretDownOutlined
rotate={collapseKeys.has(item.key) ? -90 : 0}
></CaretDownOutlined>
</span>
) : (
<span className={styles.line}></span>
)}
</div>
<div
className={cx(styles.menuItemGroup, {
'menu-item-group-collapsed': collapsed,
'menu-item-group-hidden':
!collapsed && collapseKeys.has(item.key)
})}
>
{item.children?.map((child: MenuItem) =>
menuItemRender(child, child.key)
)}
</div>
</>
) : (
menuItemRender(item, item.key)
)}
<OverlayScroller
styles={{
wrapper: {
paddingInline: 0,
maxHeight: '100%'
}
}}
>
<div style={{ paddingRight: 8 }}>
{menuData.map((item: MenuItem, index: number) => (
<div key={item.key}>
{item.children && item.children.length > 0 ? (
<>
<div
className={cx(styles.groupTitle, {
'menu-item-group-title-collapsed': collapsed
})}
onClick={(e) => handleToggleGroup(e, item)}
>
{!collapsed ? (
<span className="group-title-text">
<span>{item.name}</span>
<CaretDownOutlined
rotate={collapseKeys.has(item.key) ? -90 : 0}
></CaretDownOutlined>
</span>
) : (
<span className={styles.line}></span>
)}
</div>
<div
className={cx(styles.menuItemGroup, {
'menu-item-group-collapsed': collapsed,
'menu-item-group-hidden':
!collapsed && collapseKeys.has(item.key)
})}
>
{item.children?.map((child: MenuItem) =>
menuItemRender(child, child.key)
)}
</div>
</>
) : (
menuItemRender(item, item.key)
)}
</div>
))}
</div>
))}
</OverlayScroller>
</div>
);
};
+3 -1
View File
@@ -23,5 +23,7 @@ export default {
'apikeys.accessScope.inference': 'Inference APIs',
'apikeys.access.permissions': 'Access Permissions',
'apikeys.type.auto': 'Auto-generated',
'apikeys.type.custom': 'Custom'
'apikeys.type.custom': 'Custom',
'apikeys.button.ipConfig': 'IP Access Control',
'quotaLimits.button.title': 'Quota Limit'
};
+14 -5
View File
@@ -39,7 +39,7 @@ export default {
'clusters.workerpool.batchSize.desc':
'Number of workers created simultaneously in the Worker pool',
'clusters.create.addworker.tips':
'Please make sure the <a href={link} target="_blank">prerequisites</a> for {label} are met before executing the following command.',
'Please make sure the <a href={link} target="_blank">prerequisites</a> are met before executing the following command.',
'clusters.create.addCommand.tips':
'On the Worker that needs to be added, run the following command to join it to the cluster.',
'clusters.create.addCommand.k8s.tips':
@@ -110,6 +110,10 @@ export default {
'{count} new workers have been added to the cluster.',
'clusters.create.serverUrl': 'GPUStack Server URL',
'clusters.create.workerConfig': 'Worker Configuration',
'clusters.edit.k8sOptions.changed.tip':
'You have changed the Kubernetes options. Re-run the registration command on the target cluster for the changes to take effect.',
'clusters.edit.workerConfig.tip':
'Changes to the worker configuration take effect only after restarting the affected workers.',
'clusters.addworker.containerName': 'Worker Container Name',
'clusters.addworker.containerName.tips':
'Specify a name for the worker container.',
@@ -165,7 +169,7 @@ export default {
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.k8sOptions.title': 'Kubernetes Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry',
@@ -180,9 +184,14 @@ export default {
'clusters.namespace.title': 'Namespace',
'clusters.namespace.tip':
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.',
'clusters.gpuInstances.title': 'GPU Instances',
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.clusterType.title': 'Cluster Type',
'clusters.modelService.title': 'Model Service',
'clusters.modelService.tip':
'For LLM inference and API serving — e.g. exposing model APIs and token-based services.',
'clusters.gpuInstances.title': 'GPU Service',
'clusters.gpuInstances.tip':
'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.',
'clusters.gpuInstances.staticAddress': 'GPU Service Static Access Address',
'clusters.gpuInstances.staticAddress.tip':
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
};
+6 -1
View File
@@ -46,6 +46,7 @@ export default {
'common.button.enabled': 'Enabled',
'common.button.disabled': 'Disabled',
'common.button.upgrade': 'Upgrade',
'common.enterprise.feature': 'Available in GPUStack Enterprise',
'common.input.holder': 'Please enter',
'common.validate.value': '{name} value is required',
'common.button.edit': 'Edit',
@@ -55,6 +56,7 @@ export default {
'common.button.viewevent': 'View Events',
'common.button.recreate': 'Recreate',
'common.table.operation': 'Operations',
'common.table.creator': 'Creator',
'common.table.createTime': 'Created',
'common.table.updateTime': 'Updated',
'common.table.description': 'Description',
@@ -65,6 +67,7 @@ export default {
'common.search.name.placeholder': 'filter by name',
'common.search.id.placeholder': 'filter by ID',
'common.filter.byId': 'filter by ID',
'common.filter.byCreator': 'Filter by creator',
'common.table.type': 'Type',
'common.table.default': 'Default Value',
'common.copy.success': 'Copied success!',
@@ -285,5 +288,7 @@ export default {
'common.image.limit.height': 'Image height must be {height}.',
'common.remaining': 'Remaining {count}',
'common.max': 'Max {count}',
'common.validate.group': 'Please complete the {group} configuration'
'common.max.count': '{label} Count',
'common.validate.group': 'Please complete the {group} configuration',
'common.preferences': 'Preferences'
};
+17 -4
View File
@@ -13,7 +13,11 @@ export default {
'gpuservice.template.command.placeholder':
'Separate arguments with spaces; wrap arguments containing spaces in quotes, e.g.: /bin/bash -c "echo hello world"',
'gpuservice.template.mountPath': 'Mount Path',
'gpuservice.template.mountPath.tips':
'The default mount path for the storage volume when creating an instance from this template. Useful for persisting data that needs to be retained while the instance is running.',
'gpuservice.template.containerDisk': 'Container Disk (GB)',
'gpuservice.template.containerDisk.tips':
'The size of the container system disk.',
'gpuservice.template.memory': 'Memory (GB)',
'gpuservice.instance.containerDisk.remaining':
'Container Disk (Max {count} GB)',
@@ -97,12 +101,17 @@ export default {
'gpuservice.instance.templates': 'Instance Templates',
'gpuservice.instance.section.storage': 'Storage',
'gpuservice.instance.type.required': 'Please select an instance type',
'gpuservice.instance.type.noAvailable': 'No instance type available',
'gpuservice.instance.gpuCount': 'GPU Count',
'gpuservice.instance.gpuCount.required': 'Please enter the GPU count',
'gpuservice.instance.gpuCount.max':
'Please select at most {count} GPU card(s)',
'gpuservice.instance.gpuCount.min':
'Please select at least {count} GPU card(s)',
'gpuservice.instance.cpuCount.max':
'Please select at most {count} CPU core(s)',
'gpuservice.instance.cpuCount.min':
'Please select at least {count} CPU core(s)',
'gpuservice.instance.gpuCount.noAvailable':
'No available GPU resources, please choose another instance type.',
'gpuservice.instance.gpuCount.zero':
@@ -112,6 +121,10 @@ export default {
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.disk': 'Disk',
'gpuservice.table.count': 'Count',
'gpuservice.instance.disk.system': 'System Disk',
'gpuservice.instance.disk.ephemeral': 'Ephemeral Storage',
'gpuservice.instance.disk.persistent': 'Persistent Storage',
'gpuservice.instance.search.type.placeholder': 'Search by name',
'gpuservice.instance.search.template.placeholder':
'Search by template name, image or mount path',
@@ -147,9 +160,8 @@ export default {
'gpuservice.storage.temporary.tips':
'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips':
'Data persists across restarts and is deleted only when the instance is terminated. Cannot be shared with other instances.',
'gpuservice.storage.persistentVolume.required':
'Please select a persistent volume',
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
'gpuservice.storage.persistentVolume.required': 'Please select a storage',
'gpuservice.storage.persistentVolume.capacity': 'Capacity (GB)',
'gpuservice.storage.persistentVolume.capacity.required':
'Please enter capacity',
@@ -159,5 +171,6 @@ export default {
'gpuservice.storage.tempCapacity.required':
'Please enter the temporary storage capacity',
'gpuservice.form.rule.name':
"Lowercase letters, numbers, and '-'. Start and end with a letter or number, no consecutive '-', max 63 characters."
"Lowercase letters, numbers, and '-'. Start and end with a letter or number, no consecutive '-', max 63 characters.",
'gpuservice.form.storage.select': 'Select Storage'
};
+9 -6
View File
@@ -31,15 +31,18 @@ export default {
'menu.accessControl': 'Access Control',
'menu.accessControl.apikeys': 'API Keys',
'menu.accessControl.users': 'Users',
'menu.profile': 'Profile',
'menu.accessControl.organizations': 'Organizations',
'menu.profile': 'Preferences',
'menu.login': 'Login',
'menu.usage': 'Usage',
'menu.usage.usage': 'Usage',
'menu.billingAndUsage': 'Usage & Billing',
'menu.billingAndUsage.usage': 'Usage',
'menu.404': '404',
'menu.clusterManagement': 'Cluster Management',
'menu.clusterManagement.clusters': 'Clusters',
'menu.clusterManagement.credentials': 'Cloud Credentials',
'menu.clusterManagement.clusterDetail': 'Cluster Detail',
'menu.clusterManagement.clusterCreate': 'Create Cluster',
'menu.resources.clusters': 'Clusters',
'menu.resources.credentials': 'Cloud Credentials',
'menu.resources.clusterDetail': 'Cluster Detail',
'menu.resources.clusterCreate': 'Create Cluster',
'menu.models.backendsList': 'Inference Backends',
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
+1 -1
View File
@@ -291,7 +291,7 @@ export default {
'models.instance.startHistory': 'Run History',
'models.instance.startHistory.tips':
'Shows logs from the run before the last error-triggered restart.',
'models.form.lora.label': 'LoRA Adapter',
'models.form.lora.label': 'LoRA Adapters',
'models.form.lora.add': 'Add LoRA Adapter',
'models.form.lora.select': 'Select LoRA',
'models.form.lora.name': 'LoRA name',
+15
View File
@@ -0,0 +1,15 @@
export default {
'organizations.upsell.title': 'Organizations are an Enterprise feature',
'organizations.upsell.subtitle':
'Multi-tenancy lets you isolate users, resources, and quotas across teams. Upgrade to GPUStack Enterprise to manage organizations.',
'organizations.upsell.featuresTitle': 'What you get in Enterprise',
'organizations.upsell.feature.orgs':
'Create organizations to group users and isolate workloads',
'organizations.upsell.feature.members':
'Manage members and roles per organization',
'organizations.upsell.feature.quotas':
'Set resource and token quotas per organization',
'organizations.upsell.feature.isolation':
'Scope API keys, model deployments, and resources per organization',
'organizations.upsell.cta': 'Learn about Enterprise'
};
+71 -1
View File
@@ -17,9 +17,15 @@ export default {
'usage.table.user.apiKeysUsed': 'API Keys Used',
'usage.table.lastActive': 'Last Active',
'usage.filter.granularity': 'Granularity',
'usage.filter.granularity.hour': 'Hour',
'usage.filter.granularity.day': 'Day',
'usage.filter.granularity.week': 'Week',
'usage.filter.granularity.month': 'Month',
'usage.tabs.summary': 'Summary',
'usage.tabs.tokens': 'Tokens',
'usage.tabs.gpuInstances': 'GPU Instances',
'usage.tabs.storage': 'Storage',
'usage.tabs.resourceEvents': 'Resource Events',
'usage.tabs.models': 'Models',
'usage.tabs.apikeys': 'API Keys',
'usage.tabs.users': 'User',
@@ -32,5 +38,69 @@ export default {
'usage.chart.cached': 'Cached',
'usage.chart.uncached': 'Uncached',
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
'usage.table.inputTokensCached': 'Input Tokens Cached'
'usage.table.inputTokensCached': 'Input Tokens Cached',
// --- Resource usage: shared metrics & units ---
'usage.metric.tokens': 'Tokens',
'usage.metric.input': 'Input',
'usage.metric.output': 'Output',
'usage.metric.gpuHours': 'GPU Hours',
'usage.metric.instanceHours': 'Instance Hours',
'usage.metric.gbDays': 'GB-Days',
'usage.metric.gbHours': 'GB-Hours',
'usage.metric.activeUsers': 'Active Users',
'usage.metric.activeInstances': 'Active Instances',
'usage.metric.activeStorage': 'Active Storage',
'usage.metric.activeVolumes': 'Active Volumes',
'usage.metric.storageTypes': 'Storage Types',
'usage.metric.gpuHours.tip':
'Instance running time weighted by GPU count: an instance with N GPUs running for H hours counts as N × H GPU-hours. Equal to Instance Hours when every instance uses a single GPU.',
'usage.metric.instanceHours.tip':
'Total running time summed across all instances, regardless of how many GPUs each uses. One instance running for 2 hours = 2 instance-hours.',
'usage.metric.gbDays.tip':
'Storage capacity integrated over time, in GB × days: 10 GB kept for 5 days = 50 GB-days. (= GB-Hours ÷ 24)',
'usage.metric.gbHours.tip':
'Storage capacity integrated over time, in GB × hours: 10 GB kept for 5 hours = 50 GB-hours.',
// --- Resource usage: common table / labels ---
'usage.common.noData': 'No data',
'usage.common.unknown': 'unknown',
'usage.table.date': 'Date',
'usage.table.name': 'Name',
'usage.table.user': 'User',
'usage.table.users': 'Users',
'usage.table.type': 'Type',
'usage.table.instance': 'Instance',
'usage.table.instanceType': 'Instance Type',
'usage.table.instanceTypes': 'Instance Types',
'usage.table.instances': 'Instances',
'usage.table.capacity': 'Capacity',
'usage.export.tableNamed': 'Export Table Data — {name}',
// --- Summary tab ---
'usage.summary.compute': 'Compute',
'usage.summary.tokensOverTime': 'Tokens over time',
'usage.summary.gpuHoursOverTime': 'GPU Hours over time',
'usage.summary.gbDaysOverTime': 'GB-Days over time',
// --- GPU Instances / Storage filters ---
'usage.filter.instance': 'Filter by instance',
'usage.filter.storage': 'Filter by storage',
// --- Resource events ---
'usage.events.resourceType': 'Resource type',
'usage.events.eventType': 'Event type',
'usage.events.col.time': 'Time',
'usage.events.col.resource': 'Resource',
'usage.events.col.event': 'Event',
'usage.events.col.message': 'Message',
'usage.events.resource.gpuInstance': 'GPU Instance',
'usage.events.resource.cpuInstance': 'CPU Instance',
'usage.events.type.created': 'Created',
'usage.events.type.deleted': 'Deleted',
'usage.events.type.started': 'Started',
'usage.events.type.stopped': 'Stopped',
'usage.events.type.updated': 'Updated',
'usage.events.type.attached': 'Attached',
'usage.events.type.detached': 'Detached'
};
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
'vendor.hygon': 'Hygon',
'vendor.moorthreads': 'Moore Threads',
'vendor.iluvatar': 'Iluvatar',
'vendor.metax': 'Metax',
'vendor.metax': 'MetaX',
'vendor.cambricon': 'Cambricon',
'vendor.thead': 'T-Head PPU'
};
+3 -1
View File
@@ -23,7 +23,9 @@ export default {
'apikeys.accessScope.inference': 'Inference APIs',
'apikeys.access.permissions': 'Access Permissions',
'apikeys.type.auto': 'Auto-generated',
'apikeys.type.custom': 'Custom'
'apikeys.type.custom': 'Custom',
'apikeys.button.ipConfig': 'IP Access Control',
'quotaLimits.button.title': 'Quota Limit'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+14 -5
View File
@@ -39,7 +39,7 @@ export default {
'clusters.workerpool.batchSize.desc':
'Number of workers created simultaneously in the Worker pool',
'clusters.create.addworker.tips':
'Please make sure the <a href={link} target="_blank">prerequisites</a> for {label} are met before executing the following command.',
'Please make sure the <a href={link} target="_blank">prerequisites</a> are met before executing the following command.',
'clusters.create.addCommand.tips':
'On the Worker that needs to be added, run the following command to join it to the cluster.',
'clusters.create.addCommand.k8s.tips':
@@ -110,6 +110,10 @@ export default {
'{count} new workers have been added to the cluster.',
'clusters.create.serverUrl': 'GPUStack Server URL',
'clusters.create.workerConfig': 'Worker Configuration',
'clusters.edit.k8sOptions.changed.tip':
'Kubernetes オプションを変更しました。変更を有効にするには、対象クラスターで登録コマンドを再実行してください。',
'clusters.edit.workerConfig.tip':
'ワーカー設定の変更は、対象のワーカーを再起動した後に有効になります。',
'clusters.addworker.containerName': 'Worker Container Name',
'clusters.addworker.containerName.tips':
'Specify a name for the worker container.',
@@ -165,7 +169,7 @@ export default {
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.k8sOptions.title': 'Kubernetes Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry',
@@ -180,9 +184,14 @@ export default {
'clusters.namespace.title': 'Namespace',
'clusters.namespace.tip':
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.',
'clusters.gpuInstances.title': 'GPU Instances',
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.clusterType.title': 'Cluster Type',
'clusters.modelService.title': 'Model Service',
'clusters.modelService.tip':
'For LLM inference and API serving — e.g. exposing model APIs and token-based services.',
'clusters.gpuInstances.title': 'GPU Service',
'clusters.gpuInstances.tip':
'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.',
'clusters.gpuInstances.staticAddress': 'GPU Service Static Access Address',
'clusters.gpuInstances.staticAddress.tip':
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
};
+6 -1
View File
@@ -46,6 +46,7 @@ export default {
'common.button.enabled': '有効',
'common.button.disabled': '無効',
'common.button.upgrade': 'アップグレード',
'common.enterprise.feature': 'Available in GPUStack Enterprise',
'common.input.holder': '入力してください',
'common.validate.value': '{name} の値は必須です',
'common.button.edit': '編集',
@@ -55,6 +56,7 @@ export default {
'common.button.viewevent': 'イベントを表示',
'common.button.recreate': '再作成',
'common.table.operation': '操作',
'common.table.creator': '作成者',
'common.table.createTime': '作成日時',
'common.table.updateTime': '更新日時',
'common.table.description': '説明',
@@ -65,6 +67,7 @@ export default {
'common.search.name.placeholder': '名前でフィルタ',
'common.search.id.placeholder': 'IDでフィルタ',
'common.filter.byId': 'IDでフィルタ',
'common.filter.byCreator': '作成者でフィルタ',
'common.table.type': 'タイプ',
'common.table.default': 'デフォルト値',
'common.copy.success': 'コピー成功!',
@@ -285,7 +288,9 @@ export default {
'common.image.limit.height': 'Image height must be {height}.',
'common.remaining': '残り {count}',
'common.max': '最大 {count}',
'common.validate.group': 'Please complete the {group} configuration'
'common.max.count': '{label} 数',
'common.validate.group': 'Please complete the {group} configuration',
'common.preferences': 'Preferences'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+17 -2
View File
@@ -13,7 +13,11 @@ export default {
'gpuservice.template.command.placeholder':
'引数はスペースで区切り、スペースを含む引数は引用符で囲んでください。例:/bin/bash -c "echo hello world"',
'gpuservice.template.mountPath': 'マウントパス',
'gpuservice.template.mountPath.tips':
'このテンプレートからインスタンスを作成する際に、ストレージボリュームがデフォルトでマウントされるパスです。インスタンスの実行中に保持する必要があるデータの永続化に使用できます。',
'gpuservice.template.containerDisk': 'コンテナディスク (GB)',
'gpuservice.template.containerDisk.tips':
'コンテナシステムディスクのサイズです。',
'gpuservice.template.memory': 'メモリ (GB)',
'gpuservice.instance.containerDisk.remaining':
'コンテナディスク (最大 {count} GB)',
@@ -96,12 +100,18 @@ export default {
'gpuservice.instance.templates': 'インスタンステンプレート',
'gpuservice.instance.section.storage': 'ストレージボリューム',
'gpuservice.instance.type.required': 'インスタンスタイプを選択してください',
'gpuservice.instance.type.noAvailable':
'利用可能なインスタンスタイプがありません',
'gpuservice.instance.gpuCount': 'GPU 数',
'gpuservice.instance.gpuCount.required': 'GPU 数を入力してください',
'gpuservice.instance.gpuCount.max':
'最大 {count} 枚の GPU カードを選択してください',
'gpuservice.instance.gpuCount.min':
'少なくとも {count} 枚の GPU カードを選択してください',
'gpuservice.instance.cpuCount.max':
'最大 {count} 個の CPU コアを選択してください',
'gpuservice.instance.cpuCount.min':
'少なくとも {count} 個の CPU コアを選択してください',
'gpuservice.instance.gpuCount.noAvailable':
'利用可能な GPU リソースがありません。別のインスタンスタイプを選択してください。',
'gpuservice.instance.gpuCount.zero': 'CPU のみを使用し、環境準備用です。',
@@ -110,6 +120,10 @@ export default {
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.disk': 'ディスク',
'gpuservice.table.count': '数量',
'gpuservice.instance.disk.system': 'システムディスク',
'gpuservice.instance.disk.ephemeral': '一時ストレージ',
'gpuservice.instance.disk.persistent': '永続ストレージ',
'gpuservice.instance.search.type.placeholder': '名前で検索',
'gpuservice.instance.search.template.placeholder':
'テンプレート名、イメージまたはマウントパスで検索',
@@ -143,7 +157,7 @@ export default {
'gpuservice.storage.temporary': '一時',
'gpuservice.storage.persistentVolume': '永続',
'gpuservice.storage.persistentVolume.required':
'永続ボリュームを選択してください',
'ストレージを選択してください',
'gpuservice.storage.persistentVolume.capacity': '容量 (GB)',
'gpuservice.storage.persistentVolume.capacity.required':
'容量を入力してください',
@@ -157,5 +171,6 @@ export default {
'gpuservice.storage.temporary.tips':
'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips':
'Data persists across restarts and is deleted only when the instance is terminated. Cannot be shared with other instances.'
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
'gpuservice.form.storage.select': 'ストレージを選択'
};
+9 -6
View File
@@ -23,9 +23,12 @@ export default {
'menu.resources': 'リソース',
'menu.apikeys': 'APIキー',
'menu.users': 'ユーザー',
'menu.profile': 'プロフィール',
'menu.profile': 'Preferences',
'menu.login': 'ログイン',
'menu.usage': '使用状況',
'menu.usage.usage': '使用状況',
'menu.billingAndUsage': '使用状況と請求',
'menu.billingAndUsage.usage': '使用状況',
'menu.404': '404',
'menu.settings': 'Settings',
'menu.resources.workers': 'Workers',
@@ -34,12 +37,12 @@ export default {
'menu.accessControl': 'Access Control',
'menu.accessControl.apikeys': 'API Keys',
'menu.accessControl.users': 'Users',
'menu.clusterManagement': 'Cluster Management',
'menu.clusterManagement.clusters': 'Clusters',
'menu.clusterManagement.credentials': 'Cloud Credentials',
'menu.accessControl.organizations': 'Organizations',
'menu.resources.clusters': 'Clusters',
'menu.resources.credentials': 'Cloud Credentials',
'menu.models.userModels': 'My Models',
'menu.clusterManagement.clusterDetail': 'Cluster Detail',
'menu.clusterManagement.clusterCreate': 'Create Cluster',
'menu.resources.clusterDetail': 'Cluster Detail',
'menu.resources.clusterCreate': 'Create Cluster',
'menu.models.backendsList': 'Inference Backends',
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
+1 -1
View File
@@ -291,7 +291,7 @@ export default {
'models.instance.startHistory': 'Run History',
'models.instance.startHistory.tips':
'Shows logs from the run before the last error-triggered restart.',
'models.form.lora.label': 'LoRA Adapter',
'models.form.lora.label': 'LoRA Adapters',
'models.form.lora.add': 'Add LoRA Adapter',
'models.form.lora.select': 'Select LoRA',
'models.form.lora.name': 'LoRA name',
+15
View File
@@ -0,0 +1,15 @@
export default {
'organizations.upsell.title': 'Organizations are an Enterprise feature',
'organizations.upsell.subtitle':
'Multi-tenancy lets you isolate users, resources, and quotas across teams. Upgrade to GPUStack Enterprise to manage organizations.',
'organizations.upsell.featuresTitle': 'What you get in Enterprise',
'organizations.upsell.feature.orgs':
'Create organizations to group users and isolate workloads',
'organizations.upsell.feature.members':
'Manage members and roles per organization',
'organizations.upsell.feature.quotas':
'Set resource and token quotas per organization',
'organizations.upsell.feature.isolation':
'Scope API keys, model deployments, and resources per organization',
'organizations.upsell.cta': 'Learn about Enterprise'
};
+71 -1
View File
@@ -17,9 +17,15 @@ export default {
'usage.table.user.apiKeysUsed': 'API Keys Used',
'usage.table.lastActive': 'Last Active',
'usage.filter.granularity': 'Granularity',
'usage.filter.granularity.hour': '時間',
'usage.filter.granularity.day': 'Day',
'usage.filter.granularity.week': 'Week',
'usage.filter.granularity.month': 'Month',
'usage.tabs.summary': '概要',
'usage.tabs.tokens': 'トークン',
'usage.tabs.gpuInstances': 'GPU インスタンス',
'usage.tabs.storage': 'ストレージ',
'usage.tabs.resourceEvents': 'リソースイベント',
'usage.tabs.models': 'Models',
'usage.tabs.apikeys': 'API Keys',
'usage.tabs.users': 'User',
@@ -32,5 +38,69 @@ export default {
'usage.chart.cached': 'Cached',
'usage.chart.uncached': 'Uncached',
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
'usage.table.inputTokensCached': 'Input Tokens Cached'
'usage.table.inputTokensCached': 'Input Tokens Cached',
// --- Resource usage: shared metrics & units ---
'usage.metric.tokens': 'トークン数',
'usage.metric.input': '入力',
'usage.metric.output': '出力',
'usage.metric.gpuHours': 'GPU 時間',
'usage.metric.instanceHours': 'インスタンス時間',
'usage.metric.gbDays': 'GB·日',
'usage.metric.gbHours': 'GB·時間',
'usage.metric.activeUsers': 'アクティブユーザー',
'usage.metric.activeInstances': 'アクティブインスタンス',
'usage.metric.activeStorage': 'アクティブストレージ',
'usage.metric.activeVolumes': 'アクティブボリューム',
'usage.metric.storageTypes': 'ストレージタイプ',
'usage.metric.gpuHours.tip':
'インスタンスの稼働時間を GPU 数で重み付けした値:N 個の GPU を使用するインスタンスが H 時間稼働すると N × H GPU 時間としてカウントされます。すべてのインスタンスが単一の GPU を使用する場合はインスタンス時間と等しくなります。',
'usage.metric.instanceHours.tip':
'GPU の数に関係なく、すべてのインスタンスの稼働時間を合計した値。1 つのインスタンスが 2 時間稼働 = 2 インスタンス時間。',
'usage.metric.gbDays.tip':
'ストレージ容量を時間で積分した値(GB × 日):10 GB を 5 日間保持 = 50 GB·日。(= GB·時間 ÷ 24',
'usage.metric.gbHours.tip':
'ストレージ容量を時間で積分した値(GB × 時間):10 GB を 5 時間保持 = 50 GB·時間。',
// --- Resource usage: common table / labels ---
'usage.common.noData': 'データがありません',
'usage.common.unknown': '不明',
'usage.table.date': '日付',
'usage.table.name': '名前',
'usage.table.user': 'ユーザー',
'usage.table.users': 'ユーザー',
'usage.table.type': 'タイプ',
'usage.table.instance': 'インスタンス',
'usage.table.instanceType': 'インスタンスタイプ',
'usage.table.instanceTypes': 'インスタンスタイプ',
'usage.table.instances': 'インスタンス',
'usage.table.capacity': '容量',
'usage.export.tableNamed': 'テーブルデータをエクスポート — {name}',
// --- Summary tab ---
'usage.summary.compute': 'コンピュート',
'usage.summary.tokensOverTime': 'トークン数の推移',
'usage.summary.gpuHoursOverTime': 'GPU 時間の推移',
'usage.summary.gbDaysOverTime': 'GB·日の推移',
// --- GPU Instances / Storage filters ---
'usage.filter.instance': 'インスタンスで絞り込み',
'usage.filter.storage': 'ストレージで絞り込み',
// --- Resource events ---
'usage.events.resourceType': 'リソースタイプ',
'usage.events.eventType': 'イベントタイプ',
'usage.events.col.time': '時刻',
'usage.events.col.resource': 'リソース',
'usage.events.col.event': 'イベント',
'usage.events.col.message': 'メッセージ',
'usage.events.resource.gpuInstance': 'GPU インスタンス',
'usage.events.resource.cpuInstance': 'CPU インスタンス',
'usage.events.type.created': '作成済み',
'usage.events.type.deleted': '削除済み',
'usage.events.type.started': '開始',
'usage.events.type.stopped': '停止',
'usage.events.type.updated': '更新済み',
'usage.events.type.attached': 'アタッチ済み',
'usage.events.type.detached': 'デタッチ済み'
};
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
'vendor.hygon': 'Hygon',
'vendor.moorthreads': 'Moore Threads',
'vendor.iluvatar': 'Iluvatar',
'vendor.metax': 'Metax',
'vendor.metax': 'MetaX',
'vendor.cambricon': 'Cambricon',
'vendor.thead': 'T-Head PPU'
};
+3 -1
View File
@@ -23,7 +23,9 @@ export default {
'apikeys.accessScope.inference': 'Inference APIs',
'apikeys.access.permissions': 'Access Permissions',
'apikeys.type.auto': 'Auto-generated',
'apikeys.type.custom': 'Custom'
'apikeys.type.custom': 'Custom',
'apikeys.button.ipConfig': 'IP Access Control',
'quotaLimits.button.title': 'Quota Limit'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+14 -5
View File
@@ -39,7 +39,7 @@ export default {
'clusters.workerpool.batchSize.desc':
'Количество воркеров, создаваемых одновременно в пуле воркеров',
'clusters.create.addworker.tips':
'Пожалуйста, убедитесь, что выполнены <a href={link} target="_blank">предварительные условия</a> для {label} перед выполнением следующей команды.',
'Пожалуйста, убедитесь, что выполнены <a href={link} target="_blank">предварительные условия</a> перед выполнением следующей команды.',
'clusters.create.addCommand.tips':
'На воркере, который необходимо добавить, выполните следующую команду, чтобы присоединить его к кластеру.',
'clusters.create.addCommand.k8s.tips':
@@ -110,6 +110,10 @@ export default {
'{count} новых воркеров были добавлены в кластер.',
'clusters.create.serverUrl': 'URL сервера GPUStack',
'clusters.create.workerConfig': 'Конфигурация воркера',
'clusters.edit.k8sOptions.changed.tip':
'Вы изменили параметры Kubernetes. Чтобы изменения вступили в силу, повторно выполните команду регистрации в целевом кластере.',
'clusters.edit.workerConfig.tip':
'Изменения конфигурации воркера вступают в силу только после перезапуска соответствующих воркеров.',
'clusters.addworker.containerName': 'Имя контейнера воркера',
'clusters.addworker.containerName.tips':
'Укажите имя для контейнера воркера.',
@@ -166,7 +170,7 @@ export default {
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.k8sOptions.title': 'Kubernetes Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry',
@@ -181,9 +185,14 @@ export default {
'clusters.namespace.title': 'Namespace',
'clusters.namespace.tip':
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.',
'clusters.gpuInstances.title': 'GPU Instances',
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.clusterType.title': 'Cluster Type',
'clusters.modelService.title': 'Model Service',
'clusters.modelService.tip':
'For LLM inference and API serving — e.g. exposing model APIs and token-based services.',
'clusters.gpuInstances.title': 'GPU Service',
'clusters.gpuInstances.tip':
'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.',
'clusters.gpuInstances.staticAddress': 'GPU Service Static Access Address',
'clusters.gpuInstances.staticAddress.tip':
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
};
+7 -2
View File
@@ -46,6 +46,7 @@ export default {
'common.button.enabled': 'Активно',
'common.button.disabled': 'Отключено',
'common.button.upgrade': 'Обновить',
'common.enterprise.feature': 'Available in GPUStack Enterprise',
'common.input.holder': 'Введите значение',
'common.validate.value': 'Поле {name} обязательно',
'common.button.edit': 'Редактировать',
@@ -55,6 +56,7 @@ export default {
'common.button.viewevent': 'Просмотр событий',
'common.button.recreate': 'Пересоздать',
'common.table.operation': 'Действия',
'common.table.creator': 'Создатель',
'common.table.createTime': 'Создано',
'common.table.updateTime': 'Обновлено',
'common.table.description': 'Описание',
@@ -65,6 +67,7 @@ export default {
'common.search.name.placeholder': 'Фильтр по названию',
'common.search.id.placeholder': 'Фильтр по ID',
'common.filter.byId': 'Фильтр по ID',
'common.filter.byCreator': 'Фильтр по создателю',
'common.table.type': 'Тип',
'common.table.default': 'Значение по умолчанию',
'common.copy.success': 'Скопировано!',
@@ -260,7 +263,7 @@ export default {
'common.sso.noConfig':
'Единый вход не настроен в этой системе. Пожалуйста, обратитесь к администратору.',
'common.button.edit.item': 'Редактировать {name}',
'common.button.copy.item': 'Duplicate {name}',
'common.button.copy.item': 'Дублировать {name}',
'common.button.terminal': 'Терминал',
'common.button.addItem': 'Добавить элемент',
'common.help.default': 'По умолчанию: {content}',
@@ -284,7 +287,9 @@ export default {
'common.image.limit.height': 'Image height must be {height}.',
'common.remaining': 'Остаток {count}',
'common.max': 'Макс. {count}',
'common.validate.group': 'Please complete the {group} configuration'
'common.max.count': 'Количество {label}',
'common.validate.group': 'Please complete the {group} configuration',
'common.preferences': 'Preferences'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+14 -2
View File
@@ -14,7 +14,11 @@ export default {
'gpuservice.template.command.placeholder':
'Разделяйте аргументы пробелами; аргументы с пробелами заключайте в кавычки, например: /bin/bash -c "echo hello world"',
'gpuservice.template.mountPath': 'Путь монтирования',
'gpuservice.template.mountPath.tips':
'Путь, по которому том хранилища монтируется по умолчанию при создании экземпляра из этого шаблона. Может использоваться для сохранения данных, которые нужно сохранить во время работы экземпляра.',
'gpuservice.template.containerDisk': 'Диск контейнера (GB)',
'gpuservice.template.containerDisk.tips':
'Размер системного диска контейнера.',
'gpuservice.template.memory': 'Память (GB)',
'gpuservice.instance.containerDisk.remaining':
'Диск контейнера (Макс. {count} GB)',
@@ -100,10 +104,13 @@ export default {
'gpuservice.instance.templates': 'Шаблоны экземпляров',
'gpuservice.instance.section.storage': 'Том хранилища',
'gpuservice.instance.type.required': 'Выберите тип экземпляра',
'gpuservice.instance.type.noAvailable': 'Нет доступных типов экземпляров',
'gpuservice.instance.gpuCount': 'Количество GPU',
'gpuservice.instance.gpuCount.required': 'Введите количество GPU',
'gpuservice.instance.gpuCount.max': 'Выберите максимум {count} GPU-карт',
'gpuservice.instance.gpuCount.min': 'Выберите минимум {count} GPU-карт',
'gpuservice.instance.cpuCount.max': 'Выберите максимум {count} ядер CPU',
'gpuservice.instance.cpuCount.min': 'Выберите минимум {count} ядер CPU',
'gpuservice.instance.gpuCount.noAvailable':
'Нет доступных ресурсов GPU, выберите другой тип экземпляра.',
'gpuservice.instance.gpuCount.zero': 'Только CPU, для подготовки окружения.',
@@ -112,6 +119,10 @@ export default {
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.disk': 'Диск',
'gpuservice.table.count': 'Количество',
'gpuservice.instance.disk.system': 'Системный диск',
'gpuservice.instance.disk.ephemeral': 'Временное хранилище',
'gpuservice.instance.disk.persistent': 'Постоянное хранилище',
'gpuservice.instance.search.type.placeholder': 'Поиск по имени',
'gpuservice.instance.search.template.placeholder':
'Поиск по имени шаблона, образу или пути монтирования',
@@ -144,7 +155,7 @@ export default {
'gpuservice.storage.persistent': 'Постоянное',
'gpuservice.storage.temporary': 'Временное',
'gpuservice.storage.persistentVolume': 'Постоянное',
'gpuservice.storage.persistentVolume.required': 'Выберите постоянный том',
'gpuservice.storage.persistentVolume.required': 'Выберите хранилище',
'gpuservice.storage.persistentVolume.capacity': 'Ёмкость (ГБ)',
'gpuservice.storage.persistentVolume.capacity.required': 'Введите ёмкость',
'gpuservice.storage.persistentVolume.releaseWithInstance':
@@ -157,5 +168,6 @@ export default {
'gpuservice.storage.temporary.tips':
'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips':
'Data persists across restarts and is deleted only when the instance is terminated. Cannot be shared with other instances.'
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
'gpuservice.form.storage.select': 'Выберите хранилище'
};
+9 -6
View File
@@ -23,9 +23,12 @@ export default {
'menu.resources': 'Ресурсы',
'menu.apikeys': 'API-ключи',
'menu.users': 'Пользователи',
'menu.profile': 'Профиль',
'menu.profile': 'Preferences',
'menu.login': 'Авторизация',
'menu.usage': 'Использование',
'menu.usage.usage': 'Использование',
'menu.billingAndUsage': 'Использование и биллинг',
'menu.billingAndUsage.usage': 'Использование',
'menu.404': 'Ошибка 404',
'menu.resources.workers': 'Воркеры',
'menu.resources.gpus': 'GPUs',
@@ -33,12 +36,12 @@ export default {
'menu.accessControl': 'Управление доступом',
'menu.accessControl.apikeys': 'API Ключи',
'menu.accessControl.users': 'Пользователи',
'menu.clusterManagement': 'Управление кластерами',
'menu.clusterManagement.clusters': 'Кластеры',
'menu.clusterManagement.credentials': 'Облачные аккаунты',
'menu.accessControl.organizations': 'Организации',
'menu.resources.clusters': 'Кластеры',
'menu.resources.credentials': 'Облачные аккаунты',
'menu.models.userModels': 'Мои модели',
'menu.clusterManagement.clusterDetail': 'Детали кластера',
'menu.clusterManagement.clusterCreate': 'Создать кластер',
'menu.resources.clusterDetail': 'Детали кластера',
'menu.resources.clusterCreate': 'Создать кластер',
'menu.models.backendsList': 'Бэкенды запуска',
'menu.settings': 'Settings',
'menu.gpuService': 'GPU Service',
+1 -1
View File
@@ -295,7 +295,7 @@ export default {
'models.instance.startHistory': 'Run History',
'models.instance.startHistory.tips':
'Shows logs from the run before the last error-triggered restart.',
'models.form.lora.label': 'LoRA Adapter',
'models.form.lora.label': 'LoRA Adapters',
'models.form.lora.add': 'Add LoRA Adapter',
'models.form.lora.select': 'Select LoRA',
'models.form.lora.name': 'LoRA name',
+15
View File
@@ -0,0 +1,15 @@
export default {
'organizations.upsell.title': 'Organizations are an Enterprise feature',
'organizations.upsell.subtitle':
'Multi-tenancy lets you isolate users, resources, and quotas across teams. Upgrade to GPUStack Enterprise to manage organizations.',
'organizations.upsell.featuresTitle': 'What you get in Enterprise',
'organizations.upsell.feature.orgs':
'Create organizations to group users and isolate workloads',
'organizations.upsell.feature.members':
'Manage members and roles per organization',
'organizations.upsell.feature.quotas':
'Set resource and token quotas per organization',
'organizations.upsell.feature.isolation':
'Scope API keys, model deployments, and resources per organization',
'organizations.upsell.cta': 'Learn about Enterprise'
};
+71 -1
View File
@@ -17,9 +17,15 @@ export default {
'usage.table.user.apiKeysUsed': 'API Keys Used',
'usage.table.lastActive': 'Last Active',
'usage.filter.granularity': 'Granularity',
'usage.filter.granularity.hour': 'Час',
'usage.filter.granularity.day': 'Day',
'usage.filter.granularity.week': 'Week',
'usage.filter.granularity.month': 'Month',
'usage.tabs.summary': 'Обзор',
'usage.tabs.tokens': 'Токены',
'usage.tabs.gpuInstances': 'GPU-инстансы',
'usage.tabs.storage': 'Хранилище',
'usage.tabs.resourceEvents': 'События ресурсов',
'usage.tabs.models': 'Models',
'usage.tabs.apikeys': 'API Keys',
'usage.tabs.users': 'User',
@@ -32,5 +38,69 @@ export default {
'usage.chart.cached': 'Cached',
'usage.chart.uncached': 'Uncached',
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
'usage.table.inputTokensCached': 'Input Tokens Cached'
'usage.table.inputTokensCached': 'Input Tokens Cached',
// --- Resource usage: shared metrics & units ---
'usage.metric.tokens': 'Токены',
'usage.metric.input': 'Вход',
'usage.metric.output': 'Выход',
'usage.metric.gpuHours': 'GPU-часы',
'usage.metric.instanceHours': 'Часы инстансов',
'usage.metric.gbDays': 'ГБ-дни',
'usage.metric.gbHours': 'ГБ-часы',
'usage.metric.activeUsers': 'Активные пользователи',
'usage.metric.activeInstances': 'Активные инстансы',
'usage.metric.activeStorage': 'Активное хранилище',
'usage.metric.activeVolumes': 'Активные тома',
'usage.metric.storageTypes': 'Типы хранилищ',
'usage.metric.gpuHours.tip':
'Время работы инстанса, взвешенное по количеству GPU: инстанс с N GPU, работающий H часов, считается как N × H GPU-часов. Равно часам инстансов, когда каждый инстанс использует один GPU.',
'usage.metric.instanceHours.tip':
'Суммарное время работы всех инстансов независимо от количества используемых GPU. Один инстанс, работающий 2 часа = 2 часа инстанса.',
'usage.metric.gbDays.tip':
'Ёмкость хранилища, проинтегрированная по времени, в ГБ × дни: 10 ГБ в течение 5 дней = 50 ГБ-дней. (= ГБ-часы ÷ 24)',
'usage.metric.gbHours.tip':
'Ёмкость хранилища, проинтегрированная по времени, в ГБ × часы: 10 ГБ в течение 5 часов = 50 ГБ-часов.',
// --- Resource usage: common table / labels ---
'usage.common.noData': 'Нет данных',
'usage.common.unknown': 'неизвестно',
'usage.table.date': 'Дата',
'usage.table.name': 'Имя',
'usage.table.user': 'Пользователь',
'usage.table.users': 'Пользователи',
'usage.table.type': 'Тип',
'usage.table.instance': 'Инстанс',
'usage.table.instanceType': 'Тип инстанса',
'usage.table.instanceTypes': 'Типы инстансов',
'usage.table.instances': 'Инстансы',
'usage.table.capacity': 'Ёмкость',
'usage.export.tableNamed': 'Экспорт данных таблицы — {name}',
// --- Summary tab ---
'usage.summary.compute': 'Вычисления',
'usage.summary.tokensOverTime': 'Токены по времени',
'usage.summary.gpuHoursOverTime': 'GPU-часы по времени',
'usage.summary.gbDaysOverTime': 'ГБ-дни по времени',
// --- GPU Instances / Storage filters ---
'usage.filter.instance': 'Фильтр по инстансу',
'usage.filter.storage': 'Фильтр по хранилищу',
// --- Resource events ---
'usage.events.resourceType': 'Тип ресурса',
'usage.events.eventType': 'Тип события',
'usage.events.col.time': 'Время',
'usage.events.col.resource': 'Ресурс',
'usage.events.col.event': 'Событие',
'usage.events.col.message': 'Сообщение',
'usage.events.resource.gpuInstance': 'GPU-инстанс',
'usage.events.resource.cpuInstance': 'CPU-инстанс',
'usage.events.type.created': 'Создан',
'usage.events.type.deleted': 'Удалён',
'usage.events.type.started': 'Запущен',
'usage.events.type.stopped': 'Остановлен',
'usage.events.type.updated': 'Обновлён',
'usage.events.type.attached': 'Подключён',
'usage.events.type.detached': 'Отключён'
};
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
'vendor.hygon': 'Hygon',
'vendor.moorthreads': 'Moore Threads',
'vendor.iluvatar': 'Iluvatar',
'vendor.metax': 'Metax',
'vendor.metax': 'MetaX',
'vendor.cambricon': 'Cambricon',
'vendor.thead': 'T-Head PPU'
};
+3 -1
View File
@@ -23,5 +23,7 @@ export default {
'apikeys.accessScope.inference': 'Inference APIs',
'apikeys.access.permissions': 'Access Permissions',
'apikeys.type.auto': 'Auto-generated',
'apikeys.type.custom': 'Custom'
'apikeys.type.custom': 'Custom',
'apikeys.button.ipConfig': 'IP Access Control',
'quotaLimits.button.title': 'Quota Limit'
};
+14 -5
View File
@@ -39,7 +39,7 @@ export default {
'clusters.workerpool.batchSize.desc':
'İşçi havuzunda eşzamanlı olarak oluşturulan işçi düğüm sayısı',
'clusters.create.addworker.tips':
'Aşağıdaki komutu çalıştırmadan önce lütfen {label} için <a href={link} target="_blank">ön koşulların</a> karşılandığından emin olun.',
'Aşağıdaki komutu çalıştırmadan önce lütfen <a href={link} target="_blank">ön koşulların</a> karşılandığından emin olun.',
'clusters.create.addCommand.tips':
'Eklenmesi gereken İşçi Düğümde, kümeye katılması için aşağıdaki komutu çalıştırın.',
'clusters.create.addCommand.k8s.tips':
@@ -110,6 +110,10 @@ export default {
'{count} yeni işçi düğüm kümeye eklendi.',
'clusters.create.serverUrl': "GPUStack Sunucu URL'si",
'clusters.create.workerConfig': 'İşçi Düğüm Yapılandırması',
'clusters.edit.k8sOptions.changed.tip':
'Kubernetes seçeneklerini değiştirdiniz. Değişikliklerin etkili olması için kayıt komutunu hedef kümede yeniden çalıştırın.',
'clusters.edit.workerConfig.tip':
'İşçi düğüm yapılandırmasındaki değişiklikler yalnızca ilgili işçi düğümleri yeniden başlatıldıktan sonra etkili olur.',
'clusters.addworker.containerName': 'İşçi Düğüm Konteyner Adı',
'clusters.addworker.containerName.tips':
'İşçi düğüm konteyneri için bir ad belirtin.',
@@ -166,7 +170,7 @@ export default {
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
'clusters.k8sOptions.title': 'K8s Deployment Options',
'clusters.k8sOptions.title': 'Kubernetes Deployment Options',
'clusters.imageCredentials.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry',
@@ -181,9 +185,14 @@ export default {
'clusters.namespace.title': 'Namespace',
'clusters.namespace.tip':
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.',
'clusters.gpuInstances.title': 'GPU Instances',
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.clusterType.title': 'Cluster Type',
'clusters.modelService.title': 'Model Service',
'clusters.modelService.tip':
'For LLM inference and API serving — e.g. exposing model APIs and token-based services.',
'clusters.gpuInstances.title': 'GPU Service',
'clusters.gpuInstances.tip':
'For on-demand GPU compute — e.g. interactive development, training jobs, or custom environments.',
'clusters.gpuInstances.staticAddress': 'GPU Service Static Access Address',
'clusters.gpuInstances.staticAddress.tip':
'Static address the operator uses to access GPU instances in this cluster (e.g. a LoadBalancer VIP). Optional.'
};
+6 -1
View File
@@ -46,6 +46,7 @@ export default {
'common.button.enabled': 'Etkin',
'common.button.disabled': 'Devre dışı',
'common.button.upgrade': 'Yükselt',
'common.enterprise.feature': 'Available in GPUStack Enterprise',
'common.input.holder': 'Lütfen girin',
'common.validate.value': '{name} değeri gereklidir',
'common.button.edit': 'Düzenle',
@@ -55,6 +56,7 @@ export default {
'common.button.viewevent': 'Olayları Görüntüle',
'common.button.recreate': 'Yeniden Oluştur',
'common.table.operation': 'İşlemler',
'common.table.creator': 'Oluşturan',
'common.table.createTime': 'Oluşturulma',
'common.table.updateTime': 'Güncellenme',
'common.table.description': 'Açıklama',
@@ -65,6 +67,7 @@ export default {
'common.search.name.placeholder': 'ada göre filtrele',
'common.search.id.placeholder': 'kimliğe göre filtrele',
'common.filter.byId': 'kimliğe göre filtrele',
'common.filter.byCreator': 'Oluşturana göre filtrele',
'common.table.type': 'Tür',
'common.table.default': 'Varsayılan Değer',
'common.copy.success': 'Kopyalama başarılı!',
@@ -288,5 +291,7 @@ export default {
'common.image.limit.height': 'Image height must be {height}.',
'common.remaining': 'Kalan {count}',
'common.max': 'Maks. {count}',
'common.validate.group': 'Please complete the {group} configuration'
'common.max.count': '{label} Sayısı',
'common.validate.group': 'Please complete the {group} configuration',
'common.preferences': 'Tercihler'
};
+13 -3
View File
@@ -13,7 +13,10 @@ export default {
'gpuservice.template.command.placeholder':
'Argümanları boşlukla ayırın; boşluk içeren argümanları tırnak içine alın, örn.: /bin/bash -c "echo hello world"',
'gpuservice.template.mountPath': 'Bağlama Yolu',
'gpuservice.template.mountPath.tips':
'Bu şablondan bir örnek oluşturulurken depolama biriminin varsayılan olarak bağlanacağı yol. Örnek çalışırken saklanması gereken verileri kalıcı hale getirmek için kullanılabilir.',
'gpuservice.template.containerDisk': 'Konteyner Diski (GB)',
'gpuservice.template.containerDisk.tips': 'Konteyner sistem diskinin boyutu.',
'gpuservice.template.memory': 'Bellek (GB)',
'gpuservice.instance.containerDisk.remaining':
'Konteyner Diski (Maks. {count} GB)',
@@ -97,10 +100,13 @@ export default {
'gpuservice.instance.templates': 'Örnek Şablonları',
'gpuservice.instance.section.storage': 'Depolama Hacmi',
'gpuservice.instance.type.required': 'Lütfen bir örnek türü seçin',
'gpuservice.instance.type.noAvailable': 'Kullanılabilir örnek türü yok',
'gpuservice.instance.gpuCount': 'GPU Sayısı',
'gpuservice.instance.gpuCount.required': 'Lütfen GPU sayısını girin',
'gpuservice.instance.gpuCount.max': 'En fazla {count} GPU kartı seçin',
'gpuservice.instance.gpuCount.min': 'En az {count} GPU kartı seçin',
'gpuservice.instance.cpuCount.max': 'En fazla {count} CPU çekirdeği seçin',
'gpuservice.instance.cpuCount.min': 'En az {count} CPU çekirdeği seçin',
'gpuservice.instance.gpuCount.noAvailable':
'Kullanılabilir GPU kaynağı yok, lütfen başka bir örnek türü seçin.',
'gpuservice.instance.gpuCount.zero': 'Yalnızca CPU, ortam hazırlığı için.',
@@ -109,6 +115,10 @@ export default {
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.disk': 'Disk',
'gpuservice.table.count': 'Sayı',
'gpuservice.instance.disk.system': 'Sistem Diski',
'gpuservice.instance.disk.ephemeral': 'Geçici Depolama',
'gpuservice.instance.disk.persistent': 'Kalıcı Depolama',
'gpuservice.instance.search.type.placeholder': 'Ada göre ara',
'gpuservice.instance.search.template.placeholder':
'Şablon adına, imaja veya bağlama yoluna göre ara',
@@ -142,8 +152,7 @@ export default {
'gpuservice.storage.persistent': 'Kalıcı',
'gpuservice.storage.temporary': 'Geçici',
'gpuservice.storage.persistentVolume': 'Kalıcı',
'gpuservice.storage.persistentVolume.required':
'Lütfen bir kalıcı hacim seçin',
'gpuservice.storage.persistentVolume.required': 'Lütfen bir depolama seçin',
'gpuservice.storage.persistentVolume.capacity': 'Kapasite (GB)',
'gpuservice.storage.persistentVolume.capacity.required':
'Lütfen kapasiteyi girin',
@@ -157,5 +166,6 @@ export default {
'gpuservice.storage.temporary.tips':
'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips':
'Data persists across restarts and is deleted only when the instance is terminated. Cannot be shared with other instances.'
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
'gpuservice.form.storage.select': 'Depolamayı Seç'
};
+9 -6
View File
@@ -29,15 +29,18 @@ export default {
'menu.accessControl': 'Erişim Kontrolü',
'menu.accessControl.apikeys': 'API Anahtarları',
'menu.accessControl.users': 'Kullanıcılar',
'menu.profile': 'Profil',
'menu.accessControl.organizations': 'Organizasyonlar',
'menu.profile': 'Preferences',
'menu.login': 'Giriş',
'menu.usage': 'Kullanım',
'menu.usage.usage': 'Kullanım',
'menu.billingAndUsage': 'Kullanım ve Faturalandırma',
'menu.billingAndUsage.usage': 'Kullanım',
'menu.404': '404',
'menu.clusterManagement': 'Küme Yönetimi',
'menu.clusterManagement.clusters': 'Kümeler',
'menu.clusterManagement.credentials': 'Bulut Kimlik Bilgileri',
'menu.clusterManagement.clusterDetail': 'Küme Detayı',
'menu.clusterManagement.clusterCreate': 'Küme Oluştur',
'menu.resources.clusters': 'Kümeler',
'menu.resources.credentials': 'Bulut Kimlik Bilgileri',
'menu.resources.clusterDetail': 'Küme Detayı',
'menu.resources.clusterCreate': 'Küme Oluştur',
'menu.models.backendsList': 'Çıkarım Altyapıları',
'menu.models.instances': 'Instances',
'menu.settings': 'Settings',
+1 -1
View File
@@ -291,7 +291,7 @@ export default {
'models.instance.startHistory': 'Run History',
'models.instance.startHistory.tips':
'Shows logs from the run before the last error-triggered restart.',
'models.form.lora.label': 'LoRA Adapter',
'models.form.lora.label': 'LoRA Adapters',
'models.form.lora.add': 'Add LoRA Adapter',
'models.form.lora.select': 'Select LoRA',
'models.form.lora.name': 'LoRA name',
+15
View File
@@ -0,0 +1,15 @@
export default {
'organizations.upsell.title': 'Organizations are an Enterprise feature',
'organizations.upsell.subtitle':
'Multi-tenancy lets you isolate users, resources, and quotas across teams. Upgrade to GPUStack Enterprise to manage organizations.',
'organizations.upsell.featuresTitle': 'What you get in Enterprise',
'organizations.upsell.feature.orgs':
'Create organizations to group users and isolate workloads',
'organizations.upsell.feature.members':
'Manage members and roles per organization',
'organizations.upsell.feature.quotas':
'Set resource and token quotas per organization',
'organizations.upsell.feature.isolation':
'Scope API keys, model deployments, and resources per organization',
'organizations.upsell.cta': 'Learn about Enterprise'
};
+71 -1
View File
@@ -17,9 +17,15 @@ export default {
'usage.table.user.apiKeysUsed': 'API Keys Used',
'usage.table.lastActive': 'Last Active',
'usage.filter.granularity': 'Granularity',
'usage.filter.granularity.hour': 'Saat',
'usage.filter.granularity.day': 'Day',
'usage.filter.granularity.week': 'Week',
'usage.filter.granularity.month': 'Month',
'usage.tabs.summary': 'Özet',
'usage.tabs.tokens': 'Token',
'usage.tabs.gpuInstances': 'GPU Örnekleri',
'usage.tabs.storage': 'Depolama',
'usage.tabs.resourceEvents': 'Kaynak Olayları',
'usage.tabs.models': 'Models',
'usage.tabs.apikeys': 'API Keys',
'usage.tabs.users': 'User',
@@ -32,5 +38,69 @@ export default {
'usage.chart.cached': 'Cached',
'usage.chart.uncached': 'Uncached',
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
'usage.table.inputTokensCached': 'Input Tokens Cached'
'usage.table.inputTokensCached': 'Input Tokens Cached',
// --- Resource usage: shared metrics & units ---
'usage.metric.tokens': 'Token',
'usage.metric.input': 'Girdi',
'usage.metric.output': 'Çıktı',
'usage.metric.gpuHours': 'GPU Saati',
'usage.metric.instanceHours': 'Örnek Saati',
'usage.metric.gbDays': 'GB-Gün',
'usage.metric.gbHours': 'GB-Saat',
'usage.metric.activeUsers': 'Aktif Kullanıcılar',
'usage.metric.activeInstances': 'Aktif Örnekler',
'usage.metric.activeStorage': 'Aktif Depolama',
'usage.metric.activeVolumes': 'Aktif Birimler',
'usage.metric.storageTypes': 'Depolama Türleri',
'usage.metric.gpuHours.tip':
'Örnek çalışma süresinin GPU sayısına göre ağırlıklandırılmış hali: N GPU kullanan bir örnek H saat çalıştığında N × H GPU-saat olarak sayılır. Her örnek tek bir GPU kullandığında Örnek Saati ile eşittir.',
'usage.metric.instanceHours.tip':
'Her örneğin kullandığı GPU sayısından bağımsız olarak tüm örneklerin toplam çalışma süresi. 2 saat çalışan bir örnek = 2 örnek-saat.',
'usage.metric.gbDays.tip':
'Depolama kapasitesinin zamana göre integrali, GB × gün cinsinden: 5 gün boyunca tutulan 10 GB = 50 GB-gün. (= GB-Saat ÷ 24)',
'usage.metric.gbHours.tip':
'Depolama kapasitesinin zamana göre integrali, GB × saat cinsinden: 5 saat boyunca tutulan 10 GB = 50 GB-saat.',
// --- Resource usage: common table / labels ---
'usage.common.noData': 'Veri yok',
'usage.common.unknown': 'bilinmiyor',
'usage.table.date': 'Tarih',
'usage.table.name': 'Ad',
'usage.table.user': 'Kullanıcı',
'usage.table.users': 'Kullanıcılar',
'usage.table.type': 'Tür',
'usage.table.instance': 'Örnek',
'usage.table.instanceType': 'Örnek Türü',
'usage.table.instanceTypes': 'Örnek Türleri',
'usage.table.instances': 'Örnekler',
'usage.table.capacity': 'Kapasite',
'usage.export.tableNamed': 'Tablo Verilerini Dışa Aktar — {name}',
// --- Summary tab ---
'usage.summary.compute': 'Hesaplama',
'usage.summary.tokensOverTime': 'Zaman içinde Token',
'usage.summary.gpuHoursOverTime': 'Zaman içinde GPU Saati',
'usage.summary.gbDaysOverTime': 'Zaman içinde GB-Gün',
// --- GPU Instances / Storage filters ---
'usage.filter.instance': 'Örneğe göre filtrele',
'usage.filter.storage': 'Depolamaya göre filtrele',
// --- Resource events ---
'usage.events.resourceType': 'Kaynak türü',
'usage.events.eventType': 'Olay türü',
'usage.events.col.time': 'Zaman',
'usage.events.col.resource': 'Kaynak',
'usage.events.col.event': 'Olay',
'usage.events.col.message': 'Mesaj',
'usage.events.resource.gpuInstance': 'GPU Örneği',
'usage.events.resource.cpuInstance': 'CPU Örneği',
'usage.events.type.created': 'Oluşturuldu',
'usage.events.type.deleted': 'Silindi',
'usage.events.type.started': 'Başlatıldı',
'usage.events.type.stopped': 'Durduruldu',
'usage.events.type.updated': 'Güncellendi',
'usage.events.type.attached': 'Eklendi',
'usage.events.type.detached': 'Ayrıldı'
};
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
'vendor.hygon': 'Hygon',
'vendor.moorthreads': 'Moore Threads',
'vendor.iluvatar': 'Iluvatar',
'vendor.metax': 'Metax',
'vendor.metax': 'MetaX',
'vendor.cambricon': 'Cambricon',
'vendor.thead': 'T-Head PPU'
};
+3 -1
View File
@@ -22,5 +22,7 @@ export default {
'apikeys.accessScope.inference': '推理接口',
'apikeys.access.permissions': '访问权限',
'apikeys.type.auto': '自动生成',
'apikeys.type.custom': '自定义'
'apikeys.type.custom': '自定义',
'apikeys.button.ipConfig': 'IP 访问控制',
'quotaLimits.button.title': '配额限制'
};
+14 -5
View File
@@ -38,7 +38,7 @@ export default {
'clusters.create.noRegions': '无可用的区域',
'clusters.workerpool.batchSize.desc': '节点池中同时创建的节点数量。',
'clusters.create.addworker.tips':
'在执行以下命令之前,请确保已满足 {label} 的<a href={link} target="_blank">先决条件</a>。',
'在执行以下命令之前,请确保已满足<a href={link} target="_blank">先决条件</a>。',
'clusters.create.addCommand.tips':
'在需要添加的节点上运行以下命令,将其加入到集群中。',
'clusters.create.addCommand.k8s.tips':
@@ -108,6 +108,10 @@ export default {
'已将 {count} 个新节点添加到集群中。',
'clusters.create.serverUrl': 'GPUStack Server 节点地址',
'clusters.create.workerConfig': '节点配置',
'clusters.edit.k8sOptions.changed.tip':
'您已修改 Kubernetes 选项,需要在目标集群上重新运行注册命令才会生效。',
'clusters.edit.workerConfig.tip':
'修改节点配置后,需要重启对应节点才会生效。',
'clusters.addworker.containerName': '节点容器名称',
'clusters.addworker.containerName.tips': '为节点容器指定一个名称。',
'clusters.addworker.dataVolume': 'GPUStack 数据卷',
@@ -158,7 +162,7 @@ export default {
'clusters.systemDefaultContainerRegistry.title': '默认容器镜像仓库',
'clusters.systemDefaultContainerRegistry.tip':
'用于解析该集群 GPUStack 镜像的默认镜像仓库。未设置时回退到服务端默认值。',
'clusters.k8sOptions.title': 'K8s 部署选项',
'clusters.k8sOptions.title': 'Kubernetes 部署选项',
'clusters.imageCredentials.title': '镜像仓库凭证',
'clusters.imageCredentials.add': '添加凭证',
'clusters.imageCredentials.registry': '镜像仓库地址',
@@ -173,9 +177,14 @@ export default {
'clusters.namespace.title': '命名空间',
'clusters.namespace.tip':
'集群清单渲染所使用的 Kubernetes 命名空间。留空则使用 gpustack-system。',
'clusters.gpuInstances.title': 'GPU 实例',
'clusters.gpuInstances.tip': '为该集群启用 GPU 实例支持。',
'clusters.gpuInstances.staticAddress': '静态访问地址',
'clusters.clusterType.title': '集群类型',
'clusters.modelService.title': '模型服务',
'clusters.modelService.tip':
'适用于大模型推理与 API 服务化场景,例如对外提供模型 API 与 Token 服务能力。',
'clusters.gpuInstances.title': 'GPU 服务',
'clusters.gpuInstances.tip':
'适用于按需分配 GPU 计算资源的场景,例如交互式开发、训练任务或自定义运行环境。',
'clusters.gpuInstances.staticAddress': 'GPU 服务静态访问地址',
'clusters.gpuInstances.staticAddress.tip':
'Operator 访问该集群 GPU 实例所使用的静态地址(例如 LoadBalancer VIP)。可选。'
};
+6 -2
View File
@@ -44,6 +44,7 @@ export default {
'common.button.rollback': '回滚',
'common.button.new': '新建{ text }',
'common.button.upgrade': '升级',
'common.enterprise.feature': 'GPUStack 企业版可用',
'common.input.holder': '请输入',
'common.holder.search': '搜索',
'common.button.edit': '编辑',
@@ -51,8 +52,8 @@ export default {
'common.button.confirm': '确定',
'common.button.viewlog': '查看日志',
'common.button.viewevent': '查看事件',
'common.button.recreate': '重新创建',
'common.table.operation': '操作',
'common.table.creator': '创建者',
'common.table.createTime': '创建时间',
'common.table.updateTime': '更新时间',
'common.table.description': '描述',
@@ -233,6 +234,7 @@ export default {
'common.text.tips': '提示',
'settings.system': '系统设置',
'common.filter.byId': '按 ID 查询',
'common.filter.byCreator': '按创建者筛选',
'common.appearance': '外观',
'common.appearance.dark': '深色',
'common.appearance.light': '浅色',
@@ -278,5 +280,7 @@ export default {
'common.image.limit.height': '图片高度须为{height}。',
'common.remaining': '剩余 {count}',
'common.max': '最大 {count}',
'common.validate.group': '请填写完整的{group}配置'
'common.max.count': '{label} 数量',
'common.validate.group': '请填写完整的{group}配置',
'common.preferences': '偏好设置'
};
+1 -1
View File
@@ -2,7 +2,7 @@ export default {
'dashboard.workers': '节点',
'dashboard.deployments': '部署',
'dashboard.clusters': '集群',
'dashboard.totalgpus': 'GPUs',
'dashboard.totalgpus': 'GPU',
'dashboard.systemload': '系统负载',
'dashboard.memory': '内存',
'dashboard.vram': '显存',
+18 -7
View File
@@ -13,10 +13,13 @@ export default {
'gpuservice.template.command.placeholder':
'使用空格分隔参数;含空格的参数请用引号包裹,例如:/bin/bash -c "echo hello world"',
'gpuservice.template.mountPath': '挂载路径',
'gpuservice.template.containerDisk': '容器磁盘(GB)',
'gpuservice.template.memory': '内存(GB)',
'gpuservice.instance.containerDisk.remaining': '容器磁盘(最大{count}GB)',
'gpuservice.instance.memory.remaining': '内存(最大{count}GB)',
'gpuservice.template.mountPath.tips':
'在使用该模板创建实例时,存储卷默认挂载的路径,可用于持久化实例运行过程中需要保留的数据。',
'gpuservice.template.containerDisk': '容器磁盘GB',
'gpuservice.template.containerDisk.tips': '容器系统盘大小。',
'gpuservice.template.memory': '内存(GB',
'gpuservice.instance.containerDisk.remaining': '容器磁盘(最大 {count} GB',
'gpuservice.instance.memory.remaining': '内存(最大 {count} GB',
'gpuservice.template.displayName': '显示名称',
'gpuservice.template.displayName.max': '显示名称不能超过 63 个字符',
'gpuservice.template.ports': '端口',
@@ -92,10 +95,13 @@ export default {
'gpuservice.instance.templates': '实例模板',
'gpuservice.instance.section.storage': '存储卷',
'gpuservice.instance.type.required': '请选择实例类型',
'gpuservice.instance.type.noAvailable': '无可用的实例类型',
'gpuservice.instance.gpuCount': 'GPU 数量',
'gpuservice.instance.gpuCount.required': '请输入 GPU 数量',
'gpuservice.instance.gpuCount.max': '最多选择 {count} 张卡',
'gpuservice.instance.gpuCount.min': '至少选择 {count} 张卡',
'gpuservice.instance.cpuCount.max': '最多选择 {count} 核',
'gpuservice.instance.cpuCount.min': '至少选择 {count} 核',
'gpuservice.instance.gpuCount.noAvailable':
'没有可用的 GPU 资源,请选择其他实例类型。',
'gpuservice.instance.gpuCount.zero': '仅使用 CPU,用于环境准备。',
@@ -104,6 +110,10 @@ export default {
'gpuservice.instance.memory': '显存',
'gpuservice.instance.ram': '内存',
'gpuservice.instance.disk': '磁盘',
'gpuservice.table.count': '数量',
'gpuservice.instance.disk.system': '系统盘',
'gpuservice.instance.disk.ephemeral': '临时存储',
'gpuservice.instance.disk.persistent': '持久存储',
'gpuservice.instance.search.type.placeholder': '搜索名称',
'gpuservice.instance.search.template.placeholder':
'搜索模板名称、镜像或挂载路径',
@@ -137,13 +147,14 @@ export default {
'gpuservice.storage.persistentVolume': '持久',
'gpuservice.storage.temporary.tips': '实例停止后,数据将被清除。',
'gpuservice.storage.persistentVolume.tips':
'数据在实例重启后仍会保留,仅在实例终止删除。无法被其它实例共享。',
'gpuservice.storage.persistentVolume.required': '请选择持久卷',
'数据在实例重启后仍会保留。持久卷不会随实例终止删除,并可被多个实例共享。',
'gpuservice.storage.persistentVolume.required': '请选择存储',
'gpuservice.storage.persistentVolume.capacity': '容量(GB',
'gpuservice.storage.persistentVolume.capacity.required': '请输入容量',
'gpuservice.storage.persistentVolume.releaseWithInstance': '随实例释放',
'gpuservice.storage.tempCapacity': '容量(GB',
'gpuservice.storage.tempCapacity.required': '请输入临时存储容量',
'gpuservice.form.rule.name':
'由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。'
'由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。',
'gpuservice.form.storage.select': '选择存储'
};
+10 -7
View File
@@ -25,21 +25,24 @@ export default {
'menu.resources': '资源',
'menu.apikeys': 'API 密钥',
'menu.users': '用户',
'menu.profile': '个人信息',
'menu.profile': '偏好',
'menu.login': '登录',
'menu.usage': '使用量',
'menu.usage.usage': '使用量',
'menu.billingAndUsage': '用量与计费',
'menu.billingAndUsage.usage': '用量统计',
'menu.404': '404',
'menu.resources.workers': '节点',
'menu.resources.gpus': 'GPUs',
'menu.resources.gpus': 'GPU',
'menu.models.modelfiles': '模型文件',
'menu.accessControl': '访问控制',
'menu.accessControl.apikeys': 'API 密钥',
'menu.accessControl.users': '用户',
'menu.clusterManagement': '集群管理',
'menu.clusterManagement.clusters': '集群',
'menu.clusterManagement.credentials': '云凭证',
'menu.clusterManagement.clusterDetail': '集群详情',
'menu.clusterManagement.clusterCreate': '创建集群',
'menu.accessControl.organizations': '组织',
'menu.resources.clusters': '集群',
'menu.resources.credentials': '云凭证',
'menu.resources.clusterDetail': '集群详情',
'menu.resources.clusterCreate': '创建集群',
'menu.models.backendsList': '推理后端',
'menu.gpuService': 'GPU 服务',
'menu.gpuService.instances': 'GPU 实例',
+12
View File
@@ -0,0 +1,12 @@
export default {
'organizations.upsell.title': '组织是企业版功能',
'organizations.upsell.subtitle':
'多租户可在团队间隔离用户、资源与配额。升级到 GPUStack 企业版即可管理组织。',
'organizations.upsell.featuresTitle': '企业版包含的能力',
'organizations.upsell.feature.orgs': '创建组织来分组用户并隔离工作负载',
'organizations.upsell.feature.members': '为每个组织管理成员与角色',
'organizations.upsell.feature.quotas': '为每个组织设置资源与 Token 配额',
'organizations.upsell.feature.isolation':
'按组织隔离 API 密钥、模型部署与资源',
'organizations.upsell.cta': '了解企业版'
};
+71 -1
View File
@@ -17,9 +17,15 @@ export default {
'usage.table.user.apiKeysUsed': '使用的 API 密钥',
'usage.table.lastActive': '最后活跃时间',
'usage.filter.granularity': '粒度',
'usage.filter.granularity.hour': '按小时',
'usage.filter.granularity.day': '按天',
'usage.filter.granularity.week': '按周',
'usage.filter.granularity.month': '按月',
'usage.tabs.summary': '总览',
'usage.tabs.tokens': 'Token',
'usage.tabs.gpuInstances': 'GPU 实例',
'usage.tabs.storage': '存储',
'usage.tabs.resourceEvents': '资源事件',
'usage.tabs.models': '模型',
'usage.tabs.apikeys': 'API 密钥',
'usage.tabs.users': '用户',
@@ -32,5 +38,69 @@ export default {
'usage.chart.cached': '缓存',
'usage.chart.uncached': '非缓存',
'usage.chart.inputTokensCached': '输入 Token 数(缓存/非缓存)',
'usage.table.inputTokensCached': '输入 Token 缓存数'
'usage.table.inputTokensCached': '输入 Token 缓存数',
// --- Resource usage: shared metrics & units ---
'usage.metric.tokens': 'Token 数',
'usage.metric.input': '输入',
'usage.metric.output': '输出',
'usage.metric.gpuHours': 'GPU 小时',
'usage.metric.instanceHours': '实例小时',
'usage.metric.gbDays': 'GB·天',
'usage.metric.gbHours': 'GB·小时',
'usage.metric.activeUsers': '活跃用户',
'usage.metric.activeInstances': '活跃实例',
'usage.metric.activeStorage': '活跃存储',
'usage.metric.activeVolumes': '活跃存储卷',
'usage.metric.storageTypes': '存储类型',
'usage.metric.gpuHours.tip':
'实例运行时长按 GPU 数量加权:一个使用 N 个 GPU 的实例运行 H 小时记为 N × H GPU 小时。当每个实例仅使用单个 GPU 时,等于实例小时。',
'usage.metric.instanceHours.tip':
'所有实例运行时长的总和,与每个实例使用的 GPU 数量无关。一个实例运行 2 小时 = 2 实例小时。',
'usage.metric.gbDays.tip':
'存储容量随时间的积分,单位为 GB × 天:10 GB 保留 5 天 = 50 GB·天。(= GB·小时 ÷ 24',
'usage.metric.gbHours.tip':
'存储容量随时间的积分,单位为 GB × 小时:10 GB 保留 5 小时 = 50 GB·小时。',
// --- Resource usage: common table / labels ---
'usage.common.noData': '暂无数据',
'usage.common.unknown': '未知',
'usage.table.date': '日期',
'usage.table.name': '名称',
'usage.table.user': '用户',
'usage.table.users': '用户',
'usage.table.type': '类型',
'usage.table.instance': '实例',
'usage.table.instanceType': '实例类型',
'usage.table.instanceTypes': '实例类型',
'usage.table.instances': '实例',
'usage.table.capacity': '容量',
'usage.export.tableNamed': '导出表格数据 — {name}',
// --- Summary tab ---
'usage.summary.compute': '算力',
'usage.summary.tokensOverTime': 'Token 数趋势',
'usage.summary.gpuHoursOverTime': 'GPU 小时趋势',
'usage.summary.gbDaysOverTime': 'GB·天趋势',
// --- GPU Instances / Storage filters ---
'usage.filter.instance': '按实例查询',
'usage.filter.storage': '按存储查询',
// --- Resource events ---
'usage.events.resourceType': '资源类型',
'usage.events.eventType': '事件类型',
'usage.events.col.time': '时间',
'usage.events.col.resource': '资源',
'usage.events.col.event': '事件',
'usage.events.col.message': '消息',
'usage.events.resource.gpuInstance': 'GPU 实例',
'usage.events.resource.cpuInstance': 'CPU 实例',
'usage.events.type.created': '已创建',
'usage.events.type.deleted': '已删除',
'usage.events.type.started': '已启动',
'usage.events.type.stopped': '已停止',
'usage.events.type.updated': '已更新',
'usage.events.type.attached': '已挂载',
'usage.events.type.detached': '已卸载'
};
+6 -2
View File
@@ -100,7 +100,11 @@ const BarChart: React.FC<BarChartProps> = (props) => {
data: processedData,
type: 'bar',
barMaxWidth: 20,
barMinWidth: 8,
// Keep a small floor only — a large barMinWidth would force wide bars
// when there are many categories (e.g. hourly buckets), squeezing out
// the category gap so bars look fused. A low floor lets barCategoryGap
// win, so even dense hourly views keep visible gaps.
barMinWidth: 2,
barGap: '30%',
barCategoryGap: '50%',
...(stack === false || stack === undefined ? {} : { stack }),
@@ -122,7 +126,7 @@ const BarChart: React.FC<BarChartProps> = (props) => {
left: 0,
right: 0,
top: title ? 30 : 10,
bottom: 28,
bottom: 0,
containLabel: true
},
tooltip: {
@@ -1,36 +0,0 @@
import { currentOrganizationIdAtom } from '@/atoms/user';
import { Input as CInput } from '@gpustack/core-ui';
import { Form } from 'antd';
import type { NamePath } from 'antd/es/form/interface';
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
interface OwnerPrincipalIdFieldProps {
name?: NamePath;
}
// Pins `owner_principal_id` to the Org the caller is currently acting
// under. Cluster ownership is irrelevant here: cluster_access grants
// let one Org schedule on another Org's cluster, but the resource the
// caller creates still belongs to *their* Org, and the backend enforces
// `owner_principal_id == ctx.current_principal_id`.
const OwnerPrincipalIdField: React.FC<OwnerPrincipalIdFieldProps> = ({
name = 'owner_principal_id'
}) => {
const currentOrgId = useAtomValue(currentOrganizationIdAtom);
const form = Form.useFormInstance();
useEffect(() => {
if (currentOrgId != null) {
form.setFieldValue(name, currentOrgId);
}
}, [currentOrgId, name, form]);
return (
<Form.Item name={name} hidden>
<CInput.Input />
</Form.Item>
);
};
export default OwnerPrincipalIdField;
+36 -21
View File
@@ -19,6 +19,11 @@ interface PieChartProps {
totalLabel?: string;
}
// Donut sits on the left; a vertical legend hugs it just to the right and
// scrolls when there are many entries.
const CENTER_X = '32%';
const round2 = (n: number) => Math.round((Number(n) || 0) * 100) / 100;
const PieChart: React.FC<PieChartProps> = ({
data,
height = 300,
@@ -40,8 +45,20 @@ const PieChart: React.FC<PieChartProps> = ({
const options = useMemo(
() => ({
color: colors,
grid: {
left: 0,
right: 0,
top: 0,
bottom: 0,
containLabel: true
},
tooltip: {
trigger: 'item',
position: 'top',
// Keep the tooltip inside the chart box — the donut sits on the left, so
// a left-slice tooltip could otherwise spill out and get hidden behind
// the side menu.
confine: false,
backgroundColor: token.colorBgElevated,
borderColor: 'transparent',
formatter: (params: any) => {
@@ -51,7 +68,7 @@ const PieChart: React.FC<PieChartProps> = ({
<span class="tooltip-item-dot" style="border-radius:50%;background-color:${params.color};"></span>
<span class="tooltip-item-title">${params.name}</span>:
</span>
<span class="tooltip-value">${formatLargeNumber(params.value)}</span>
<span class="tooltip-value">${formatLargeNumber(round2(params.value))} (${params.percent}%)</span>
</span>
</div>`;
}
@@ -59,18 +76,21 @@ const PieChart: React.FC<PieChartProps> = ({
legend: {
type: 'scroll',
orient: 'vertical',
right: 0,
top: 18,
bottom: 18,
left: '56%',
top: 'middle',
itemWidth: 8,
itemHeight: 8,
itemGap: 12,
itemGap: 10,
// Long names are truncated in the legend; hovering shows the full name
// in a tooltip.
tooltip: { show: true },
textStyle: {
color: token.colorTextTertiary,
overflow: 'truncate',
width: 180
width: 120
},
pageTextStyle: { color: token.colorTextTertiary },
pageIconSize: 10,
pageIconColor: token.colorTextTertiary,
pageIconInactiveColor: token.colorTextDisabled,
data: data.map((item) => item.name),
@@ -79,24 +99,19 @@ const PieChart: React.FC<PieChartProps> = ({
series: [
{
type: 'pie',
radius: ['52%', '72%'],
center: ['34%', '50%'],
radius: ['54%', '78%'],
center: [CENTER_X, '50%'],
avoidLabelOverlap: true,
label: {
show: false
},
// No on-arc label — the percentage lives in the tooltip instead, so
// hovering shows value + percent in one place.
label: { show: false },
emphasis: {
label: {
show: true,
formatter: (params: any) => `${params.percent}%`,
fontSize: 16,
fontWeight: 600,
color: token.colorText
}
},
labelLine: {
show: false
show: false
},
scale: true
},
labelLine: { show: false },
data
}
]
@@ -137,7 +152,7 @@ const PieChart: React.FC<PieChartProps> = ({
<div
style={{
position: 'absolute',
left: '34%',
left: CENTER_X,
top: '50%',
transform: 'translate(-50%, -50%)',
display: 'flex',
@@ -98,10 +98,11 @@ const AddModal: React.FC<AddModalProps> = ({
expires_in: getExpireValue(data.expires_in)
};
const res = await createApisKey({ data: params });
onOk();
// if custom value
if (data.custom) {
return onOk();
onCancel();
return;
}
setAPIKeyValue(res.value);
@@ -111,6 +112,7 @@ const AddModal: React.FC<AddModalProps> = ({
const updateAPIKey = async (data: FormData) => {
await updateApisKey(currentData?.id as number, { data });
onOk();
onCancel();
};
const handleOnOk = async (formdata: FormData) => {
@@ -142,7 +144,7 @@ const AddModal: React.FC<AddModalProps> = ({
};
const handleDone = () => {
onOk();
onCancel();
};
const handleAfterOpenChange = (isOpen: boolean) => {
+72 -19
View File
@@ -1,15 +1,27 @@
// columns.ts
import { tableSorter } from '@/config/settings';
import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui';
import { DashboardOutlined } from '@ant-design/icons';
import {
AutoTooltip,
DropdownButtons,
IconFont,
icons
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { MenuProps, Tag } from 'antd';
import { MenuProps, Tag, Tooltip } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { ListItem } from '../config/types';
import type { APIKeyConfigAction } from '../plugin';
type APIKeyAction = Global.ActionItem<ListItem> & {
type APIKeyAction = Omit<Global.ActionItem<ListItem>, 'disabled' | 'label'> & {
// Per-row callback (function) is the host's default; placeholders use a
// plain boolean to render the menu item grayed out unconditionally.
disabled?: boolean | ((record: ListItem) => boolean);
// ReactNode allowed so disabled placeholders can render a Tooltip-
// wrapped label (paired with `locale: false`).
label: string | React.ReactNode;
onClick?: (record: ListItem) => void;
};
@@ -18,7 +30,10 @@ type RankedAction = APIKeyAction & { priority: number };
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem, item?: APIKeyAction) => void;
sortOrder: string[];
is_admin?: boolean;
// Reveal the Creator column to callers who can see other users' keys
// (platform admin or current-Org owner). Members only see their own
// keys, so the column would be redundant for them.
showCreator?: boolean;
configActions?: APIKeyConfigAction[];
// Dispatches the click for a plugin-contributed dropdown entry to the
// controller `useCreate()` returned for that entry.
@@ -28,7 +43,7 @@ interface ColumnsHookProps {
const useModelsColumns = ({
handleSelect,
sortOrder,
is_admin,
showCreator,
configActions = [],
onConfigAction
}: ColumnsHookProps): ColumnsType<ListItem> => {
@@ -65,10 +80,48 @@ const useModelsColumns = ({
onClick: (record: ListItem) => onConfigAction?.(a.key, record)
}));
return [...builtIns, ...fromPlugins].sort(
// Show disabled placeholders for IP Access Control / Quota Limit in
// the OSS build only — when the enterprise plugin contributes the
// real entry under the same key, skip the placeholder so the live
// action takes over. Keeps the dropdown's surface area consistent
// between editions while making the upgrade path discoverable.
const pluginKeys = new Set(configActions.map((a) => a.key));
const enterpriseTooltip = intl.formatMessage({
id: 'common.enterprise.feature'
});
const enterprisePlaceholder = (labelId: string): React.ReactNode => (
<Tooltip title={enterpriseTooltip} placement="left">
<span style={{ display: 'inline-block' }}>
{intl.formatMessage({ id: labelId })}
</span>
</Tooltip>
);
const placeholders: RankedAction[] = [];
if (!pluginKeys.has('ipConfig')) {
placeholders.push({
key: 'ipConfig',
label: enterprisePlaceholder('apikeys.button.ipConfig'),
locale: false,
icon: <IconFont type="icon-safe-ip" />,
disabled: true,
priority: 12
});
}
if (!pluginKeys.has('quotaLimit')) {
placeholders.push({
key: 'quotaLimit',
label: enterprisePlaceholder('quotaLimits.button.title'),
locale: false,
icon: <DashboardOutlined />,
disabled: true,
priority: 14
});
}
return [...builtIns, ...fromPlugins, ...placeholders].sort(
(a, b) => a.priority - b.priority
);
}, [configActions, onConfigAction]);
}, [intl, configActions, onConfigAction]);
return useMemo(() => {
return [
@@ -109,17 +162,6 @@ const useModelsColumns = ({
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'users.table.username' }),
dataIndex: 'user_name',
key: 'user_name',
hidden: !is_admin,
render: (text: string, record: ListItem) => (
<AutoTooltip ghost style={{ maxWidth: 200 }}>
{text || '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'apikeys.form.expiretime' }),
dataIndex: 'expires_at',
@@ -187,6 +229,17 @@ const useModelsColumns = ({
<AutoTooltip ghost>{text}</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.creator' }),
dataIndex: 'user_name',
key: 'user_name',
hidden: !showCreator,
render: (text: string) => (
<AutoTooltip ghost style={{ maxWidth: 200 }}>
{text || '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
@@ -216,7 +269,7 @@ const useModelsColumns = ({
)
}
];
}, [intl, is_admin, handleSelect, actionList]);
}, [intl, showCreator, handleSelect, actionList]);
};
export default useModelsColumns;
+19 -15
View File
@@ -3,9 +3,8 @@ import { PaginationKey } from '@/config/settings';
import type { PageActionType } from '@/config/types';
import useTableFetch from '@/hooks/use-table-fetch';
import useQueryUserList from '@/pages/users/services/use-query-user-list';
import { useModel } from '@@/plugin-model';
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useAccess, useIntl } from '@umijs/max';
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
import { ConfigProvider, Table } from 'antd';
import _ from 'lodash';
@@ -22,8 +21,12 @@ import {
} from './plugin';
const APIKeys: React.FC = () => {
const { initialState } = useModel('@@initialState');
const currentUser = initialState?.currentUser;
const access = useAccess();
// `canSeeOrgAdmin` widens to Org owners in the enterprise build —
// mirrors the BE's "platform admin OR current-Org owner" gate on
// listing every key in scope. Personal/member users continue to see
// only their own keys (`user_id: undefined`).
const canSeeAllKeys = !!access.canSeeOrgAdmin;
const {
TABLE_SORT_DIRECTIONS,
dataSource,
@@ -45,7 +48,7 @@ const APIKeys: React.FC = () => {
deleteAPI: deleteApisKey,
contentForDelete: 'apikeys.table.apikeys',
defaultQueryParams: {
user_id: currentUser?.is_admin ? '*' : undefined
user_id: canSeeAllKeys ? '*' : undefined
}
});
const {
@@ -94,8 +97,15 @@ const APIKeys: React.FC = () => {
});
useEffect(() => {
// `scope=current_org` limits the creator dropdown to members of the
// active Org. Without it, an Org owner sees every user in the
// system — most of whom can't own a key visible in this list, so
// selecting them produces an empty result. The BE drops the
// param silently when the request has no Org context, so callers
// without an Org keep the full-directory behavior.
fetchUserData({
page: -1
page: -1,
scope: 'current_org'
});
return () => {
cancelUserRequest();
@@ -123,12 +133,6 @@ const APIKeys: React.FC = () => {
const handleModalOk = async () => {
try {
await fetchData();
setOpenAddModal({
open: false,
title: '',
action: PageAction.CREATE,
currentData: null
});
} catch (error) {
// do nothing
}
@@ -198,7 +202,7 @@ const APIKeys: React.FC = () => {
const columns = useKeysColumns({
handleSelect: onSelect,
sortOrder,
is_admin: currentUser?.is_admin,
showCreator: canSeeAllKeys,
configActions,
onConfigAction: handleConfigAction
});
@@ -209,10 +213,10 @@ const APIKeys: React.FC = () => {
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={currentUser?.is_admin}
showSelect={canSeeAllKeys}
selectOptions={userList}
select={{ showSearch: { optionFilterProp: 'label' } }}
selectHolder={intl.formatMessage({ id: 'models.table.filterByName' })}
selectHolder={intl.formatMessage({ id: 'common.filter.byCreator' })}
buttonText={intl.formatMessage({ id: 'apikeys.button.create' })}
handleSearch={handleSearch}
handleDeleteByBatch={handleDeleteBatch}
+3 -1
View File
@@ -276,7 +276,9 @@ const VersionsForm: React.FC<AddModalProps> = ({
key={name}
defaultOpen
styles={{
body: collapseKey.has(name) ? { padding: 16 } : {},
body: collapseKey.has(name)
? { paddingInline: 16, paddingBlock: '16px 0' }
: {},
content: { paddingTop: 0 },
header: {
backgroundColor: 'unset'
@@ -84,13 +84,6 @@ const Instance: React.FC = () => {
}),
children: renderParams(instanceData?.backend_parameters || [])
},
{
key: '1-1',
label: intl.formatMessage({
id: 'models.instance.params.autoInjected'
}),
children: renderParams(instanceData?.injected_backend_parameters || [])
},
{
key: '3',
label: intl.formatMessage({ id: 'benchmark.detail.kvCache' }),
+9 -22
View File
@@ -1,4 +1,3 @@
import { modelCategoriesMap } from '@/pages/llmodels/config';
import { SearchOutlined } from '@ant-design/icons';
import { BaseSelect, FilterForm } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
@@ -23,17 +22,13 @@ interface FilterFormContentProps {
initialValues?: any;
open?: boolean;
ref?: any;
modelList?: Global.BaseOption<number, { categories: string[] }>[];
onClose?: () => void;
onClear?: () => void;
onValuesChange: (values: any) => void;
}
const FilterFormContent: React.FC<FilterFormContentProps> = forwardRef(
(
{ initialValues, onClose, onClear, onValuesChange, open, modelList },
ref
) => {
({ initialValues, onClose, onClear, onValuesChange, open }, ref) => {
const intl = useIntl();
const filterRef = useRef<any>(null);
@@ -41,15 +36,6 @@ const FilterFormContent: React.FC<FilterFormContentProps> = forwardRef(
onValuesChange?.(allValues);
};
const modelOptions = modelList
?.filter((item) => {
return item.categories?.includes(modelCategoriesMap.llm);
})
.map((item) => ({
label: item.label,
value: item.label
}));
useImperativeHandle(ref, () => ({
reset: () => {
filterRef.current?.reset();
@@ -116,16 +102,17 @@ const FilterFormContent: React.FC<FilterFormContentProps> = forwardRef(
</Form.Item>
<Label>{intl.formatMessage({ id: 'benchmark.table.model' })}</Label>
<Form.Item noStyle name="model_name">
{/* <PillButtonGroup
options={modelCategories.filter((item) => item.value)}
></PillButtonGroup> */}
<BaseSelect
allowClear
<Input
prefix={
<SearchOutlined
style={{ color: 'var(--ant-color-text-placeholder)' }}
></SearchOutlined>
}
placeholder={intl.formatMessage({
id: 'benchmark.table.filter.bymodel'
})}
options={modelOptions}
></BaseSelect>
allowClear
></Input>
</Form.Item>
</Content>
</FilterForm>
+60 -18
View File
@@ -1,5 +1,7 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { modelNameReg, PageAction } from '@/config';
import { ClusterStatusValueMap } from '@/pages/cluster-management/config';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import { useBenchmarkTargetInstance } from '@/pages/llmodels/hooks/use-run-benchmark';
import {
Input as CInput,
@@ -8,7 +10,7 @@ import {
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import React, { useEffect } from 'react';
import React, { useEffect, useMemo } from 'react';
import { useFormContext } from '../config/form-context';
import { FormData } from '../config/types';
import ModelInstanceForm from './model-instance';
@@ -20,31 +22,70 @@ const BasicForm: React.FC = () => {
const { action, open, clusterList } = useFormContext();
const { benchmarkTargetInstance } = useBenchmarkTargetInstance();
// `organization_id` is owned by the create-scope picker slot (admin "All"
// view). When a platform admin targets an org (and we're not pre-filling
// from a launched instance), fetch *that org's* clusters directly — the
// request header carries the chosen org — rather than filtering the
// page-level list, which is fetched once and may not include the org's
// clusters. The benchmark's owner is derived from the chosen cluster.
const scopeOrgId = Form.useWatch('organization_id', form);
const orgScoped = scopeOrgId != null && !benchmarkTargetInstance.cluster_id;
const {
clusterList: scopedClusterList,
fetchClusterList: fetchScopedClusters
} = useQueryClusterList();
// The org-scoped fetch returns clusters *visible* to the org — its own plus
// any granted via cluster_access (and the platform principal can see a lot).
// A benchmark's owner is the chosen cluster's owner, so keep only clusters
// actually owned by the selected org. The owner filter also keeps this
// correct if the request header isn't applied (fetch falls back to all).
const effectiveClusterList = useMemo(() => {
if (!orgScoped) {
return clusterList || [];
}
return (scopedClusterList || []).filter(
(item: any) => item.owner_principal_id === scopeOrgId
);
}, [orgScoped, scopedClusterList, clusterList, scopeOrgId]);
useEffect(() => {
if (action === PageAction.CREATE && orgScoped) {
fetchScopedClusters({ page: -1 });
}
}, [scopeOrgId, orgScoped, action]);
useEffect(() => {
if (action !== PageAction.CREATE) {
return;
}
const clusterValue = (item: any) => item?.value ?? item?.id;
const initClusterId = (list: any[]) => {
// Find default cluster
const defaultCluster = list?.find((item) => item.is_default);
if (defaultCluster) {
return defaultCluster.id;
return clusterValue(defaultCluster);
}
const cluster_id =
list?.find((item) => item.state === ClusterStatusValueMap.Ready)?.id ||
list?.[0]?.id;
return cluster_id;
};
if (
clusterList &&
clusterList?.length > 0 &&
action === PageAction.CREATE
) {
form.setFieldValue(
'cluster_id',
benchmarkTargetInstance.cluster_id || initClusterId(clusterList)
const readyCluster = list?.find(
(item) => item.state === ClusterStatusValueMap.Ready
);
return clusterValue(readyCluster) ?? clusterValue(list?.[0]);
};
const current = form.getFieldValue('cluster_id');
const stillValid = effectiveClusterList.some(
(item: any) => clusterValue(item) === current
);
if (stillValid) {
return;
}
}, [form, action, clusterList, benchmarkTargetInstance]);
// Re-pick within the (org-scoped) list. When the chosen org owns no
// clusters this resolves to undefined, clearing a stale cross-org cluster
// instead of leaving it selected.
form.setFieldValue(
'cluster_id',
benchmarkTargetInstance.cluster_id || initClusterId(effectiveClusterList)
);
}, [form, action, effectiveClusterList, benchmarkTargetInstance]);
return (
<>
@@ -67,6 +108,7 @@ const BasicForm: React.FC = () => {
required
></CInput.Input>
</Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
<Form.Item<FormData>
name="cluster_id"
rules={[
@@ -78,7 +120,7 @@ const BasicForm: React.FC = () => {
>
<SealSelect
disabled={action === PageAction.EDIT}
options={clusterList}
options={effectiveClusterList}
label={intl.formatMessage({ id: 'clusters.title' })}
required
></SealSelect>
+22 -2
View File
@@ -10,7 +10,7 @@ import { useQueryModelList } from '@/pages/llmodels/services/use-query-model-lis
import { Cascader as SealCascader, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form, Tooltip } from 'antd';
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import { useFormContext } from '../config/form-context';
import { FormData } from '../config/types';
@@ -45,6 +45,11 @@ const ModelInstanceForm: React.FC = () => {
const form = Form.useFormInstance();
const { getRuleMessage } = useAppUtils();
const { action, open } = useFormContext();
// Owned by the create-scope picker slot (admin "All" view). The model list
// is tenant-scoped by the request header, so refetch it when the org
// changes so only the chosen org's models/instances are offered.
const scopeOrgId = Form.useWatch('organization_id', form);
const prevScopeRef = useRef<number | null | undefined>(undefined);
const [modelList, setModelList] = React.useState<any[]>([]);
const {
loading: modelLoading,
@@ -158,14 +163,29 @@ const ModelInstanceForm: React.FC = () => {
useEffect(() => {
if (open && action === PageAction.CREATE) {
// On a genuine org change, clear the stale (possibly cross-org) target
// so the refetched list re-selects within the new org.
if (
prevScopeRef.current !== undefined &&
prevScopeRef.current !== scopeOrgId
) {
form.setFieldsValue({
model_name: undefined,
model_id: undefined,
model_instance_name: undefined,
model_instance: undefined
});
}
prevScopeRef.current = scopeOrgId;
initModelInstance();
}
if (!open) {
prevScopeRef.current = undefined;
cancelModelRequest();
cancelInstanceRequest();
clearBenchmarkTargetInstance();
}
}, [open, benchmarkTargetInstance, action]);
}, [open, benchmarkTargetInstance, action, scopeOrgId]);
return (
<Form.Item<FormData>
@@ -27,7 +27,7 @@ const useBenchmarkColumns = (params: {
dataIndex: 'name',
sorter: tableSorter(1),
render: (text: string, record) => (
<AutoTooltip ghost minWidth={20}>
<AutoTooltip ghost minWidth={20} title={text}>
<Typography.Link onClick={() => onCellClick?.(record, 'name')}>
{text}
</Typography.Link>
+2 -1
View File
@@ -113,7 +113,8 @@ export async function queryClusterList(
return request<Global.PageResponse<ClusterListItem>>(`${CLUSTERS_API}`, {
method: 'GET',
params,
cancelToken: options?.token
cancelToken: options?.token,
skipErrorHandler: options?.skipErrorHandler
});
}
@@ -62,13 +62,15 @@ const ClusterDetailModal = () => {
key: 'workers',
label: `Workers`,
icon: <IconFont type="icon-resources" />,
children: <WorkerList clusterId={Number(id)} />
children: (
<WorkerList clusterId={Number(id)} source="clusterDetail" />
)
},
{
key: 'gpus',
label: `GPUs`,
icon: <IconFont type="icon-gpu1" />,
children: <GPUList clusterId={Number(id)} />
children: <GPUList clusterId={Number(id)} source="clusterDetail" />
},
...extraTabs
]}
+2 -2
View File
@@ -271,7 +271,7 @@ const Clusters: React.FC = () => {
const handleOnCell = useMemoizedFn((record: ClusterListItem, dataIndex) => {
if (dataIndex === 'name') {
navigate(
`/cluster-management/clusters/detail?id=${record.id}&name=${record.name}&page=clusters`
`/resources/clusters/detail?id=${record.id}&name=${record.name}&page=clusters`
);
}
});
@@ -461,7 +461,7 @@ const Clusters: React.FC = () => {
<DeleteModal ref={modalRef}></DeleteModal>
<ClusterModal
title={intl.formatMessage({
id: 'menu.clusterManagement.clusterCreate'
id: 'menu.resources.clusterCreate'
})}
open={clusterModalStatus.open}
providerHint={pendingProviderHint}
@@ -1,13 +1,22 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { FormDrawer } from '@gpustack/core-ui';
import React, { useRef } from 'react';
import { ProviderType } from '../config';
import { ExclamationCircleFilled } from '@ant-design/icons';
import { AlertBlockInfo, FormDrawer, ModalFooter } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import React, { useRef, useState } from 'react';
import { ProviderType, ProviderValueMap } from '../config';
import {
ClusterFormData as FormData,
ClusterListItem as ListItem
} from '../config/types';
import ClusterForm from './cluster-form';
const ModalFooterStyle = {
padding: '16px 24px 8px',
display: 'flex',
justifyContent: 'flex-end'
};
type AddModalProps = {
title: string;
action: PageActionType;
@@ -28,7 +37,12 @@ const AddCluster: React.FC<AddModalProps> = ({
onOk,
onCancel
}) => {
const intl = useIntl();
const form = useRef<any>(null);
// Whether the user has changed any k8s_options field. Lifted from ClusterForm
// so the "re-run registration" notice can sit in the drawer footer, above the
// Save/Cancel buttons (mirrors the model edit interaction).
const [k8sOptionsChanged, setK8sOptionsChanged] = useState<boolean>(false);
const handleSubmit = () => {
form.current?.submit();
@@ -53,6 +67,27 @@ const AddCluster: React.FC<AddModalProps> = ({
onCancel={handleCancel}
onSubmit={handleSubmit}
width={710}
footer={
<>
{action === PageAction.EDIT &&
provider === ProviderValueMap.Kubernetes &&
k8sOptionsChanged && (
<AlertBlockInfo
type="warning"
style={{ margin: '8px 24px 0' }}
icon={<ExclamationCircleFilled />}
message={intl.formatMessage({
id: 'clusters.edit.k8sOptions.changed.tip'
})}
></AlertBlockInfo>
)}
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
style={ModalFooterStyle}
></ModalFooter>
</>
}
>
<ClusterForm
ref={form}
@@ -61,6 +96,7 @@ const AddCluster: React.FC<AddModalProps> = ({
action={action}
currentData={currentData}
onFinish={handleOk}
onK8sOptionsChange={setK8sOptionsChanged}
/>
</FormDrawer>
);
@@ -67,6 +67,7 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
);
const { update, summary, register } = useSummaryStatus();
const { addedCount, createModelsChunkRequest } = useAddWorkerMessage();
console.log('addedCount=========', addedCount);
const onToggle = (open: boolean, key: string) => {
setCollapseKey(open ? new Set([key]) : new Set());
@@ -81,6 +82,7 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
setCollapseKey(new Set([stepList[0]]));
}, [stepList]);
console.log('actionSource=========', actionSource, registrationInfo);
React.useEffect(() => {
// this effect is only triggered when used in cluster create page inner
if (actionSource === 'page' && registrationInfo?.cluster_id) {
@@ -164,6 +166,7 @@ const AddWorkerSteps: React.FC<AddWorkerProps> = (props) => {
</>
)}
{actionSource === 'modal' && (
// show in cluster create page inner
<AddedMessage addedCount={addedCount}></AddedMessage>
)}
</Container>
@@ -75,6 +75,7 @@ const AddWorker: React.FC<AddWorkerProps> = (props) => {
});
const handleOnClusterChange = async (value: number, row?: any) => {
console.log('handleOnClusterChange value, row=========', value, row);
try {
createModelsChunkRequest({ cluster_id: value });
axiosTokenRef.current?.cancel?.();
@@ -121,6 +122,8 @@ const AddWorker: React.FC<AddWorkerProps> = (props) => {
);
};
console.log('addedCount 1========', addedCount);
return (
<GSDrawer
title={title}
@@ -36,9 +36,7 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
// for the Kubernetes provider; other providers stay single-select.
const multiCapable = provider === ProviderValueMap.Kubernetes;
const [selectedKeys, setSelectedKeys] = useState<string[]>([
GPUDriverMap.NVIDIA
]);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
// No vendor is gated anymore — every card stays selectable.
const availableKeys = undefined;
@@ -49,18 +47,6 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
{}
);
// Push current selection into the shared summary so consumers
// (K8sRunCommand, CheckEnvironment, VendorNotes) can read it.
useEffect(() => {
const primary = selectedKeys[0] || '';
updateField('currentGPU', primary);
updateField('selectedGPUs', selectedKeys);
updateField(
'workerCommand',
primary ? buildWorkerCommand(primary, itemMetaRef.current[primary]) : null
);
}, [selectedKeys]);
useEffect(() => {
const unregister1 = registerField('currentGPU');
const unregister2 = registerField('workerCommand');
@@ -72,6 +58,29 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
};
}, []);
const buildSelectedKeys = (key: string) => {
const prev = [...selectedKeys];
const has = prev.includes(key);
if (has) {
// Clicking a selected card always toggles it off.
return prev.filter((v) => v !== key);
}
// K8s clusters support multiple GPU runtimes, so accumulate picks.
// Other providers stay single-select and replace the current pick.
if (multiCapable) return [...prev, key];
return [key];
};
const updateFieldsOnSelect = (keys: string[]) => {
const primary = keys[0] || '';
updateField('currentGPU', primary);
updateField('selectedGPUs', keys);
updateField(
'workerCommand',
primary ? buildWorkerCommand(primary, itemMetaRef.current[primary]) : null
);
};
const handleSelect = (key: string, item: any) => {
if (item) {
itemMetaRef.current[key] = {
@@ -79,19 +88,25 @@ const SelectVendor: React.FC<AddWorkerStepProps> = ({ disabled }) => {
link: item.link
};
}
setSelectedKeys((prev) => {
const has = prev.includes(key);
if (has) {
// Clicking a selected card always toggles it off.
return prev.filter((v) => v !== key);
}
// K8s clusters support multiple GPU runtimes, so accumulate picks.
// Other providers stay single-select and replace the current pick.
if (multiCapable) return [...prev, key];
return [key];
});
const keys = buildSelectedKeys(key);
updateFieldsOnSelect(keys);
setSelectedKeys(keys);
};
useEffect(() => {
// init a default selection
handleSelect(GPUDriverMap.NVIDIA, {
label: 'NVIDIA',
hiddenTitle: true,
value: GPUDriverMap.NVIDIA,
description: '',
key: GPUDriverMap.NVIDIA,
locale: false,
notes: AddWorkerDockerNotes[GPUDriverMap.NVIDIA],
link: 'https://docs.gpustack.ai/latest/installation/requirements/#nvidia-gpu'
});
}, []);
return (
<StepCollapse
disabled={disabled}
@@ -16,6 +16,7 @@ interface StepItemProps {
const Box = styled.div`
border: 1px solid var(--ant-color-border);
border-radius: 4px;
overflow: hidden;
&.step-collapse-open {
border-color: var(--ant-color-primary);
}
@@ -53,7 +53,7 @@ const NotFoundCredentialContent: React.FC = () => {
};
return (
<Link to={'/cluster-management/credentials'} onClick={handleOnClick}>
<Link to={'/resources/credentials'} onClick={handleOnClick}>
{intl.formatMessage({ id: 'clusters.button.addCredential' })}
</Link>
);
@@ -11,15 +11,24 @@ import {
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { useAtomValue } from 'jotai';
import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState
} from 'react';
import { ProviderType, ProviderValueMap } from '../config';
import { FormContext } from '../config/form-context';
import {
ClusterFormData as FormData,
ClusterListItem as ListItem
} from '../config/types';
import AdvanceConfig from '../step-forms/advance-config';
import CloudProvider from './cloud-provider-form';
import K8sPodSpec from './k8s-pod-spec';
import K8sAdvancedOptions, {
ClusterTypeSelector,
K8sOptionsChangeWatcher
} from './k8s-pod-spec';
type AddModalProps = {
action: PageActionType;
@@ -27,18 +36,27 @@ type AddModalProps = {
provider: ProviderType;
credentialList: Global.BaseOption<number>[];
onFinish: (values: FormData) => void;
// Reports whether the user has changed any k8s_options field, so the parent
// can show the "re-run registration" notice in the footer.
onK8sOptionsChange?: (changed: boolean) => void;
ref?: any;
};
const ClusterForm: React.FC<AddModalProps> = forwardRef(
({ action, provider, currentData, credentialList, onFinish }, ref) => {
(
{
action,
provider,
currentData,
credentialList,
onFinish,
onK8sOptionsChange
},
ref
) => {
const [form] = Form.useForm();
const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]);
// K8s deployment options is its own top-level section (sibling of Advanced),
// open by default so the fields are visible without an extra click.
const [k8sActiveKey, setK8sActiveKey] = React.useState<string[]>([
'k8sOptions'
]);
const [submitAttempted, setSubmitAttempted] = useState(false);
const advanceConfigRef = React.useRef<any>(null);
const systemConfig = useAtomValue(systemConfigAtom);
@@ -59,20 +77,8 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
}
}, [activeKey, action]);
// The backend models the optional k8s_options string knobs as
// Optional[str] and treats null/absent as "use the server default" or
// "no auth". Coerce empty form values to null before sending so a blank
// input is unambiguous rather than an empty string that defeats fallbacks.
const normalizeOutgoing = (values: any): any => {
const base: any = { ...values };
// Top-level cluster field shared by Docker and K8s. Trim then coerce a
// blank input to null so clearing it on edit (or a whitespace-only
// value) falls back to the server default rather than persisting an
// empty string.
if (base.system_default_container_registry !== undefined) {
base.system_default_container_registry =
base.system_default_container_registry?.trim() || null;
}
const opts = base.k8s_options;
if (!opts) return base;
@@ -88,32 +94,14 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
}));
}
next.operatorImage = opts.operatorImage || null;
next.namespace = opts.namespace || null;
// Presence of gpuInstanceOptions is the enable flag; keep it only when
// the toggle left an object behind, coercing a blank address to null.
if (opts.gpuInstanceOptions) {
next.gpuInstanceOptions = {
gpuInstancesAccessStaticAddress:
opts.gpuInstanceOptions.gpuInstancesAccessStaticAddress || null
};
}
return { ...base, k8s_options: next };
};
const handleOnFinish = (_values: FormData) => {
const handleOnFinish = (values: FormData) => {
const workerConfig = yaml2Json(advanceConfigRef.current?.getYamlValue());
// antd's onFinish only delivers values for registered Form.Items.
// Spreading those on top of `getFieldsValue(true)` clobbers nested
// objects (e.g. `k8s_options` would lose values set via setFieldValue),
// so we go straight to the full store.
const fullValues = form.getFieldsValue(true);
onFinish(
normalizeOutgoing({
...fullValues,
...values,
worker_config: {
...workerConfig
}
@@ -203,11 +191,12 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
};
},
validateFields: async () => {
// Run validation first to display any field errors. Then read the
// FULL store via `getFieldsValue(true)` so values that were set via
// setFieldValue on non-registered paths are still included in what we
// hand to the API.
await form.validateFields();
try {
await form.validateFields();
} catch (e) {
setSubmitAttempted(true);
throw e;
}
const values = form.getFieldsValue(true);
const workerConfig = yaml2Json(
@@ -223,108 +212,109 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
}
}));
const handleOnFinishFailed = () => {
setSubmitAttempted(true);
};
return (
<Form
name="clusterForm"
form={form}
onFinish={handleOnFinish}
preserve={false}
scrollToFirstError={true}
initialValues={currentData}
>
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: intl.formatMessage(
{ id: 'common.form.rule.input' },
{
name: intl.formatMessage({ id: 'common.table.name' })
}
)
}
]}
<FormContext.Provider value={{ submitAttempted }}>
<Form
name="clusterForm"
form={form}
onFinish={handleOnFinish}
onFinishFailed={handleOnFinishFailed}
preserve={false}
scrollToFirstError={true}
initialValues={currentData}
>
<CInput.Input
label={intl.formatMessage({ id: 'common.table.name' })}
required
trim={false}
></CInput.Input>
</Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
{provider === ProviderValueMap.DigitalOcean && (
<CloudProvider
provider={provider}
action={action}
credentialID={currentData?.credential_id}
credentialList={credentialList}
></CloudProvider>
)}
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: intl.formatMessage(
{ id: 'common.form.rule.input' },
{
name: intl.formatMessage({ id: 'common.table.name' })
}
)
}
]}
>
<CInput.Input
label={intl.formatMessage({ id: 'common.table.name' })}
required
trim={false}
></CInput.Input>
</Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
{provider === ProviderValueMap.DigitalOcean && (
<CloudProvider
provider={provider}
action={action}
credentialID={currentData?.credential_id}
credentialList={credentialList}
></CloudProvider>
)}
<Form.Item<FormData>
name="description"
rules={[{ required: false }]}
style={{ marginBottom: 8 }}
>
<SealTextArea
autoSize={{ minRows: 2, maxRows: 4 }}
label={intl.formatMessage({ id: 'common.table.description' })}
></SealTextArea>
</Form.Item>
<Form.Item<FormData>
name="description"
rules={[{ required: false }]}
// For Kubernetes the cluster type selector follows directly, so
// fall back to the default item margin (matching the name field)
// to keep the description spacing symmetric; other providers keep
// the tighter gap before the advanced panel.
style={{
marginBottom:
provider === ProviderValueMap.Kubernetes ? undefined : 8
}}
>
<SealTextArea
scaleSize
label={intl.formatMessage({ id: 'common.table.description' })}
></SealTextArea>
</Form.Item>
{provider === ProviderValueMap.Kubernetes && <ClusterTypeSelector />}
{provider === ProviderValueMap.Kubernetes && (
<CollapsePanel
accordion={false}
activeKey={k8sActiveKey}
onChange={(keys) =>
setK8sActiveKey(Array.isArray(keys) ? keys : [keys])
}
activeKey={activeKey}
onChange={handleOnCollapseChange}
items={[
{
key: 'k8sOptions',
label: intl.formatMessage({ id: 'clusters.k8sOptions.title' }),
key: 'advanceConfig',
label: intl.formatMessage({ id: 'resources.form.advanced' }),
forceRender: true,
children: (
// Key by cluster id so the section fully remounts when the
// active cluster changes. GpuInstanceOptionsForm seeds its
// local state from initialValue only once (initializedRef),
// so without a remount a reused form instance could carry a
// previous cluster's GPU instance config into the next one.
<K8sPodSpec
key={currentData?.id ?? 'new'}
action={action}
initialGpuInstanceOptions={
currentData?.k8s_options?.gpuInstanceOptions
}
></K8sPodSpec>
<>
{provider === ProviderValueMap.Kubernetes && (
<K8sAdvancedOptions
key={currentData?.id ?? 'new'}
action={action}
></K8sAdvancedOptions>
)}
<AdvanceConfig
action={action}
provider={provider}
currentData={currentData}
ref={advanceConfigRef}
></AdvanceConfig>
</>
)
}
]}
></CollapsePanel>
)}
<CollapsePanel
accordion={false}
activeKey={activeKey}
onChange={handleOnCollapseChange}
items={[
{
key: 'advanceConfig',
label: intl.formatMessage({ id: 'resources.form.advanced' }),
forceRender: true,
children: (
<AdvanceConfig
action={action}
provider={provider}
currentData={currentData}
ref={advanceConfigRef}
></AdvanceConfig>
)
}
]}
></CollapsePanel>
</Form>
{provider === ProviderValueMap.Kubernetes && onK8sOptionsChange && (
<K8sOptionsChangeWatcher
action={action}
currentData={currentData}
onChange={onK8sOptionsChange}
/>
)}
</Form>
</FormContext.Provider>
);
}
);
@@ -0,0 +1,81 @@
import { Input as CInput } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Divider } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { ImageCredential } from '../config/types';
const Wrapper = styled.div`
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
.row {
display: flex;
gap: 12px;
> * {
flex: 1;
min-width: 0;
}
}
`;
interface CredentialItemProps {
item: ImageCredential;
validated: boolean;
index: number;
onChange: (partial: Partial<ImageCredential>) => void;
}
const CredentialItem: React.FC<CredentialItemProps> = ({
item,
index,
validated,
onChange
}) => {
const intl = useIntl();
const registryEmpty = !item.registry?.trim();
const registryStatus =
validated && registryEmpty ? ('error' as const) : ('success' as const);
return (
<Wrapper>
{index !== 0 && (
<Divider style={{ marginBlock: 8 }} variant="dashed"></Divider>
)}
<CInput.Input
required
status={registryStatus}
value={item.registry}
onChange={(e) => onChange({ registry: e.target.value })}
label={intl.formatMessage({
id: 'clusters.imageCredentials.registry'
})}
/>
<div className="row">
<CInput.Input
value={item.username}
status="success"
onChange={(e) => onChange({ username: e.target.value })}
label={intl.formatMessage({
id: 'clusters.imageCredentials.username'
})}
/>
<CInput.Password
status="success"
value={item.password}
onChange={(e) => onChange({ password: e.target.value })}
label={intl.formatMessage({
id: 'clusters.imageCredentials.password'
})}
/>
</div>
</Wrapper>
);
};
export default CredentialItem;
@@ -19,7 +19,7 @@ const Container = styled.div`
height: 168px;
.left {
padding: 16px 24px;
width: 160px;
width: 124px;
display: flex;
flex-direction: column;
align-items: center;
@@ -143,17 +143,23 @@ const ClusterBasic: React.FC<{ clusterId: number }> = ({ clusterId }) => {
<span className="value">
{clusterDetail.ready_workers}/{clusterDetail.workers}
</span>
<span className="label">Workers</span>
<span className="label">
{intl.formatMessage({ id: 'resources.nodes' })}
</span>
</div>
<div className="item">
<IconFont type="icon-rocket-launch-fill"></IconFont>
<span className="value">{clusterDetail.models}</span>
<span className="label">Deployments</span>
<span className="label">
{intl.formatMessage({ id: 'clusters.table.deployments' })}
</span>
</div>
<div className="item">
<IconFont type="icon-gpu"></IconFont>
<span className="value">{clusterDetail.gpus}</span>
<span className="label">GPUs</span>
<span className="label">
{intl.formatMessage({ id: 'menu.resources.gpus' })}
</span>
</div>
</Resources>
</div>
@@ -1,4 +1,5 @@
import { CardWrapper } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Col, Progress, Row, Tag } from 'antd';
import { round } from 'lodash';
import React, { useEffect } from 'react';
@@ -15,7 +16,7 @@ const Container = styled.div`
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 500;
font-weight: 400;
color: var(--ant-color-text-tertiary);
}
@@ -38,6 +39,7 @@ const Container = styled.div`
const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
const { systemLoad, fetchClusterSystemLoad } = useClusterSystemLoad();
const intl = useIntl();
useEffect(() => {
if (clusterId) {
@@ -74,14 +76,15 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
};
console.log('systemLoad', systemLoad);
return (
<Row gutter={16} style={{ marginTop: 24 }}>
<Col span={6}>
<CardWrapper style={{ padding: '16px', height: 120 }}>
<Container>
<div className="title">
<span>GPU Utilization</span>
<span>
{intl.formatMessage({ id: 'dashboard.gpuutilization' })}
</span>
</div>
<div className="value-wrapper">
<div className="value">
@@ -101,14 +104,16 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
<CardWrapper style={{ padding: '16px', height: 120 }}>
<Container>
<div className="title">
<span>VRAM Utilization</span>
<span>
{intl.formatMessage({ id: 'dashboard.vramutilization' })}
</span>
</div>
<div className="value-wrapper">
<div className="value">{`${round(systemLoad.current.vram, 1)}%`}</div>
<div className="chart">
{renderStepsProgress(round(systemLoad.current.vram, 1), {
color: 'purple',
text: 'VRAM'
text: intl.formatMessage({ id: 'dashboard.vram' })
})}
</div>
</div>
@@ -119,7 +124,9 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
<CardWrapper style={{ padding: '16px', height: 120 }}>
<Container>
<div className="title">
<span>CPU Utilization</span>
<span>
{intl.formatMessage({ id: 'dashboard.cpuutilization' })}
</span>
</div>
<div className="value-wrapper">
<div className="value">
@@ -139,7 +146,9 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
<CardWrapper style={{ padding: '16px', height: 120 }}>
<Container>
<div className="title">
<span>Memo Utilization</span>
<span>
{intl.formatMessage({ id: 'dashboard.memoryutilization' })}
</span>
</div>
<div className="value-wrapper">
<div className="value">
@@ -148,7 +157,7 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
<div className="chart">
{renderStepsProgress(round(systemLoad.current.ram, 1), {
color: 'green',
text: 'RAM'
text: intl.formatMessage({ id: 'dashboard.memory' })
})}
</div>
</div>
@@ -0,0 +1,98 @@
import { MetadataList, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import React, { useEffect } from 'react';
import { useFormContext } from '../config/form-context';
import { ImageCredential as ImageCredentialType } from '../config/types';
import imgCredentialStyle from '../styles/img-credential.less';
import CredentialItem from './credential-item';
const FIELD_PATH = ['k8s_options', 'imageCredentials'];
const ImageCredential: React.FC = () => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
const { submitAttempted } = useFormContext();
const validated = !!submitAttempted;
const form = Form.useFormInstance();
const credentials: ImageCredentialType[] =
Form.useWatch(FIELD_PATH, form) || [];
const updateList = (list: ImageCredentialType[]) => {
form.setFieldValue(FIELD_PATH, list);
};
useEffect(() => {
if (validated) {
form.validateFields([FIELD_PATH]).catch(() => {});
}
}, [credentials, validated]);
const handleAdd = () => {
updateList([...credentials, { registry: '', username: '', password: '' }]);
};
const handleDelete = (index: number) => {
const next = [...credentials];
next.splice(index, 1);
updateList(next);
};
const handleChange = (
index: number,
partial: Partial<ImageCredentialType>
) => {
const next = [...credentials];
next[index] = { ...next[index], ...partial };
updateList(next);
};
return (
<div className={imgCredentialStyle.container}>
<Form.Item
name={FIELD_PATH}
style={{ marginTop: 24 }}
rules={[
{
validator: async (_, value: ImageCredentialType[]) => {
if (!value?.length) return;
const hasMissingRegistry = value.some(
(item) => !item?.registry?.trim()
);
if (hasMissingRegistry) {
throw new Error(
getRuleMessage('input', 'clusters.imageCredentials.registry')
);
}
}
}
]}
>
<MetadataList
label={intl.formatMessage({ id: 'clusters.imageCredentials.title' })}
btnText={intl.formatMessage({ id: 'clusters.imageCredentials.add' })}
dataList={credentials}
onAdd={handleAdd}
onDelete={handleDelete}
styles={{
delBtn: {
marginTop: 44
}
}}
>
{(item: ImageCredentialType, index: number) => (
<CredentialItem
item={item}
index={index}
key={index}
validated={validated}
onChange={(partial) => handleChange(index, partial)}
/>
)}
</MetadataList>
</Form.Item>
</div>
);
};
export default ImageCredential;
@@ -1,177 +1,34 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import {
MinusOutlined,
PlusOutlined,
QuestionCircleOutlined
} from '@ant-design/icons';
import { Input as CInput, LabelSelector, useAppUtils } from '@gpustack/core-ui';
import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Form, Switch, Tooltip } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import { Form } from 'antd';
import _ from 'lodash';
import React, { useEffect, useId } from 'react';
import styled from 'styled-components';
import { GpuInstanceOptions } from '../config/types';
import { ClusterListItem as ListItem } from '../config/types';
import ImageCredential from './image-credential';
import K8SVolumeMount from './k8s-volume-mount';
const Title = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
background-color: transparent;
font-weight: 500;
font-size: 14px;
padding-top: 0px;
padding-bottom: 8px;
`;
const SectionWrap = styled.div`
margin-bottom: 16px;
`;
const ImageCredentialsForm: React.FC = () => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
return (
<SectionWrap>
<Form.List name={['k8s_options', 'imageCredentials']}>
{(fields, { add, remove }) => (
<>
<Title>
<div className="flex-center gap-8">
<span>
{intl.formatMessage({
id: 'clusters.imageCredentials.title'
})}
</span>
<Button
type="link"
onClick={() =>
add({ registry: '', username: '', password: '' })
}
>
<PlusOutlined />{' '}
{intl.formatMessage({
id: 'clusters.imageCredentials.add'
})}
</Button>
</div>
</Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{fields.map(({ key, name }) => (
<div
key={key}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: 12,
border: '1px solid var(--ant-color-split)',
borderRadius: 'var(--ant-border-radius-lg)'
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 12
}}
>
<Form.Item
name={[name, 'registry']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.imageCredentials.registry'
)
}
]}
style={{ marginBottom: 0 }}
>
<CInput.Input
required
label={intl.formatMessage({
id: 'clusters.imageCredentials.registry'
})}
></CInput.Input>
</Form.Item>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<Form.Item
name={[name, 'username']}
style={{ marginBottom: 0 }}
>
<CInput.Input
label={intl.formatMessage({
id: 'clusters.imageCredentials.username'
})}
></CInput.Input>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item
name={[name, 'password']}
style={{ marginBottom: 0 }}
>
<CInput.Password
label={intl.formatMessage({
id: 'clusters.imageCredentials.password'
})}
></CInput.Password>
</Form.Item>
</div>
</div>
</div>
<Button
size="small"
shape="circle"
style={{ marginTop: 8 }}
onClick={() => remove(name)}
>
<MinusOutlined />
</Button>
</div>
))}
</div>
</>
)}
</Form.List>
</SectionWrap>
);
};
const NodeSelectorForm: React.FC = () => {
const intl = useIntl();
return (
<SectionWrap>
<Title>
<span className="flex-center gap-4">
<span>
{intl.formatMessage({ id: 'clusters.nodeSelector.title' })}
</span>
<Tooltip
title={intl.formatMessage({ id: 'clusters.nodeSelector.tip' })}
>
<QuestionCircleOutlined
style={{ color: 'var(--ant-color-text-secondary)' }}
/>
</Tooltip>
</span>
</Title>
<Form.Item name={['k8s_options', 'nodeSelector']}>
<LabelSelector
label={intl.formatMessage({ id: 'clusters.nodeSelector.title' })}
description={intl.formatMessage({ id: 'clusters.nodeSelector.tip' })}
></LabelSelector>
</Form.Item>
</SectionWrap>
);
};
// Render namespace. Kept as the first field of the section so the most
// fundamental K8s deployment knob is set before the rest.
const NamespaceForm: React.FC = () => {
const intl = useIntl();
@@ -180,6 +37,7 @@ const NamespaceForm: React.FC = () => {
<Form.Item
name={['k8s_options', 'namespace']}
style={{ marginBottom: 0 }}
normalize={(value) => value || null}
>
<CInput.Input
label={intl.formatMessage({ id: 'clusters.namespace.title' })}
@@ -191,9 +49,7 @@ const NamespaceForm: React.FC = () => {
);
};
// Operator-image override. A plain string knob that used to ride along inside
// worker_config; it now lives directly on k8s_options.
const OperatorImageForm: React.FC = () => {
export const OperatorImageForm: React.FC = () => {
const intl = useIntl();
return (
@@ -201,6 +57,7 @@ const OperatorImageForm: React.FC = () => {
<Form.Item
name={['k8s_options', 'operatorImage']}
style={{ marginBottom: 0 }}
normalize={(value) => value || null}
>
<CInput.Input
label={intl.formatMessage({ id: 'clusters.operatorImage.title' })}
@@ -211,92 +68,215 @@ const OperatorImageForm: React.FC = () => {
);
};
// GPU-instance support. The backend treats the mere presence of
// `gpuInstanceOptions` as the enable flag, so the switch toggles the whole
// object in/out of the form rather than setting a boolean field; the static
// address (optional even when enabled) is nested underneath.
//
// We drive the toggle from local state (not Form.useWatch) because the
// gpuInstanceOptions path has no registered Form.Item of its own — useWatch
// doesn't reliably re-render on setFieldValue for such paths, which left the
// switch unresponsive. Local state owns the visible state and we mirror it
// into the form via setFieldValue so submit still collects it.
const GpuInstanceOptionsForm: React.FC<{
initialValue?: GpuInstanceOptions;
}> = ({ initialValue }) => {
// The presence of `gpuInstanceOptions` on `k8s_options` is the source of truth
// for whether GPU instances are enabled. Both the cluster-type selector
// (rendered up top) and the static-address field (rendered in the advanced
// section) watch this same path so they stay in sync without sharing local
// state.
const GPU_INSTANCE_OPTIONS_PATH = ['k8s_options', 'gpuInstanceOptions'];
// Visual parity with @gpustack/core-ui's SwitchCard so the selector blends
// in with surrounding form fields: same border, radius, padding, and
// typography. The only differences are the two-column grid layout and an
// active state (blue border + tinted background) to mark the selection.
const ClusterTypeWrap = styled.div`
margin-bottom: 24px;
`;
const ClusterTypeLabel = styled.div`
color: var(--ant-color-text);
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
.required {
color: var(--ant-color-error);
margin-left: 4px;
}
`;
const ClusterTypeGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
`;
const ClusterTypeCard = styled.div<{ $active: boolean }>`
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: var(--ant-border-radius-lg);
border: 1px solid
${(p) =>
p.$active ? 'var(--ant-color-primary)' : 'var(--ant-color-border)'};
background: ${(p) =>
p.$active ? 'var(--ant-color-primary-bg)' : 'transparent'};
cursor: pointer;
transition:
border-color 0.2s,
background-color 0.2s;
&:hover,
&:focus-visible {
border-color: var(--ant-color-primary);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ant-control-outline);
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
color: var(--ant-color-text);
font-size: 14px;
font-weight: 500;
}
.description {
color: var(--ant-color-text-secondary);
}
`;
const RadioDot = styled.span<{ $active: boolean }>`
position: relative;
flex-shrink: 0;
width: 16px;
height: 16px;
margin-top: 3px;
border-radius: 50%;
border: 1.5px solid
${(p) =>
p.$active ? 'var(--ant-color-primary)' : 'var(--ant-color-border)'};
background: ${(p) =>
p.$active ? 'var(--ant-color-primary)' : 'transparent'};
transition: all 0.2s;
&::after {
content: '';
position: absolute;
inset: 0;
margin: auto;
width: 6px;
height: 6px;
border-radius: 50%;
background: #fff;
opacity: ${(p) => (p.$active ? 1 : 0)};
transition: opacity 0.2s;
}
`;
// Card-based selector for cluster type. The two options are mutually exclusive
// and the choice maps directly to the presence/absence of `gpuInstanceOptions`
// on the form — "model" clears it, "gpu" seeds it to {} (preserving any
// already-entered static address). No standalone form field is registered;
// state is read via useWatch with `preserve: true` so it tracks updates made
// through setFieldValue.
export const ClusterTypeSelector: React.FC = () => {
const intl = useIntl();
const form = Form.useFormInstance();
const [enabled, setEnabled] = useState<boolean>(!!initialValue);
const [address, setAddress] = useState<string>(
initialValue?.gpuInstancesAccessStaticAddress || ''
const labelId = useId();
const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
form,
preserve: true
});
const value: 'model' | 'gpu' = gpuInstanceOptions ? 'gpu' : 'model';
const handleSelect = (next: 'model' | 'gpu') => {
if (!form || next === value) return;
if (next === 'gpu') {
form.setFieldValue(
GPU_INSTANCE_OPTIONS_PATH,
form.getFieldValue(GPU_INSTANCE_OPTIONS_PATH) ?? {}
);
} else {
form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined);
}
};
const options: {
key: 'model' | 'gpu';
title: string;
description: string;
}[] = [
{
key: 'model',
title: intl.formatMessage({ id: 'clusters.modelService.title' }),
description: intl.formatMessage({ id: 'clusters.modelService.tip' })
},
{
key: 'gpu',
title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }),
description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' })
}
];
return (
<ClusterTypeWrap>
<ClusterTypeLabel id={labelId}>
{intl.formatMessage({ id: 'clusters.clusterType.title' })}
<span className="required">*</span>
</ClusterTypeLabel>
<ClusterTypeGrid role="radiogroup" aria-labelledby={labelId}>
{options.map((opt) => {
const active = value === opt.key;
return (
<ClusterTypeCard
key={opt.key}
$active={active}
role="radio"
aria-checked={active}
tabIndex={0}
onClick={() => handleSelect(opt.key)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(opt.key);
}
}}
>
<RadioDot $active={active} />
<div className="body">
<div className="title">{opt.title}</div>
<div className="description">{opt.description}</div>
</div>
</ClusterTypeCard>
);
})}
</ClusterTypeGrid>
</ClusterTypeWrap>
);
const initializedRef = useRef<boolean>(!!initialValue);
};
const writeForm = (en: boolean, addr: string) => {
form.setFieldValue(
['k8s_options', 'gpuInstanceOptions'],
en ? { gpuInstancesAccessStaticAddress: addr } : undefined
);
};
// Static access address for GPU instances. Only shown when "GPU 服务" is
// the selected cluster type. Rendered in the advanced section, between the
// default container registry and the worker config (节点配置).
export const GpuInstancesStaticAddressForm: React.FC = () => {
const intl = useIntl();
// See note in ClusterTypeSelector: watch the full store so this field's
// visibility tracks the selector even before it has mounted its own
// Form.Item.
const enabled = !!Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
preserve: true
});
// Mirror a seeded initial value into the form on mount so submit collects it.
useEffect(() => {
if (initialValue) {
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
}
}, []);
// Adopt currentData arriving after mount (async edit load), once. After the
// user has interacted (`initializedRef`), local state owns the section.
useEffect(() => {
if (initializedRef.current) return;
if (initialValue) {
setEnabled(true);
setAddress(initialValue.gpuInstancesAccessStaticAddress || '');
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
initializedRef.current = true;
}
}, [initialValue]);
const handleToggle = (checked: boolean) => {
initializedRef.current = true;
setEnabled(checked);
if (!checked) {
setAddress('');
}
writeForm(checked, checked ? address : '');
};
const handleAddressChange = (e: any) => {
const next = typeof e === 'string' ? e : (e?.target?.value ?? '');
setAddress(next);
writeForm(true, next);
};
if (!enabled) {
return null;
}
return (
<SectionWrap>
<Title>
<div className="flex-center gap-8">
<span className="flex-center gap-4">
<span>
{intl.formatMessage({ id: 'clusters.gpuInstances.title' })}
</span>
<Tooltip
title={intl.formatMessage({ id: 'clusters.gpuInstances.tip' })}
>
<QuestionCircleOutlined
style={{ color: 'var(--ant-color-text-secondary)' }}
/>
</Tooltip>
</span>
<Switch checked={enabled} onChange={handleToggle} />
</div>
</Title>
{enabled && (
<Form.Item
name={[
'k8s_options',
'gpuInstanceOptions',
'gpuInstancesAccessStaticAddress'
]}
style={{ marginBottom: 0 }}
normalize={(value) => value || null}
>
<CInput.Input
isInFormItems={false}
value={address}
onChange={handleAddressChange}
label={intl.formatMessage({
id: 'clusters.gpuInstances.staticAddress'
})}
@@ -304,25 +284,82 @@ const GpuInstanceOptionsForm: React.FC<{
id: 'clusters.gpuInstances.staticAddress.tip'
})}
></CInput.Input>
)}
</Form.Item>
</SectionWrap>
);
};
const K8sPodSpec: React.FC<{
// Strip UI-only / undefined-valued noise so two k8s_options snapshots compare
// on real content. `sourceType` is derived from `volumeSource` purely for the
// volume-mount UI (see cluster-form init), and the JSON round-trip drops
// undefined-valued keys so a missing key and `key: undefined` compare equal.
const cleanK8sOptions = (opts: any) => {
const cloned = _.cloneDeep(opts || {});
if (Array.isArray(cloned.volumeMounts)) {
cloned.volumeMounts = cloned.volumeMounts.map(
({ sourceType, ...rest }: any) => rest
);
}
// The edit form always seeds k8s_options.volumeMounts to [] even when the
// saved cluster had no value, so an absent field would otherwise read as a
// change the moment the drawer opens. Drop empty top-level arrays on both
// sides: "absent" and "empty list" both mean nothing configured. A
// non-empty -> empty edit is still detected, since only the empty side drops.
Object.keys(cloned).forEach((key) => {
if (Array.isArray(cloned[key]) && cloned[key].length === 0) {
delete cloned[key];
}
});
return JSON.parse(JSON.stringify(cloned));
};
// Headless watcher: in EDIT mode it reports (via onChange) whether the user has
// changed any k8s_options field from the cluster's saved values. It renders
// nothing — the notice itself is shown in the form footer, above Save/Cancel
// (see cluster-create.tsx), mirroring the model edit interaction. Must be
// mounted inside the cluster <Form> so the watch reads the form store.
export const K8sOptionsChangeWatcher: React.FC<{
action: PageActionType;
initialGpuInstanceOptions?: GpuInstanceOptions;
}> = ({ action, initialGpuInstanceOptions }) => {
currentData?: ListItem;
onChange: (changed: boolean) => void;
}> = ({ action, currentData, onChange }) => {
// `preserve: true` so the watch tracks the full store, including
// gpuInstanceOptions which is toggled via setFieldValue without a mounted
// Form.Item (mirrors ClusterTypeSelector).
const k8sOptions = Form.useWatch(['k8s_options'], { preserve: true });
const changed =
action === PageAction.EDIT &&
!_.isEqual(
cleanK8sOptions(currentData?.k8s_options),
cleanK8sOptions(k8sOptions)
);
useEffect(() => {
onChange(changed);
}, [changed, onChange]);
// Clear the footer notice when this form unmounts (e.g. switching steps or
// provider) so a stale warning never lingers over the buttons.
useEffect(() => {
return () => onChange(false);
}, [onChange]);
return null;
};
// Kubernetes-specific options that live inside the cluster's advanced section.
const K8sAdvancedOptions: React.FC<{
action: PageActionType;
}> = ({ action }) => {
return (
<>
<NamespaceForm />
<K8SVolumeMount action={action}></K8SVolumeMount>
<ImageCredentialsForm />
<ImageCredential />
<NodeSelectorForm />
<OperatorImageForm />
<GpuInstanceOptionsForm initialValue={initialGpuInstanceOptions} />
</>
);
};
export default K8sPodSpec;
export default K8sAdvancedOptions;
@@ -40,7 +40,7 @@ const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => {
const k8sVolumeMounts = Form.useWatch(['k8s_options', 'volumeMounts'], form);
const [collapseKey, setCollapseKey] = useState<Set<number | string>>(
new Set([0])
new Set()
);
const volumeList = [
@@ -169,7 +169,9 @@ const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => {
open={collapseKey.has(name)}
onToggle={(open: boolean) => onToggle(open, name)}
styles={{
body: collapseKey.has(name) ? { padding: 16 } : {},
body: collapseKey.has(name)
? { paddingBlock: '16px 0', paddingInline: 16 }
: {},
content: { paddingTop: 0 },
header: {
backgroundColor: 'unset'
@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
import { ClusterListItem } from './types';
// for cluster form
interface FormContextProps {
currentData?: ClusterListItem;
submitAttempted?: boolean;
}
export const FormContext = createContext<FormContextProps>({});
export const useFormContext = () => useContext(FormContext);
@@ -1,6 +1,7 @@
import { workerAddedCountAtom } from '@/atoms/clusters';
import useSetChunkRequest from '@/hooks/use-chunk-request';
import useUpdateChunkedList from '@/hooks/use-update-chunk-list';
import useQueryWorkerList from '@/pages/resources/services/use-query-worker-list';
import { useAtom } from 'jotai';
import _ from 'lodash';
import qs from 'query-string';
@@ -13,7 +14,31 @@ export default function useAddWorkerMessage() {
const [addedCount, setAddedCount] = useState(0);
const timerRef = useRef<any>(null);
const triggerAtRef = useRef<number>(0);
const existingIdsRef = useRef<Set<string | number>>(new Set());
const snapshotReceivedRef = useRef<boolean>(false);
const [, setWorkerAddedCount] = useAtom(workerAddedCountAtom);
// fetchData auto-cancels any in-flight request on each call and on unmount,
// so a stale seed from a previous open/cluster can't leak in
const { fetchData: fetchWorkerList, cancelRequest: cancelWorkerListRequest } =
useQueryWorkerList();
const isNewWorker = (item: any) => {
console.log(
'isNewWorker item:',
item,
existingIdsRef.current,
snapshotReceivedRef.current
);
if (item?.id == null) {
return false;
}
if (existingIdsRef.current.has(item.id)) {
return false;
}
existingIdsRef.current.add(item.id);
// before the full snapshot has been received, every item is pre-existing
return snapshotReceivedRef.current;
};
const updateAddedCount = (count: number) => {
setAddedCount(count);
@@ -30,8 +55,9 @@ export default function useAddWorkerMessage() {
const { updateChunkedList } = useUpdateChunkedList({
events: ['CREATE', 'INSERT'],
dataList: [],
triggerAt: triggerAtRef,
isNewItem: isNewWorker,
onCreate: (newItems: any) => {
console.log('onCreate newItems:', newItems, triggerAtRef.current);
if (triggerAtRef.current) {
newItemsRef.current = newItemsRef.current.concat(newItems);
showAddWorkerMessage();
@@ -49,6 +75,27 @@ export default function useAddWorkerMessage() {
_.each(list, (data: any) => {
updateChunkedList(data);
});
// fallback: if seeding the baseline failed, the first watch chunk marks the
// snapshot as received (only reliable when there is at least one worker)
snapshotReceivedRef.current = true;
};
// Seed the baseline set of already-existing worker ids via REST before the
// watch starts. Relying on the watch's first chunk fails when a cluster has
// zero workers (no CREATE event is sent, so the snapshot flag never flips and
// genuinely new workers get misclassified as pre-existing).
const seedExistingWorkers = async (params: Record<string, any>) => {
try {
const items = await fetchWorkerList({ ...params, page: -1 } as any);
(items || []).forEach((item: any) => {
if (item?.id != null) {
existingIdsRef.current.add(item.id);
}
});
snapshotReceivedRef.current = true;
} catch (error) {
// ignore: fall back to the first watch chunk (see updateHandler)
}
};
const resetAddedCount = () => {
@@ -56,11 +103,18 @@ export default function useAddWorkerMessage() {
chunkRequestRef.current?.current?.cancel?.();
newItemsRef.current = [];
triggerAtRef.current = 0;
existingIdsRef.current = new Set();
snapshotReceivedRef.current = false;
// cancel any in-flight seed request
cancelWorkerListRequest();
clearTimeout(timerRef.current);
};
const createModelsChunkRequest = async (params = {}) => {
resetAddedCount();
// seed the baseline before watching so new workers are detected even when
// the cluster currently has zero workers
await seedExistingWorkers(params);
try {
chunkRequestRef.current = setChunkRequest({
url: `${WORKERS_API}?${qs.stringify(_.pickBy(params, (val: any) => !!val))}`,
@@ -19,7 +19,12 @@ export default function useClusterList() {
const fetchClusterList = async () => {
try {
const res = await queryClusterList({ page: -1 });
const res = await queryClusterList(
{ page: -1 },
{
skipErrorHandler: true
}
);
const list = res?.items?.map((item: any) => ({
label: item.name,
value: item.id,
@@ -35,7 +40,12 @@ export default function useClusterList() {
const fetchWorkerList = async () => {
try {
const res = await queryWorkersList({ page: -1 });
const res = await queryWorkersList(
{ page: -1 },
{
skipErrorHandler: true
}
);
const list = res?.items?.map((item: any) => ({
cluster_id: item.cluster_id,
state: item.state,
@@ -20,7 +20,7 @@ const useCredentialColumns = (
dataIndex: 'name',
sorter: tableSorter(1),
render: (text: string) => (
<AutoTooltip ghost minWidth={20}>
<AutoTooltip ghost minWidth={20} title={text}>
<span className="text-primary">{text}</span>
</AutoTooltip>
)
@@ -1,12 +1,17 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useUserSettings from '@/hooks/use-user-settings';
import { Input as CInput, IconFont } from '@gpustack/core-ui';
import { ExclamationCircleFilled } from '@ant-design/icons';
import { AlertBlockInfo, Input as CInput, IconFont } from '@gpustack/core-ui';
import { YamlEditor } from '@gpustack/core-ui/yaml-editor';
import { useIntl } from '@umijs/max';
import { Button, Form } from 'antd';
import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import styled from 'styled-components';
import {
GpuInstancesStaticAddressForm,
OperatorImageForm
} from '../components/k8s-pod-spec';
import { ProviderType, ProviderValueMap } from '../config';
import {
ClusterFormData as FormData,
@@ -20,7 +25,6 @@ const Title = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--ant-color-bg-container);
font-weight: 500;
font-size: 14px;
padding-top: 0px;
@@ -95,6 +99,7 @@ const ClusterAdvanceConfig: React.FC<{
<Form.Item<FormData>
name="system_default_container_registry"
style={{ marginBottom: 16 }}
normalize={(value) => value?.trim?.() || null}
>
<CInput.Input
label={intl.formatMessage({
@@ -106,9 +111,23 @@ const ClusterAdvanceConfig: React.FC<{
placeholder="docker.io"
></CInput.Input>
</Form.Item>
{provider === ProviderValueMap.Kubernetes && (
<>
<OperatorImageForm />
<GpuInstancesStaticAddressForm />
</>
)}
<Title>
{intl.formatMessage({ id: 'clusters.create.workerConfig' })}
</Title>
{action === PageAction.EDIT && (
<AlertBlockInfo
type="warning"
style={{ marginBottom: 8 }}
icon={<ExclamationCircleFilled />}
message={intl.formatMessage({ id: 'clusters.edit.workerConfig.tip' })}
></AlertBlockInfo>
)}
<YamlEditor
ref={editorRef}
isDarkTheme={isDarkTheme}
@@ -0,0 +1,11 @@
.container {
:global(.item-container) {
align-items: flex-start;
&:nth-child(2) {
:global(.btn) {
margin-top: 16px !important;
}
}
}
}
@@ -30,7 +30,16 @@ const ActiveTable = () => {
ellipsis: true,
render: (text: any, record: any) => {
return (
<AutoTooltip ghost>
<AutoTooltip
ghost
title={
<span>
{record.provider_name
? `${record.provider_name}/${text}`
: text}
</span>
}
>
<span className="text-primary">
{record.provider_name ? `${record.provider_name}/${text}` : text}
</span>
@@ -117,7 +117,7 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
showSearch
mode="multiple"
options={userList}
maxTagCount={0}
maxTagCount={'responsive'}
placeholder={intl.formatMessage({
id: 'dashboard.usage.selectuser'
})}
@@ -104,7 +104,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
firstAddCluster: true
});
navigate(`/cluster-management/clusters/list`);
navigate(`/resources/clusters/list`);
return;
}
@@ -113,7 +113,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
firstAddWorker: true,
firstAddCluster: false
});
navigate(`/cluster-management/clusters/list`);
navigate(`/resources/clusters/list`);
}
};
@@ -75,6 +75,18 @@ export async function deleteGPUServiceInstance(id: number) {
});
}
export async function stopGPUServiceInstance(id: number) {
return request(`${GPU_SERVICE_INSTANCES_API}/${id}/stop`, {
method: 'PUT'
});
}
export async function startGPUServiceInstance(id: number) {
return request(`${GPU_SERVICE_INSTANCES_API}/${id}/start`, {
method: 'PUT'
});
}
// =========== Instance Types ===========
export async function queryGPUServiceInstanceTypes(
@@ -1,5 +1,6 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import Separator from '@/pages/llmodels/components/separator';
import { SearchOutlined } from '@ant-design/icons';
import {
@@ -10,7 +11,7 @@ import {
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Empty, Input, Typography } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ListItem as TemplateItem } from '../../templates/config/types';
import useQueryTemplates from '../../templates/services/use-query-templates';
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
@@ -97,8 +98,58 @@ const AddModal: React.FC<AddModalProps> = ({
} = useQueryInstanceTypes();
const { detailData: templatesData, fetchData: fetchTemplates } =
useQueryTemplates();
const { clusterList, fetchClusterList } = useQueryClusterList();
// Set by the create-scope picker (admin "All" view) via onScopeChange.
// undefined = no picker (org context) → no client-side scoping.
const [scopeOrgId, setScopeOrgId] = useState<number | null | undefined>(
undefined
);
const templateList = templatesData?.items || [];
// A GPU instance is scheduled on the chosen instance type's cluster, and
// its owner is that cluster's owner. So when a platform admin targets an
// org, restrict each instance type's candidates to clusters that org owns
// (dropping tiers/types left with none). Header-independent: filters the
// fetched list client-side, so it doesn't rely on the request scope.
const filterTypesByOwner = (
types: InstanceTypeItem[],
clusters: Array<{
id?: number;
value?: number;
owner_principal_id?: number;
}>,
orgId?: number | null
): InstanceTypeItem[] => {
if (orgId == null) return types;
const owned = new Set(
(clusters || [])
.filter((c) => c.owner_principal_id === orgId)
.map((c) => c.id ?? c.value)
);
return types
.map((it) => ({
...it,
status: {
...it.status,
tiers: (it.status?.tiers ?? [])
.map((tier: any) => ({
...tier,
candidates: (tier.candidates ?? []).filter((c: any) =>
owned.has(Number(c.cluster))
)
}))
.filter((tier: any) => (tier.candidates ?? []).length > 0)
}
}))
.filter((it) => (it.status?.tiers ?? []).length > 0);
};
const ownedInstanceTypes = useMemo(
() => filterTypesByOwner(instanceTypeList, clusterList as any, scopeOrgId),
[instanceTypeList, clusterList, scopeOrgId]
);
// const readonly = action === PageAction.VIEW;
const readonly = false;
const isRecreate = realAction === PageAction.CREATE;
@@ -166,7 +217,7 @@ const AddModal: React.FC<AddModalProps> = ({
): InstanceTypeItem | undefined => {
if (!candidateName) return undefined;
return instanceTypes.find((item) =>
(item.status?.acceleratorTiers ?? []).some((tier) =>
(item.status?.tiers ?? []).some((tier) =>
(tier.candidates ?? []).some(
(c) => c.name === candidateName && Number(c.cluster) === clusterId
)
@@ -177,7 +228,13 @@ const AddModal: React.FC<AddModalProps> = ({
// initial for first
const applyAutoSelection = (
instanceTypes: InstanceTypeItem[],
templates: TemplateItem[]
templates: TemplateItem[],
clusters?: Array<{
id?: number;
value?: number;
owner_principal_id?: number;
}>,
orgId?: number | null
) => {
// On edit / view, surface the persisted selection in the card list.
if (!shouldAutoSelectResource) {
@@ -195,9 +252,24 @@ const AddModal: React.FC<AddModalProps> = ({
return;
}
const first = instanceTypes.find((item) => !item.disabled);
// Scope to clusters the chosen org owns (admin "All" view).
const owned = filterTypesByOwner(instanceTypes, clusters || [], orgId);
const first = owned.find((item) => !item.disabled);
if (!first) return;
if (!first) {
// The chosen org has no clusters (hence no instance types). Clear any
// prior pick so a stale instance type / cross-org cluster isn't left
// on the form.
setInstanceTypeSelection({
instanceType: undefined,
manufacturer: undefined
});
setTemplateId(undefined);
form.current?.applyInstanceType?.(undefined);
form.current?.setFieldValue?.('clusterId', null);
form.current?.setFieldValue?.(['spec', 'type'], undefined);
return;
}
// On create, auto-select the first instance type in the list
@@ -209,6 +281,52 @@ const AddModal: React.FC<AddModalProps> = ({
applySelection(first, template);
};
// Fetch the (tenant-scoped) instance types + templates and auto-select.
// The query hook cancels any in-flight request on each new call, so when
// this runs twice in quick succession (drawer open, then the scope
// picker settling on its default) the latest scope's result wins.
const loadCreateResources = (orgId?: number | null) => {
const session = ++sessionRef.current;
Promise.all([
fetchData({ page: -1 }),
fetchTemplates({ page: -1 }),
fetchClusterList({ page: -1 })
]).then(([instanceResItems, templatesRes, clusters]) => {
if (sessionRef.current !== session) return;
applyAutoSelection(
instanceResItems || [],
templatesRes?.items || [],
(Array.isArray(clusters) ? clusters : (clusters as any)?.items) || [],
orgId
);
});
};
// Platform admin retargeted the create to another org (or Global). The
// instance-type / cluster offerings are tenant-scoped, so drop the
// current pick and reload for the new scope. The request interceptor
// already carries the new org header by the time this fires.
const handleScopeChange = (orgId?: number | null) => {
if (!open || action !== PageAction.CREATE) return;
setScopeOrgId(orgId);
setInstanceTypeSelection({
instanceType: undefined,
manufacturer: undefined
});
setTemplateId(undefined);
// Also clear the instance-type-derived form state (the selected type
// card + its limits, the cluster, and spec.type). The cluster decides
// where the instance is scheduled, so a stale pick from the previous
// scope must not survive — otherwise an instance owned by the newly
// chosen org could land on the old org's cluster. The reload's
// owner-scoped auto-selection re-fills them from the new org, or leaves
// them empty (blocking submit) when the chosen org has no clusters.
form.current?.applyInstanceType?.(undefined);
form.current?.setFieldValue?.('clusterId', null);
form.current?.setFieldValue?.(['spec', 'type'], undefined);
loadCreateResources(orgId);
};
useEffect(() => {
if (!open) {
sessionRef.current += 1;
@@ -219,25 +337,27 @@ const AddModal: React.FC<AddModalProps> = ({
setTemplateId(undefined);
setInstanceKeyword('');
setTemplateKeyword('');
setScopeOrgId(undefined);
return;
}
if (action === PageAction.CREATE) {
const session = ++sessionRef.current;
Promise.all([fetchData({ page: -1 }), fetchTemplates({ page: -1 })]).then(
([instanceResItems, templatesRes]) => {
if (sessionRef.current !== session) return;
applyAutoSelection(instanceResItems || [], templatesRes?.items || []);
}
);
loadCreateResources();
}
}, [open, shouldAutoSelectResource, action]);
// filter instance types
const filteredInstanceTypes = instanceTypeList.filter((item) =>
// filter instance types (already scoped to the chosen org's clusters)
const filteredInstanceTypes = ownedInstanceTypes.filter((item) =>
matchKeyword([item.name], instanceKeyword)
);
// No instance types for the chosen org (e.g. it owns no clusters), and not
// mid-fetch — drives the "no available instance type" message in the form.
const noAvailableInstanceTypes =
action === PageAction.CREATE &&
!instanceTypesLoading &&
ownedInstanceTypes.length === 0;
// filter templates based on selection and keyword
const filteredTemplates = templateList.filter((item) => {
if (
@@ -268,6 +388,7 @@ const AddModal: React.FC<AddModalProps> = ({
await onOk({
...values
});
console.log('submit form values', values);
} finally {
setLoading(false);
}
@@ -444,8 +565,10 @@ const AddModal: React.FC<AddModalProps> = ({
currentData={data}
disabled={readonly}
onFinish={onFinish}
onScopeChange={handleScopeChange}
open={open}
instanceTypeList={instanceTypeList}
instanceTypeList={ownedInstanceTypes}
noAvailableInstanceTypes={noAvailableInstanceTypes}
/>
</>
</ColumnWrapper>
@@ -0,0 +1,105 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { AutoTooltip, IconFont } from '@gpustack/core-ui';
import { Flex, Tooltip } from 'antd';
import React from 'react';
export type InstanceTypeSection = {
icon: string;
name: string;
// [label, value] — a null label renders a single value with no sub-label;
// rows whose value is falsy or "-" are dropped.
rows: [string | null, string | undefined][];
};
/**
* "Instance Type" cell shared by the GPU Instances list and the Usage GPU
* Instances table: a primary product label (e.g. "NVIDIA-GeForce-RTX-5090-D x
* 1") plus a question/info icon whose dark popover breaks the spec down by
* category (GPU / CPU / Memory / Disk), titled with the instance name.
*/
const InstanceTypeCell: React.FC<{
title: string;
name?: string;
sections: InstanceTypeSection[];
}> = ({ title, name, sections }) => {
const specInfo = (
<div style={{ minWidth: 200 }}>
{name && <div style={{ fontWeight: 600, marginBottom: 8 }}>{name}</div>}
{sections.map((sec) => {
const rows = sec.rows.filter(([, v]) => v && v !== '-');
if (!rows.length) return null;
return (
<div
key={sec.name}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 16,
paddingBlock: 4,
borderBottom: '1px solid rgba(255, 255, 255, 0.2)'
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
minWidth: 88,
lineHeight: '22px'
}}
>
<IconFont type={sec.icon} />
<span>{sec.name}</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 2,
width: '100%'
}}
>
{rows.map(([label, value], i) => (
<div
key={i}
style={{ display: 'flex', gap: 16, lineHeight: '22px' }}
>
<span style={{ opacity: 0.65, minWidth: 96 }}>
{label || ''}
</span>
<span
style={{
display: 'flex',
flex: 1,
justifyContent: 'flex-end'
}}
>
{value}
</span>
</div>
))}
</div>
</div>
);
})}
</div>
);
return (
<Flex align="center" style={{ gap: 6 }}>
<AutoTooltip ghost title={<span>{title}</span>}>
<span className="text-primary">{title}</span>
</AutoTooltip>
<Tooltip
title={specInfo}
styles={{ container: { width: 'max-content', maxWidth: 480 } }}
>
<InfoCircleOutlined
style={{ color: 'var(--ant-color-primary)', cursor: 'pointer' }}
/>
</Tooltip>
</Flex>
);
};
export default InstanceTypeCell;
@@ -1,15 +1,11 @@
import { ceilMilliToCore } from '@/pages/gpu-service/utils';
import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex } from 'antd';
import styled from 'styled-components';
import { manufactureColorMap } from '../../templates/config';
import { convertKiToGi } from '../config';
import { formatMemoryDisplay } from '../config';
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
const toDisplayUnit = (value?: string | null) =>
value ? value.replace(/Gi$/, 'GB').replace(/Ti$/, 'TB') : value;
const Title = styled.div`
display: flex;
align-items: center;
@@ -23,28 +19,20 @@ const Title = styled.div`
const Meta = styled.div`
display: grid;
grid-template-columns: repeat(7, auto);
grid-auto-rows: minmax(15px, auto);
justify-content: start;
column-gap: 4px;
row-gap: 8px;
align-items: center;
color: var(--ant-color-text-secondary);
color: var(--ant-color-text-tertiary);
font-size: 13px;
.meta-row {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
align-items: center;
min-height: 15px;
color: var(--ant-color-text-tertiary);
}
.dot {
width: 3px;
height: 3px;
border-radius: 50%;
background-color: var(--ant-color-text-quaternary);
margin: 0 4px;
margin: 0 6px;
justify-self: center;
}
@@ -56,13 +44,16 @@ const Meta = styled.div`
interface InstanceTypeItemProps {
item: InstanceTypeItemModel;
showStatus?: boolean;
}
interface MetadataSectionProps {
spec: InstanceTypeItemModel['spec'];
}
const MetaItem: React.FC<{
icon: string;
label?: string;
value?: string | null;
value?: string | null | number;
showDot?: boolean;
show?: boolean;
}> = ({ icon, label, value, showDot = true, show = true }) => {
@@ -77,110 +68,122 @@ const MetaItem: React.FC<{
);
};
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
const intl = useIntl();
const specData = item.spec || {};
function getInstanceDerived(item: InstanceTypeItemModel) {
const spec = item.spec || {};
const acceleratable = spec.acceleratable;
// false: CPU; true: GPU
const acceleratable = specData.acceleratable;
const manufacturer = acceleratable ? specData.manufacturer || '' : 'cpu';
const manufacturerColor = manufactureColorMap[manufacturer] ?? 'purple';
// resource once-max-request status
const onceMaxRequestData = item.status?.onceMaxRequest || {};
// RAM resource
const ramRaw = acceleratable
? specData.unitResources?.ram
: onceMaxRequestData.ram;
console.log('InstanceTypeItem', item.name, 'ramRaw', ramRaw);
// CPU resource
const cpuRaw = acceleratable
? specData.unitResources?.cpu
: onceMaxRequestData.cpu;
const renderName = () => {
const displayName = specData.acceleratable
? specData.product || item.name
: 'CPU';
return displayName;
return {
acceleratable,
isGPU: acceleratable,
manufacturer: acceleratable ? spec.manufacturer || '' : 'cpu',
displayName: acceleratable ? spec.product || item.name : 'CPU',
ramUnit: spec.unitResourcesParsed?.ram?.value,
cpuUnitCores: spec.unitResourcesParsed?.cpu?.cores
};
}
export const InstanceMetadataSection: React.FC<MetadataSectionProps> = ({
spec
}) => {
const intl = useIntl();
const { ramUnit, cpuUnitCores, isGPU } = getInstanceDerived({
spec
} as InstanceTypeItemModel);
return (
<>
<Title>
<Flex gap={8} align="center">
<AutoTooltip ghost minWidth={20} maxWidth={200}>
{renderName() || '-'}
</AutoTooltip>
{acceleratable && manufacturer && (
<span
style={{
color: 'var(--ant-color-text-tertiary)',
fontWeight: 400
}}
>
<ThemeTag color={manufacturerColor} disabled={false}>
{manufacturer?.toUpperCase()}
</ThemeTag>
</span>
)}
</Flex>
</Title>
<Meta>
<span className="meta-row">
{acceleratable && (
<>
<MetaItem
showDot={false}
icon="icon-gpu1"
label={intl.formatMessage({ id: 'gpuservice.instance.memory' })}
value={
toDisplayUnit(convertKiToGi(specData?.memory ?? undefined)) ??
'-'
}
/>
<MetaItem
show={!!specData?.sliced}
icon="icon-sliced"
label={intl.formatMessage({
id: 'gpuservice.instance.sliced'
})}
value={specData?.sliced}
/>
<MetaItem
icon="icon-database"
label={intl.formatMessage(
{
id: 'common.max'
},
{ count: '' }
)}
value={`${item.maxAccelerator || '-'}`}
/>
</>
)}
</span>
<span className="meta-row">
<Meta>
{isGPU && (
<>
<MetaItem
show={isGPU}
showDot={false}
icon="icon-gpu1"
label={intl.formatMessage({ id: 'gpuservice.instance.memory' })}
value={formatMemoryDisplay(spec?.memory ?? undefined) ?? '-'}
/>
<MetaItem
icon="icon-database"
label={intl.formatMessage(
{
id: 'common.max'
},
{ count: '' }
)}
value={`${spec.maxComputeUnitCount || 0}`}
/>
<MetaItem
showDot={false}
icon="icon-ram-02"
label={intl.formatMessage({ id: 'gpuservice.instance.ram' })}
value={toDisplayUnit(convertKiToGi(ramRaw)) ?? '-'}
value={ramUnit ? `${ramUnit} GB` : '-'}
/>
<MetaItem
show={!!cpuRaw}
show={isGPU}
showDot={true}
icon="icon-cpu"
label="CPU"
value={ceilMilliToCore(cpuRaw) ?? '-'}
value={cpuUnitCores || '-'}
/>
</span>
</Meta>
</>
</>
)}
{!isGPU && (
<>
<MetaItem
showDot={false}
icon="icon-ram-02"
label={intl.formatMessage({ id: 'gpuservice.instance.ram' })}
value={ramUnit ? `${ramUnit} GB` : '-'}
/>
<MetaItem
icon="icon-database"
label={intl.formatMessage(
{
id: 'common.max'
},
{ count: '' }
)}
value={`${spec.maxComputeUnitCount || 0}`}
/>
</>
)}
</Meta>
);
};
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
const specData = item.spec || {};
const { acceleratable, manufacturer, displayName } = getInstanceDerived(item);
const manufacturerColor = manufactureColorMap[manufacturer] ?? 'purple';
const showManufacturerTag = acceleratable && manufacturer;
return (
<Flex
orientation="vertical"
justify="space-between"
style={{ height: '100%' }}
>
<Title>
<Flex gap={8} align="center">
<AutoTooltip ghost minWidth={20} maxWidth={200}>
{displayName || '-'}
</AutoTooltip>
{showManufacturerTag && (
<ThemeTag
color={manufacturerColor}
disabled={false}
style={{ fontWeight: 400 }}
>
{manufacturer?.toUpperCase()}
</ThemeTag>
)}
</Flex>
</Title>
<InstanceMetadataSection spec={specData}></InstanceMetadataSection>
</Flex>
);
};

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