fix(app): run probe resources at two stages
This commit is contained in:
+17
-85
@@ -3,9 +3,6 @@ import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
|
|||||||
import { setAtomStorage } from '@/atoms/utils';
|
import { setAtomStorage } from '@/atoms/utils';
|
||||||
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
|
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
|
||||||
import { COLOR_PRIMARY } from '@/config/theme/constants';
|
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 { getGPUStackPlugin } from '@/plugins';
|
||||||
import { enterprisePluginReady } from '@/plugins/enterprise-ready';
|
import { enterprisePluginReady } from '@/plugins/enterprise-ready';
|
||||||
import { GPUStackPluginManager } from '@/plugins/manager';
|
import { GPUStackPluginManager } from '@/plugins/manager';
|
||||||
@@ -17,6 +14,10 @@ import {
|
|||||||
} from '@/services/profile/apis';
|
} from '@/services/profile/apis';
|
||||||
import { fetchSystemConfig } from '@/services/system/query-system-config';
|
import { fetchSystemConfig } from '@/services/system/query-system-config';
|
||||||
import { isOnline } from '@/utils';
|
import { isOnline } from '@/utils';
|
||||||
|
import {
|
||||||
|
markInitialStateProbed,
|
||||||
|
probeAccessFlags
|
||||||
|
} from '@/utils/access-probes';
|
||||||
import { installTenantFetch } from '@/utils/install-fetch';
|
import { installTenantFetch } from '@/utils/install-fetch';
|
||||||
import {
|
import {
|
||||||
IS_FIRST_LOGIN,
|
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<boolean | undefined> => {
|
|
||||||
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<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
|
// runtime configuration
|
||||||
export async function getInitialState(): Promise<{
|
export async function getInitialState(): Promise<{
|
||||||
fetchUserInfo: () => Promise<Global.UserInfo>;
|
fetchUserInfo: () => Promise<Global.UserInfo>;
|
||||||
@@ -235,19 +162,24 @@ export async function getInitialState(): Promise<{
|
|||||||
getAppVersionInfo();
|
getAppVersionInfo();
|
||||||
|
|
||||||
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
|
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
|
||||||
const [userInfo, hasKubernetesCluster, hasResourceEvents] =
|
const [userInfo, accessFlags] = await Promise.all([
|
||||||
await Promise.all([
|
fetchUserInfo(),
|
||||||
fetchUserInfo(),
|
probeAccessFlags()
|
||||||
probeHasKubernetesCluster(),
|
]);
|
||||||
probeHasResourceEvents()
|
// 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);
|
checkDefaultPage(userInfo);
|
||||||
return {
|
return {
|
||||||
fetchUserInfo,
|
fetchUserInfo,
|
||||||
currentUser: userInfo,
|
currentUser: userInfo,
|
||||||
pluginData,
|
pluginData,
|
||||||
hasKubernetesCluster,
|
...accessFlags
|
||||||
hasResourceEvents
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
+46
-1
@@ -12,6 +12,7 @@ import useUserSettings from '@/hooks/use-user-settings';
|
|||||||
import useUserSettingsStorage from '@/hooks/use-user-settings-storage';
|
import useUserSettingsStorage from '@/hooks/use-user-settings-storage';
|
||||||
import useAddResource from '@/pages/dashboard/hooks/use-add-resource';
|
import useAddResource from '@/pages/dashboard/hooks/use-add-resource';
|
||||||
import { logout } from '@/pages/login/apis';
|
import { logout } from '@/pages/login/apis';
|
||||||
|
import { didInitialStateProbe, probeAccessFlags } from '@/utils/access-probes';
|
||||||
import {
|
import {
|
||||||
readColumnSettings,
|
readColumnSettings,
|
||||||
readState,
|
readState,
|
||||||
@@ -41,7 +42,7 @@ import {
|
|||||||
import { Button, ConfigProvider, Modal, theme } from 'antd';
|
import { Button, ConfigProvider, Modal, theme } from 'antd';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import 'overlayscrollbars/overlayscrollbars.css';
|
import 'overlayscrollbars/overlayscrollbars.css';
|
||||||
import { useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
import { PageContainerInner } from '../pages/_components/page-box';
|
import { PageContainerInner } from '../pages/_components/page-box';
|
||||||
import Exception from './Exception';
|
import Exception from './Exception';
|
||||||
import './Layout.css';
|
import './Layout.css';
|
||||||
@@ -155,6 +156,50 @@ export default (props: any) => {
|
|||||||
|
|
||||||
const { initialState, loading, setInitialState } = initialInfo;
|
const { initialState, loading, setInitialState } = initialInfo;
|
||||||
const access = useAccess();
|
const access = useAccess();
|
||||||
|
const probedForUserRef = useRef<string | null>(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 = {
|
const userConfig = {
|
||||||
title: '',
|
title: '',
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const Usage: React.FC = () => {
|
|||||||
if (!item.access) return true;
|
if (!item.access) return true;
|
||||||
return (access as Record<string, boolean>)[item.access];
|
return (access as Record<string, boolean>)[item.access];
|
||||||
});
|
});
|
||||||
}, [intl, access]);
|
}, [intl, access.canSeeGpuService]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Access probes used to gate Kubernetes-only menus (GPU Service / the
|
||||||
|
* full Usage page).
|
||||||
|
*
|
||||||
|
* These live outside `src/app.tsx` because umi treats every export from
|
||||||
|
* the runtime-config file as a registration key — exporting helpers from
|
||||||
|
* there throws "register failed, invalid key". `getInitialState` (in
|
||||||
|
* app.tsx) and the login flow both import from here.
|
||||||
|
*/
|
||||||
|
import { queryClusterList } from '@/pages/cluster-management/apis';
|
||||||
|
import { ProviderValueMap } from '@/pages/cluster-management/config';
|
||||||
|
import { queryResourceEvents } from '@/pages/usage/apis/resource';
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
export const probeHasKubernetesCluster = async (): Promise<
|
||||||
|
boolean | undefined
|
||||||
|
> => {
|
||||||
|
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;
|
||||||
Reference in New Issue
Block a user