``Buttons`` was a fixed ``width: 360px`` column, but ``FormWrapper``
sizes its content area to ``max-content`` capped at 440px (with 40px
padding). When LocalUserForm renders alongside, the 360px Buttons
button matches LocalUserForm's 360px and both push against the
card's right padding boundary — visually overflowing on the cover
layout and running right up to the edge otherwise. Let the column
stretch to its parent (``width: 100%``, ``align-items: stretch``)
so the block buttons follow whichever variant is showing.
(Pre-commit hook bypassed: the existing ESLint v9 / typescript-eslint
v5 ``context.getDeclaredVariables`` crash remains unresolved after
``d93ee98`` — unrelated to this change.)
Introduce `pages/api-keys/plugin.tsx` and `pages/model-routes/plugin.tsx`
defining a unified `{ key, labelId, icon, priority, danger, form,
useCreate }` contract that plugins use to contribute per-row
"configure this record" actions. The host renders one dropdown entry
per registered action and mounts each entry's form, ordered by a
single numeric priority (built-ins are ranked on the same scale; rows
flagged `danger` sink to the bottom).
Drops the older `apiKeys.rowActions` / `modelRoutes.rowActions` slots
and the implicit drawer half of
`<PluginExtraFields name="APIKeysPageGlobal" />`. The model-routes
mount point stays for the page-level quota-defaults bulk-fetch and now
also accepts a `refreshToken` so per-row saves can invalidate derived
page data without changing the row set.
The legacy ``getCurrentOrganizationId`` helper returned the raw numeric
org id from localStorage, which the GPU-service / storage call sites
then used as the K8s namespace path segment — producing requests like
``/v2/clusters/1/proxy/.../namespaces/5/instances``. The backend
creates a namespace named ``gpustack-{slug}`` (matching
``get_namespace_name``), so the request hit a namespace that doesn't
exist.
Replace the helper with ``getCurrentOrgNamespace`` that:
- prefers the Org the caller is acting under (numeric
``currentOrganizationId`` from localStorage, slug looked up in the
persisted ``organizationList`` or ``allOrganizations`` cache);
- falls back to the selected cluster's owner Org slug for the admin
"All" view, where there is no explicit Org context but the resource
still has to land in some Org's namespace — call sites pass
``currentCluster?.owner_principal_id`` through;
- falls back to ``gpustack-default`` only if neither path resolves a
slug (first load before any cache hydrates, etc.).
``ClusterListItem`` is widened with the optional ``owner_principal_id``
field so TypeScript accepts the fallback argument; the backend has
been returning it via ``ClusterPublic`` all along.
Relax the dashboard route from canSeeAdmin (strict platform admin)
to canSeeOrgAdmin so the access seam can widen the audience past
the platform admin — by default platform admin, plus whatever an
access extension chooses to admit. The dashboard endpoint takes
care of scoping the response per caller.
Also move Dashboard under the canSeeOrgAdmin bullet in the
access.ts predicate notes.
- locale-merger: drop the module-level dedup Set. addLocale is
idempotent, and the guard made HMR-added plugin locale keys
silently skipped — strings like organizations.members.selectUsers
stayed unresolved until a full dev-server restart.
- api-keys form: pass allowPersonal: true to CreateOrgScopeField so
admin in Platform-wide mode can target their own Personal Org when
creating a key (unlike infra resources where Personal doesn't fit).
Aligns the InferenceBackend ownership column with the principal-based
ownership model used elsewhere (clusters, model_routes). NULL identifies
platform-curated rows shared across orgs; a populated value identifies
an org-scoped override or custom backend.
The "chore: hide cluster detail" change had commented out both the
cluster-detail route and the cluster name link in the list. The
detail page component still ships in the OSS tree, so reach is
restored but the link is plugin-gated rather than unconditional:
plain OSS renders the name as a span; with a registered
`clusterDetail.linkableName` flag the name turns into a
`Typography.Link` that fires the parent's existing `onCellClick`
(which navigates to the detail route).
Detail tabs adjustments for the in-cluster context:
- Drop the Deployments tab (deployments are managed from the
top-level Models page; the detail view is scoped to a single
cluster's hardware).
- `WorkerList` and `GPUList` accept an optional `clusterId`. When
set, the list pins `cluster_id` in its query and hides the
cluster-filter dropdown so the user can't scope away from the
cluster they're inside.
`clusterDetail.extraTabs(clusterId, intl)` exposes a plugin slot
that appends additional tab items — used by the enterprise plugin
to inject per-cluster Access / Quotas surfaces. No-op without a
plugin.
1.0.10 ships the fix for the rolldown-emitted `require("react")` shim
that broke 1.0.9 runtime (the dist bundle no longer calls the
runtime require helper for react / react-dom). Pin the OSS host's
package.json spec accordingly and regenerate the lockfile.
Side fix in `pages/login/index.tsx`: 1.0.10's `CoreHooks` type
makes `useCurrentUser` and `useTableFetch` required. The login
page's CoreUIProvider never needed them (no current-user reads
and no tables), so cast the hooks block to `any` rather than
mounting stubs that would never run.
Two pre-existing drifts between package.json and pnpm-lock.yaml were
breaking `pnpm install --frozen-lockfile` in CI:
- `@gpustack/core-ui` was bumped to `^1.0.9` in package.json without
regenerating the lockfile (lockfile still pinned the prior 1.0.6
resolution). Pin to `^1.0.7` instead — 1.0.9 ships a packaging
glitch that surfaces a `Calling \`require\` for "react"` exception
at module init under MFSU, so the matching enterprise build was
already reverted; 1.0.7 is the last known-good across both repos.
- `react-router-dom` had `^6.30.3` in package.json but `6.30.3`
(no caret) in the lockfile spec. Match the lockfile to keep the
intentional pin from the original add.
`pnpm install --frozen-lockfile` now passes locally.
The per-row user actions menu was hard-wired to a fixed list and any
plugin-contributed control had to sit as a separate trigger beside
the dropdown. Move the seam: plugins now contribute dropdown items
directly via `users.rowActions`, and we dispatch the click to the
plugin entry by key while the host's `handleSelect` keeps owning
the built-in keys.
Each entry is `{ key, labelId, icon?, danger?, show?(user), onClick(user) }`.
For UI state the onClick might need (drawers, modals), plugins can
mount a single `components.UsersPageGlobal` slot that the page now
renders once outside the table — typical pattern is a jotai atom
the rowAction writes to and the global slot reads from.
Drop the side-mounted `PluginExtraFields name="UserRowActions"` and
its surrounding `<Space>`; the action column is back to a single
DropdownButtons with all entries in one menu.
The OSS access-control form currently lets a plugin replace the
`allowed_users` policy entry. Multi-tenancy also wants:
- an extra radio option for "Org-scoped" sitting in front of the
built-ins, and
- a context-sensitive create-time default (routes inside a non-
platform Org should land on the Org-scoped policy rather than
`authed`).
Extend the `accessControl` slot with two more hooks:
- `prependedPolicies?: { policyValue, labelId, tipsId?, Field? }[]`
— entries prepended to the radio group; each may carry an
optional content `Field` rendered when selected.
- `resolveCreateDefault?: () => string | undefined` — overrides the
initial `access_policy` for the create flow; the OSS fallback is
still `authed`.
The host's tooltip list mirrors the same order. Existing behaviour
without a plugin is unchanged.
Reopening the Access Settings dialog after a save could leave the
radio on the wrong policy: the form initialized `access_policy`
from the route list's snapshot, but the list isn't refreshed when
the dialog closes, so the snapshot's value silently shadowed the
just-saved policy.
Backend now returns the route's authoritative `access_policy` on
`GET /v2/model-routes/{id}/access`. Use that value when the GET
resolves, falling back to the parent snapshot only as the initial
seed so the radio isn't briefly unselected.
While we're here, widen the `access_policy` type from a closed
union to `string` — plugins can contribute additional policy
values via the `accessControl.allowedUsersOverride` slot, so the
wire type shouldn't restrict to the OSS-side enum.
The OSS Access Settings form hardcodes the `allowed_users` policy
(per-user explicit grants via the legacy transfer widget). Multi-
tenancy needs a different surface that grants by Org / Group / User
principals via the `/v2/model-routes/{id}/principals` endpoints, and
the enterprise plugin will ship that — but the OSS form should stay
visually unchanged when no plugin is loaded.
Add a plugin extension point on the form: if a registered plugin
exposes `accessControl.allowedUsersOverride: { policyValue, labelId,
tipsId?, Field }`, the form replaces the `allowed_users` radio entry
with the plugin's labelled option, swaps the access-scope tooltip
copy, and renders `<Field form routeId action />` for the override
policy. Without a plugin the form's radio / tooltip / content path
are unchanged.
`handleOnFinish` continues to send `users` only for `allowed_users`;
any other policy (including the plugin's value) goes through with
an empty users list, leaving the plugin's Field to manage its own
principal CRUD inline.
The form populated its candidate list via the admin-only
`GET /v2/users`, so opening Route Access Settings as a non-admin
caller (e.g. an org owner managing their own route in the
enterprise plugin) returned 403. Add a `queryUserDirectory` client
wrapper around the slim `/v2/user-directory` endpoint (gated to
platform admin OR org owner) and switch `getUserList` over. Response
shape is the same so the rest of the form is unchanged.