fix(gpu-service): use gpustack-{slug} namespace for org resources

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.
This commit is contained in:
gitlawr
2026-05-13 12:29:09 +08:00
committed by jialin
parent beb74b3c47
commit 2dae886fd1
10 changed files with 103 additions and 22 deletions
+67 -6
View File
@@ -28,13 +28,74 @@ export const initialPasswordAtom = atomWithStorage<string>(
''
);
export const getCurrentOrganizationId = (): string => {
// Namespace the server creates for an Org's resources on each Kubernetes
// cluster. The format must match the backend's ``get_namespace_name``
// helper — ``gpustack-{slug}`` — because the GPU-instance / storage CRDs
// (worker.gpustack.ai/v1) are namespaced and the server-side admission
// keys off this exact name.
//
// Resolution path:
// 1. The Org the caller is currently acting under — the enterprise
// plugin persists ``currentOrganizationId`` (numeric) when the user
// picks an Org via OrgSwitcher.
// 2. The cluster's own owner Org — used in the platform-admin "All"
// view, where the caller has no Org context but the resource still
// has to land in *some* Org's namespace.
// 3. ``gpustack-default`` as a last resort (first load before any
// cache is hydrated, or a cluster whose owner Org is missing from
// both caches).
//
// Called outside React (umi page utilities) so it reads localStorage
// directly rather than going through a Jotai hook. The org caches are
// kept fresh by the enterprise plugin's atomWithStorage atoms, and the
// OrgSwitcher reloads the page on switch so we don't need in-process
// reactivity here.
export const getCurrentOrgNamespace = (
clusterOwnerPrincipalId?: number | null
): string => {
return (
lookupOrgNamespace(getStoredCurrentOrgId()) ??
lookupOrgNamespace(clusterOwnerPrincipalId ?? null) ??
'gpustack-default'
);
};
const getStoredCurrentOrgId = (): number | null => {
try {
const raw = localStorage.getItem('currentOrganizationId');
if (raw) {
const value = JSON.parse(raw);
if (value != null) return String(value);
if (!raw) return null;
const value = JSON.parse(raw);
return typeof value === 'number' ? value : null;
} catch {
return null;
}
};
// Org caches the enterprise plugin persists. ``organizationList`` is
// the caller's member orgs; ``allOrganizations`` is admin-only (every
// Org on the platform) so admin sessions can resolve any owner Org id.
// Both are checked because ``currentOrganizationId`` is null in the
// admin "All" view but a member org's slug might still cover the
// cluster-owner fallback.
const ORG_CACHE_KEYS = ['organizationList', 'allOrganizations'] as const;
const lookupOrgNamespace = (id: number | null): string | 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; slug?: string }>;
if (!Array.isArray(list)) continue;
const match = list.find((item) => String(item?.id) === target);
if (match?.slug) return `gpustack-${match.slug}`;
} catch {
// ignore malformed cache; continue checking other keys
}
} catch {}
return 'gpustack-default';
}
return null;
};
@@ -88,6 +88,9 @@ export interface ClusterListItem {
state_message: string;
worker_pools: NodePoolListItem[];
k8s_volume_mounts?: VolumeMount[];
// Backend ClusterPublic carries this; admin-"All" namespace
// resolution falls back to the cluster's owner Org slug.
owner_principal_id?: number;
}
export interface ClusterFormData {
@@ -1,7 +1,9 @@
import { getCurrentOrganizationId } from '@/atoms/user';
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { InputNumber as CInputNumber, Select } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Flex, Form, Radio } from 'antd';
import { useAtomValue } from 'jotai';
import { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { FormData as StorageFormData } from '../../storage/config/types';
@@ -29,7 +31,8 @@ const StorageVolume = () => {
const form = Form.useFormInstance<FormData>();
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom);
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
useEffect(() => {
fetchStorage({});
+4 -2
View File
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrganizationId } from '@/atoms/user';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import type { PageActionType } from '@/config/types';
@@ -33,9 +33,11 @@ import useUpdateInstance from './services/use-update-instance';
const GPUService: React.FC = () => {
const intl = useIntl();
const namespace = getCurrentOrganizationId();
const [currentCluster, setCurrentCluster] = useAtom(currentClusterAtom);
const clusterID = currentCluster?.id;
// In admin "All" view there's no Org context, so the helper falls
// back to the selected cluster's owner Org slug.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const deleteInstance = useCallback(
(id: number) => deleteGPUServiceInstance({ namespace, clusterID, id }),
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrganizationId } from '@/atoms/user';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
@@ -12,9 +12,11 @@ interface CreateInstanceParams {
}
export default function useCreateInstance() {
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org slug for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: CreateInstanceParams, option?: any) =>
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrganizationId } from '@/atoms/user';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
@@ -13,9 +13,11 @@ interface UpdateInstanceParams {
}
export default function useUpdateInstance() {
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org slug for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: UpdateInstanceParams, option?: any) =>
+4 -2
View File
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrganizationId } from '@/atoms/user';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import type { PageActionType } from '@/config/types';
@@ -34,9 +34,11 @@ import useUpdateStorage from './services/use-update-storage';
const GPUServiceStorage: React.FC = () => {
const intl = useIntl();
const namespace = getCurrentOrganizationId();
const [currentCluster, setCurrentCluster] = useAtom(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view falls back to the cluster's owner Org slug —
// see :func:`getCurrentOrgNamespace`.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const deleteStorage = useCallback(
(id: number) => deleteGPUServiceStorage({ namespace, clusterID, id }),
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrganizationId } from '@/atoms/user';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
@@ -12,9 +12,11 @@ interface CreateStorageParams {
}
export default function useCreateStorage() {
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org slug for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: CreateStorageParams, option?: any) =>
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrganizationId } from '@/atoms/user';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
@@ -7,9 +7,11 @@ import { queryGPUServiceStorage } from '../apis';
import { ListItem } from '../config/types';
export default function useQueryStorage() {
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org slug for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: Global.K8sSearchParams = {}, options?: any) =>
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrganizationId } from '@/atoms/user';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
@@ -13,9 +13,11 @@ interface UpdateStorageParams {
}
export default function useUpdateStorage() {
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org slug for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: UpdateStorageParams, option?: any) =>