diff --git a/src/app.tsx b/src/app.tsx index 452bde5f..5bb033b7 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -3,9 +3,6 @@ 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'; @@ -17,6 +14,10 @@ import { } from '@/services/profile/apis'; import { fetchSystemConfig } from '@/services/system/query-system-config'; import { isOnline } from '@/utils'; +import { + markInitialStateProbed, + probeAccessFlags +} from '@/utils/access-probes'; import { installTenantFetch } from '@/utils/install-fetch'; import { IS_FIRST_LOGIN, @@ -41,80 +42,6 @@ const checkDefaultPage = async (userInfo: any) => { } }; -// Probes the caller's cluster list once so access predicates can gate -// GPU Service (Kubernetes-only). Cheap (one list request) and never -// blocks login — any failure just falls back to `undefined`, which -// the predicate treats as "unknown / don't restrict beyond role". -// The result is also mirrored into sessionStorage so access extensions -// that run without the initialState argument can read it (e.g. to -// override the admin shortcut in scopes where the menu shouldn't -// show even for admins). -const HAS_K8S_CLUSTER_KEY = 'hasKubernetesCluster'; -const probeHasKubernetesCluster = async (): Promise => { - try { - const res = await queryClusterList( - { page: -1 }, - { - skipErrorHandler: true - } - ); - const value = (res?.items ?? []).some( - (c) => c?.provider === ProviderValueMap.Kubernetes - ); - try { - window.sessionStorage.setItem(HAS_K8S_CLUSTER_KEY, JSON.stringify(value)); - } catch { - // sessionStorage may be unavailable (Safari private mode); the - // access predicate already handles a missing value as "unknown". - } - return value; - } catch (error) { - console.error('probeHasKubernetesCluster error', error); - try { - window.sessionStorage.removeItem(HAS_K8S_CLUSTER_KEY); - } catch { - // ignore - } - return 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 => { - 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; @@ -235,19 +162,24 @@ export async function getInitialState(): Promise<{ getAppVersionInfo(); if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) { - const [userInfo, hasKubernetesCluster, hasResourceEvents] = - await Promise.all([ - fetchUserInfo(), - probeHasKubernetesCluster(), - probeHasResourceEvents() - ]); + const [userInfo, accessFlags] = await Promise.all([ + fetchUserInfo(), + probeAccessFlags() + ]); + // Record that the probes ran for an authenticated user this page load + // (the refresh path) so the layout doesn't re-probe. A failed + // fetch (empty user — e.g. unauthenticated deep link that bounces to + // login) is NOT marked: the user will log in via SPA afterwards and + // the layout becomes responsible for probing. + if (userInfo?.username) { + markInitialStateProbed(); + } checkDefaultPage(userInfo); return { fetchUserInfo, currentUser: userInfo, pluginData, - hasKubernetesCluster, - hasResourceEvents + ...accessFlags }; } return { diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index ee83c91d..2a4c37d2 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -12,6 +12,7 @@ import useUserSettings from '@/hooks/use-user-settings'; import useUserSettingsStorage from '@/hooks/use-user-settings-storage'; import useAddResource from '@/pages/dashboard/hooks/use-add-resource'; import { logout } from '@/pages/login/apis'; +import { didInitialStateProbe, probeAccessFlags } from '@/utils/access-probes'; import { readColumnSettings, readState, @@ -41,7 +42,7 @@ import { import { Button, ConfigProvider, Modal, theme } from 'antd'; import { useAtom } from 'jotai'; import 'overlayscrollbars/overlayscrollbars.css'; -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { PageContainerInner } from '../pages/_components/page-box'; import Exception from './Exception'; import './Layout.css'; @@ -155,6 +156,50 @@ export default (props: any) => { const { initialState, loading, setInitialState } = initialInfo; const access = useAccess(); + const probedForUserRef = useRef(null); + + // Backfill the access probes (cluster / resource-events) once we're in + // the authenticated shell. `getInitialState` runs only once at app + // boot and can't probe on the login page (no session => 401), so after + // a first login the flags arrive here as `undefined` — which the + // access predicate treats as "don't restrict", flashing GPU Service / + // the full Usage page on until a manual refresh. This layout mounts + // only post-auth (login is `layout:false`) and on every entry, so it's + // the reliable place to resolve them. + // + // The effect is keyed on the user IDENTITY, not on the flag values — + // gating on the flags is what made this fragile (on a refresh + // `getInitialState` commits `currentUser` and the flags in the same + // update, so a flag-gated effect sees them already-known and never + // fires). Whether to actually hit the network is decided by the + // module-scoped `didInitialStateProbe()` marker, which is true only + // when `getInitialState` already probed for an authenticated user this + // page load (the refresh path) — so we skip the duplicate request there + // but still probe on the SPA-login path. Keying on identity also + // re-probes correctly if the signed-in user changes. + const currentUser = initialState?.currentUser; + const username = currentUser?.username; + + useEffect(() => { + if (!username || !setInitialState) { + return; + } + if (probedForUserRef.current === username) { + return; + } + probedForUserRef.current = username; + // The refresh path already resolved the flags inside `getInitialState` + // for this user — don't issue a duplicate probe. + if (didInitialStateProbe()) { + return; + } + probeAccessFlags().then((accessFlags) => { + setInitialState((prev: any) => ({ + ...prev, + ...accessFlags + })); + }); + }, [username, setInitialState]); const userConfig = { title: '', diff --git a/src/pages/usage/index.tsx b/src/pages/usage/index.tsx index e49e48cc..996e288b 100644 --- a/src/pages/usage/index.tsx +++ b/src/pages/usage/index.tsx @@ -67,7 +67,7 @@ const Usage: React.FC = () => { if (!item.access) return true; return (access as Record)[item.access]; }); - }, [intl, access]); + }, [intl, access.canSeeGpuService]); return ( => { + try { + const res = await queryClusterList( + { page: -1 }, + { + skipErrorHandler: true + } + ); + const value = (res?.items ?? []).some( + (c) => c?.provider === ProviderValueMap.Kubernetes + ); + try { + window.sessionStorage.setItem(HAS_K8S_CLUSTER_KEY, JSON.stringify(value)); + } catch { + // sessionStorage may be unavailable (Safari private mode); the + // access predicate already handles a missing value as "unknown". + } + return value; + } catch (error) { + console.error('probeHasKubernetesCluster error', error); + try { + window.sessionStorage.removeItem(HAS_K8S_CLUSTER_KEY); + } catch { + // ignore + } + return 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'; +export 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; + } +}; + +// Runs both access probes in parallel. Used after login (when +// `getInitialState` skipped them because the app booted on the login +// page) so the access predicate has definitive cluster / resource-event +// answers before the user navigates — otherwise GPU Service / the full +// Usage page flash on (undefined => "don't restrict") and only collapse +// to the correct set on the next refresh. +export const probeAccessFlags = async (): Promise<{ + hasKubernetesCluster?: boolean; + hasResourceEvents?: boolean; +}> => { + const [hasKubernetesCluster, hasResourceEvents] = await Promise.all([ + probeHasKubernetesCluster(), + probeHasResourceEvents() + ]); + return { hasKubernetesCluster: false, hasResourceEvents: false }; +}; + +// Module-scoped marker for "did `getInitialState` actually run the access +// probes during THIS page load". It resets to `false` on every full +// reload (the module is re-evaluated) and stays sticky across SPA +// navigations within the same load. +// +// The layout uses it to decide whether it must probe: on a refresh of an +// authenticated page `getInitialState` already probed (marks `true`), so +// the layout skips and avoids a duplicate request; on a first login the +// app booted on `/login` where `getInitialState` skipped probing (stays +// `false`), so the layout is the one that resolves the flags. Keeping +// this in module scope — rather than reading the flag values off +// `initialState` — decouples the decision from React's state-update +// timing, which is what made the earlier flag-gated effect fragile. +let initialStateDidProbe = false; +export const markInitialStateProbed = () => { + initialStateDidProbe = true; +}; +export const didInitialStateProbe = () => initialStateDidProbe;