diff --git a/frontend/@types/console/console.d.ts b/frontend/@types/console/console.d.ts new file mode 100644 index 00000000000..9bb05f6c68f --- /dev/null +++ b/frontend/@types/console/console.d.ts @@ -0,0 +1,77 @@ +declare interface Window { + SERVER_FLAGS: { + copiedCSVsDisabled: boolean; + alertManagerBaseURL: string; + alertmanagerUserWorkloadBaseURL: string; + authDisabled: boolean; + basePath: string; + branding: string; + consoleVersion: string; + customLogoURL: string; + customLogosConfigured: boolean; + customFaviconsConfigured: boolean; + customProductName: string; + documentationBaseURL: string; + kubeAdminLogoutURL: string; + kubeAPIServerURL: string; + loadTestFactor: number; + loginErrorURL: string; + loginSuccessURL: string; + loginURL: string; + logoutRedirect: string; + logoutURL: string; + prometheusBaseURL: string; + prometheusTenancyBaseURL: string; + quickStarts: string; + releaseVersion: string; + inactivityTimeout: number; + statuspageID: string; + GOARCH: string; + GOOS: string; + graphqlBaseURL: string; + developerCatalogCategories: string; + perspectives: string; + developerCatalogTypes: string; + userSettingsLocation: string; + addPage: string; // JSON encoded configuration + consolePlugins: string[]; // Console dynamic plugins enabled on the cluster + i18nNamespaces: string[]; // Available i18n namespaces + quickStarts: string; + projectAccessClusterRoles: string; + controlPlaneTopology: string; + telemetry?: Partial<{ + // All of the following should be always available on prod env. + SEGMENT_API_HOST: string; + SEGMENT_JS_HOST: string; + // One of the following should be always available on prod env. + SEGMENT_API_KEY: string; + SEGMENT_PUBLIC_API_KEY: string; + DEVSANDBOX_SEGMENT_API_KEY: string; + // Optional override for analytics.min.js script URL + SEGMENT_JS_URL: string; + // Additional telemetry options passed to Console frontend + DEBUG: 'true' | 'false'; + DISABLED: 'true' | 'false'; + [name: string]: string; + }>; + nodeArchitectures: string[]; + nodeOperatingSystems: string[]; + hubConsoleURL: string; + k8sMode: string; + techPreview: boolean; + capabilities: { + name: string; + visibility: { state: 'Enabled' | 'Disabled' }; + }[]; + }; + /** (OCPBUGS-46415) Do not override this string! To add new errors please append to windowError if it exists*/ + windowError?: string; + __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: Function; + i18n?: {}; // i18next instance, only available in development builds for debugging + store?: {}; // Redux store, only available in development builds for debugging + pluginStore?: {}; // Console plugin store + loadPluginEntry?: Function; // Console plugin entry callback, used to load dynamic plugins + webpackSharedScope?: {}; // webpack shared scope object + Cypress?: {}; + monaco?: {}; +} diff --git a/frontend/@types/console/index.d.ts b/frontend/@types/console/index.d.ts index 6e157fdf33e..7ef664edd7b 100644 --- a/frontend/@types/console/index.d.ts +++ b/frontend/@types/console/index.d.ts @@ -1,5 +1,6 @@ /// /// +/// /// declare module '*.svg' { @@ -11,83 +12,3 @@ declare module '*.png' { const value: any; export default value; } - -declare interface Window { - SERVER_FLAGS: { - copiedCSVsDisabled: boolean; - alertManagerBaseURL: string; - alertmanagerUserWorkloadBaseURL: string; - authDisabled: boolean; - basePath: string; - branding: string; - consoleVersion: string; - customLogoURL: string; - customLogosConfigured: boolean; - customFaviconsConfigured: boolean; - customProductName: string; - documentationBaseURL: string; - kubeAPIServerURL: string; - loadTestFactor: number; - loginErrorURL: string; - loginSuccessURL: string; - loginURL: string; - logoutRedirect: string; - logoutURL: string; - prometheusBaseURL: string; - prometheusTenancyBaseURL: string; - quickStarts: string; - releaseVersion: string; - inactivityTimeout: number; - statuspageID: string; - GOARCH: string; - GOOS: string; - graphqlBaseURL: string; - developerCatalogCategories: string; - perspectives: string; - developerCatalogTypes: string; - userSettingsLocation: string; - addPage: string; // JSON encoded configuration - consolePlugins: string[]; // Console dynamic plugins enabled on the cluster - i18nNamespaces: string[]; // Available i18n namespaces - quickStarts: string; - projectAccessClusterRoles: string; - controlPlaneTopology: string; - telemetry?: Partial<{ - // All of the following should be always available on prod env. - SEGMENT_API_HOST: string; - SEGMENT_JS_HOST: string; - // One of the following should be always available on prod env. - SEGMENT_API_KEY: string; - SEGMENT_PUBLIC_API_KEY: string; - DEVSANDBOX_SEGMENT_API_KEY: string; - // Optional override for analytics.min.js script URL - SEGMENT_JS_URL: string; - // Additional telemetry options passed to Console frontend - DEBUG: 'true' | 'false'; - DISABLED: 'true' | 'false'; - [name: string]: string; - }>; - nodeArchitectures: string[]; - nodeOperatingSystems: string[]; - hubConsoleURL: string; - k8sMode: string; - techPreview: boolean; - capabilities: { - name: string; - visibility: { state: 'Enabled' | 'Disabled' }; - }[]; - }; - /** (OCPBUGS-46415) Do not override this string! To add new errors please append to windowError if it exists*/ - windowError?: string; - __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: Function; - i18n?: {}; // i18next instance, only available in development builds for debugging - store?: {}; // Redux store, only available in development builds for debugging - pluginStore?: {}; // Console plugin store - loadPluginEntry?: Function; // Console plugin entry callback, used to load dynamic plugins - webpackSharedScope?: {}; // webpack shared scope object - Cypress?: {}; - monaco?: {}; -} - -// From https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html -declare type Diff = Omit; diff --git a/frontend/packages/console-dynamic-plugin-sdk/@types/sdk/index.d.ts b/frontend/packages/console-dynamic-plugin-sdk/@types/sdk/index.d.ts index e01cd3c389c..a434905a880 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/@types/sdk/index.d.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/@types/sdk/index.d.ts @@ -1,8 +1 @@ -declare interface Window { - SERVER_FLAGS: { - basePath: string; - }; - /** (OCPBUGS-46415) Do not override this string! To add new errors please append to windowError if it exists*/ - windowError?: string; - __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: Function; -} +/// diff --git a/frontend/packages/console-dynamic-plugin-sdk/CHANGELOG-core.md b/frontend/packages/console-dynamic-plugin-sdk/CHANGELOG-core.md index d1e19c4d22d..12d600b6a8b 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/CHANGELOG-core.md +++ b/frontend/packages/console-dynamic-plugin-sdk/CHANGELOG-core.md @@ -13,6 +13,7 @@ table in [Console dynamic plugins README](./README.md). ## 4.22.0-prerelease.2 - TBD - **Breaking**: Removed `pluginID` from the result in `useResolvedExtensions` hook ([CONSOLE-3769], [#15904]) +- **Breaking**: Removed `AppInitSDK` and `useReduxStore` in `app` directory ([CONSOLE-5063], [#16019]) - The following types are now re-exported from `@openshift/dynamic-plugin-sdk` instead of being defined by Console: `CodeRef`, `EncodedCodeRef`, `LoadedExtension`, and `ResolvedExtension` ([CONSOLE-3769], [#15904]) @@ -250,3 +251,4 @@ table in [Console dynamic plugins README](./README.md). [#15893]: https://github.com/openshift/console/pull/15893 [#15904]: https://github.com/openshift/console/pull/15904 [#15934]: https://github.com/openshift/console/pull/15934 +[#16019]: https://github.com/openshift/console/pull/16019 diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/app/AppInitSDK.tsx b/frontend/packages/console-dynamic-plugin-sdk/src/app/AppInitSDK.tsx deleted file mode 100644 index bce69e5a6d3..00000000000 --- a/frontend/packages/console-dynamic-plugin-sdk/src/app/AppInitSDK.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { ReactNode, FC } from 'react'; -import { useEffect } from 'react'; -import { Provider } from 'react-redux'; -import { setUtilsConfig, UtilsConfig } from './configSetup'; -import { initApiDiscovery } from './k8s/api-discovery/api-discovery'; -import { InitApiDiscovery } from './k8s/api-discovery/api-discovery-types'; -import { useReduxStore } from './useReduxStore'; - -type AppInitSDKProps = { - children: ReactNode; - configurations: { - apiDiscovery?: InitApiDiscovery; - appFetch: UtilsConfig['appFetch']; - dynamicPlugins?: () => void; - }; -}; - -/** - * Component for providing store access to the SDK. - * Add this at app-level to make use of app's redux store and pass configurations prop needed to initialize the app, preferred to have it under Provider. - * It checks for store instance if present or not. - * If the store is there then the reference is persisted to be used in SDK else it creates a new store and passes it to the children with the provider - * @component AppInitSDK - * @example - * ```ts - * return ( - * - * - * - * ... - * - * - * ) - * ``` - */ -const AppInitSDK: FC = ({ children, configurations }) => { - const { store, storeContextPresent } = useReduxStore(); - useEffect(() => { - const { appFetch, dynamicPlugins, apiDiscovery = initApiDiscovery } = configurations; - try { - setUtilsConfig({ appFetch }); - dynamicPlugins?.(); - apiDiscovery(store); - } catch (e) { - // eslint-disable-next-line no-console - console.warn(e); - } - }, [configurations, store]); - - return !storeContextPresent ? {children} : <>{children}; -}; - -export default AppInitSDK; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/app/__tests__/AppInitSDK.spec.tsx b/frontend/packages/console-dynamic-plugin-sdk/src/app/__tests__/AppInitSDK.spec.tsx deleted file mode 100644 index cebe76b2c42..00000000000 --- a/frontend/packages/console-dynamic-plugin-sdk/src/app/__tests__/AppInitSDK.spec.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import * as reactRedux from 'react-redux'; -import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; -import AppInitSDK from '../AppInitSDK'; -import * as configSetup from '../configSetup'; -import * as apiDiscovery from '../k8s/api-discovery/api-discovery'; -import * as hooks from '../useReduxStore'; - -jest.mock('react-redux', () => ({ - Provider: jest.fn(({ children }) => children), -})); - -jest.mock('../useReduxStore', () => ({ - useReduxStore: jest.fn(), -})); - -jest.mock('../configSetup', () => ({ - setUtilsConfig: jest.fn(), -})); - -jest.mock('../k8s/api-discovery/api-discovery', () => ({ - initApiDiscovery: jest.fn(), -})); - -const { useReduxStore: useReduxStoreMock } = hooks as jest.Mocked; -const mockStore = configureMockStore([thunk]); -const store = mockStore({}); -const mockProvider = (reactRedux as jest.Mocked).Provider; -const mockConfig = { appFetch: jest.fn() }; -const mockApiDiscoveryConfig = { apiDiscovery: jest.fn(), appFetch: jest.fn() }; - -describe('AppInitSDK', () => { - const renderAppInitSDK = (config, storeContext = { store, storeContextPresent: true }) => { - useReduxStoreMock.mockReturnValue(storeContext); - return render( - -
Hello, OpenShift!
-
, - ); - }; - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should not wrap children with Provider', () => { - renderAppInitSDK(mockConfig, { store, storeContextPresent: true }); - - expect(mockProvider).not.toHaveBeenCalled(); - expect(screen.getByText('Hello, OpenShift!')).toBeVisible(); - }); - - it('should wrap children with a Provider if no store context is present', () => { - renderAppInitSDK(mockConfig, { store, storeContextPresent: false }); - - expect(mockProvider).toHaveBeenCalled(); - expect(screen.getByText('Hello, OpenShift!')).toBeVisible(); - }); - - it('should call the useReduxStore hook', () => { - renderAppInitSDK(mockConfig, { store, storeContextPresent: true }); - - expect(useReduxStoreMock).toHaveBeenCalledTimes(1); - }); - - it('should call the setUtilsConfig utility with the proper config', () => { - renderAppInitSDK(mockConfig, { store, storeContextPresent: true }); - - expect(configSetup.setUtilsConfig).toHaveBeenCalledTimes(1); - expect(configSetup.setUtilsConfig).toHaveBeenCalledWith({ appFetch: mockConfig.appFetch }); - }); - - it('should call the provided apiDiscovery function if it exists', () => { - renderAppInitSDK(mockApiDiscoveryConfig, { store, storeContextPresent: true }); - - expect(mockApiDiscoveryConfig.apiDiscovery).toHaveBeenCalledTimes(1); - expect(mockApiDiscoveryConfig.apiDiscovery).toHaveBeenCalledWith(store); - expect(apiDiscovery.initApiDiscovery).not.toHaveBeenCalled(); - }); - - it('should trigger the default initApiDiscovery if no apiDiscovery function is provided', () => { - renderAppInitSDK(mockConfig, { store, storeContextPresent: false }); - - expect(apiDiscovery.initApiDiscovery).toHaveBeenCalledTimes(1); - expect(apiDiscovery.initApiDiscovery).toHaveBeenCalledWith(store); - }); -}); diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/app/__tests__/configSetup.spec.ts b/frontend/packages/console-dynamic-plugin-sdk/src/app/__tests__/configSetup.spec.ts deleted file mode 100644 index 23d79ca4658..00000000000 --- a/frontend/packages/console-dynamic-plugin-sdk/src/app/__tests__/configSetup.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { setUtilsConfig, getUtilsConfig } from '../configSetup'; - -describe('configSetup', () => { - it('getUtilsConfig should throw an error if config is not set', () => { - try { - getUtilsConfig(); - } catch (e) { - expect(e.message).toEqual('setUtilsConfig has not been called'); - } - }); - - it('getUtilsConfig should provide the config if config has been set', () => { - const configOptions = { appFetch: jest.fn() }; - setUtilsConfig(configOptions); - const configData = getUtilsConfig(); - expect(configData).toEqual(configOptions); - }); - - it('should throw an error if setUtilsConfig is called and config is already defined', () => { - try { - const configOptions = { appFetch: jest.fn() }; - setUtilsConfig(configOptions); - } catch (e) { - expect(e.message).toEqual('setUtilsConfig has already been called'); - } - }); -}); diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/app/configSetup.ts b/frontend/packages/console-dynamic-plugin-sdk/src/app/configSetup.ts deleted file mode 100644 index 0e86f65074d..00000000000 --- a/frontend/packages/console-dynamic-plugin-sdk/src/app/configSetup.ts +++ /dev/null @@ -1,39 +0,0 @@ -export type UtilsConfig = { - /** - * Resource fetch implementation provided by the host application. - * - * Applications must validate the response before resolving the Promise. - * - * If the request cannot be completed successfully, the Promise should be rejected - * with an appropriate error. - */ - appFetch: (url: string, options?: RequestInit) => Promise; -}; - -let config: UtilsConfig | undefined; - -/** - * Set the {@link UtilsConfig} reference. - * - * This must be done before using any of the Kubernetes utilities. - */ -export const setUtilsConfig = (c: UtilsConfig) => { - if (config !== undefined) { - throw new Error('setUtilsConfig has already been called'); - } - - config = Object.freeze({ ...c }); -}; - -/** - * Get the {@link UtilsConfig} reference. - * - * Throws an error if the reference isn't already set. - */ -export const getUtilsConfig = (): UtilsConfig => { - if (config === undefined) { - throw new Error('setUtilsConfig has not been called'); - } - - return config; -}; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/app/index.ts b/frontend/packages/console-dynamic-plugin-sdk/src/app/index.ts index fba49e4b67d..bade1ec813a 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/app/index.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/app/index.ts @@ -1,4 +1,3 @@ -export { default as AppInitSDK } from './AppInitSDK'; export { SDKReducers } from './redux'; export * from './components'; export * from './core'; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/app/useReduxStore.ts b/frontend/packages/console-dynamic-plugin-sdk/src/app/useReduxStore.ts deleted file mode 100644 index 37248272958..00000000000 --- a/frontend/packages/console-dynamic-plugin-sdk/src/app/useReduxStore.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useState, useMemo } from 'react'; -import { useStore } from 'react-redux'; -import { applyMiddleware, combineReducers, createStore, compose, Store } from 'redux'; -import thunk from 'redux-thunk'; -import { SDKReducers } from './redux'; -import { SDKStoreState } from './redux-types'; -import storeHandler from './storeHandler'; - -const composeEnhancers = - (process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose; - -/** - * `useReduxStore` will provide the store instance if present or else create one along with info if the context was present. - * - * @example - * ```ts - * function Component () { - * const {store, storeContextPresent} = useReduxStore() - * return ... - * } - * ``` - */ -export const useReduxStore = (): { store: Store; storeContextPresent: boolean } => { - const storeContext = useStore(); - const [storeContextPresent, setStoreContextPresent] = useState(false); - const store = useMemo(() => { - // check if store exists and if not create it - if (storeContext) { - setStoreContextPresent(true); - storeHandler.setStore(storeContext); - } else { - // eslint-disable-next-line no-console - console.log('Creating the SDK redux store'); - setStoreContextPresent(false); - const storeInstance = createStore( - combineReducers(SDKReducers), - {}, - composeEnhancers(applyMiddleware(thunk)), - ); - storeHandler.setStore(storeInstance); - } - return storeHandler.getStore(); - }, [storeContext]); - - return { store, storeContextPresent }; -}; diff --git a/frontend/public/__tests__/co-fetch.spec.ts b/frontend/packages/console-dynamic-plugin-sdk/src/utils/fetch/__tests__/console-fetch.spec.ts similarity index 89% rename from frontend/public/__tests__/co-fetch.spec.ts rename to frontend/packages/console-dynamic-plugin-sdk/src/utils/fetch/__tests__/console-fetch.spec.ts index d90e94898eb..12d298c40f6 100644 --- a/frontend/public/__tests__/co-fetch.spec.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/utils/fetch/__tests__/console-fetch.spec.ts @@ -1,9 +1,8 @@ -import { RetryError } from '@console/dynamic-plugin-sdk/src/utils/error/http-error'; -import { consoleFetch } from '@console/dynamic-plugin-sdk/src/utils/fetch'; -import { setUtilsConfig } from '@console/dynamic-plugin-sdk/src/app/configSetup'; -import { shouldLogout, validateStatus, appInternalFetch } from '../co-fetch'; +import { RetryError } from '../../error/http-error'; +import { consoleFetch } from '../console-fetch'; +import { shouldLogout, validateStatus } from '../console-fetch-utils'; -describe('coFetch', () => { +describe('consoleFetch', () => { const json = async () => ({ details: { kind: 'clusterresourcequotas', @@ -80,7 +79,6 @@ describe('coFetch', () => { window.fetch = jest.fn(() => Promise.resolve({ status: 404, headers: emptyHeaders } as Response), ); - setUtilsConfig({ appFetch: appInternalFetch }); try { await consoleFetch(''); } catch { diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/utils/fetch/console-fetch-utils.ts b/frontend/packages/console-dynamic-plugin-sdk/src/utils/fetch/console-fetch-utils.ts index be80ef025b5..fc3c5cfe5d7 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/utils/fetch/console-fetch-utils.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/utils/fetch/console-fetch-utils.ts @@ -1,5 +1,6 @@ import { getImpersonate } from '../../app/core/reducers'; import storeHandler from '../../app/storeHandler'; +import { HttpError, RetryError } from '../error/http-error'; type ConsoleRequestHeaders = { 'Impersonate-Group'?: string | string[]; @@ -86,3 +87,136 @@ export const normalizeConsoleHeaders = ( return normalized; }; + +/** + * A utility function to apply console-specific headers to the provided fetch options. + * @returns Modified `options` object with additional request headers. + */ +export const applyConsoleHeaders = (url: string, options: RequestInit): RequestInit => { + const consoleHeaders = getConsoleRequestHeaders(); + + if (!options.headers) { + options.headers = {}; + } + + // Apply console headers, handling array values for multiple headers + Object.entries(consoleHeaders || {}).forEach(([key, value]) => { + if (Array.isArray(value)) { + // For multiple Impersonate-Group headers, we need special handling + // because fetch() API combines them into a single comma-separated header + // which doesn't work for Kubernetes impersonation + if (key === 'Impersonate-Group') { + // Send as a special header that the backend will split + options.headers['X-Console-Impersonate-Groups'] = value.join(','); + } else { + // For other array headers, store as array + options.headers[key] = value; + } + } else if (value) { + options.headers[key] = value; + } + }); + + // X-CSRFToken is used only for non-GET requests targeting bridge + if (options.method === 'GET' || url.indexOf('://') >= 0) { + delete options.headers['X-CSRFToken']; + } + + return options; +}; + +// TODO: url can be url or path, but shouldLogout only handles paths +export const shouldLogout = (url: string): boolean => { + const k8sRegex = new RegExp(`^${window.SERVER_FLAGS.basePath}api/kubernetes/`); + // 401 from k8s. show logout screen + if (k8sRegex.test(url)) { + // Don't let 401s from proxied services log out users + const proxyRegex = new RegExp(`^${window.SERVER_FLAGS.basePath}api/kubernetes/api/v1/proxy/`); + if (proxyRegex.test(url)) { + return false; + } + const serviceRegex = new RegExp( + `^${window.SERVER_FLAGS.basePath}api/kubernetes/api/v1/namespaces/\\w+/services/\\w+/proxy/`, + ); + if (serviceRegex.test(url)) { + return false; + } + return true; + } + return false; +}; + +export const validateStatus = async ( + response: Response, + url: string, + method: string, + retry: boolean, +) => { + if (response.ok || response.status === 304) { + return response; + } + + if (retry && response.status === 429) { + throw new RetryError(); + } + + if (response.status === 401 && shouldLogout(url)) { + const next = window.location.pathname + window.location.search + window.location.hash; + // We can't use regular import from outside this package, so a dynamic import is required + // This also breaks a nasty cycle - authSvc.logout calls coFetch (which calls validateStatus) + import('@console/internal/module/auth') + .then((m) => m.authSvc) + .then((authSvc) => { + authSvc.logout(next); + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.error('Error during logout after 401 response', e); + }); + } + + const contentType = response.headers.get('content-type'); + if (!contentType || contentType.indexOf('json') === -1) { + throw new HttpError(response.statusText, response.status, response); + } + + if (response.status === 403) { + return response.json().then((json) => { + throw new HttpError( + json.message || 'Access denied due to cluster policy.', + response.status, + response, + json, + ); + }); + } + + return response.json().then((json) => { + // retry 409 conflict errors due to ClustResourceQuota / ResourceQuota + // https://bugzilla.redhat.com/show_bug.cgi?id=1920699 + if ( + retry && + method === 'POST' && + response.status === 409 && + ['resourcequotas', 'clusterresourcequotas'].includes(json.details?.kind) + ) { + throw new RetryError(); + } + const cause = json.details?.causes?.[0]; + let reason; + if (cause) { + reason = `Error "${cause.message}" for field "${cause.field}".`; + } + if (!reason) { + reason = json.message; + } + if (!reason) { + reason = json.error; + } + if (!reason) { + reason = response.statusText; + } + + throw new HttpError(reason, response.status, response, json); + }); +}; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/utils/fetch/console-fetch.ts b/frontend/packages/console-dynamic-plugin-sdk/src/utils/fetch/console-fetch.ts index 94199c4b80d..a1c5243d887 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/utils/fetch/console-fetch.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/utils/fetch/console-fetch.ts @@ -1,31 +1,67 @@ import * as _ from 'lodash'; -import { getUtilsConfig } from '../../app/configSetup'; import { setAdmissionWebhookWarning } from '../../app/core/actions'; import storeHandler from '../../app/storeHandler'; -import { ConsoleFetchText, ConsoleFetchJSON, ConsoleFetch } from '../../extensions/console-types'; -import { TimeoutError } from '../error/http-error'; -import { getConsoleRequestHeaders, normalizeConsoleHeaders } from './console-fetch-utils'; +import { ConsoleFetchJSON, ConsoleFetchText, ConsoleFetch } from '../../extensions/console-types'; +import { RetryError, TimeoutError } from '../error/http-error'; +import { + applyConsoleHeaders, + getConsoleRequestHeaders, + normalizeConsoleHeaders, + validateStatus, +} from './console-fetch-utils'; + +const initDefaults = { + headers: {}, + credentials: 'same-origin', +}; /** * A custom wrapper around `fetch` that adds console-specific headers and allows for retries and timeouts. * It also validates the response status code and throws an appropriate error or logs out the user if required. - * @param url The URL to fetch - * @param options The options to pass to fetch - * @param timeout The timeout in milliseconds + * @param url - The URL to fetch + * @param options - The options to pass to fetch + * @param timeout - The timeout in milliseconds * @returns A promise that resolves to the response. */ export const consoleFetch: ConsoleFetch = async (url, options = {}, timeout = 60000) => { - const fetchPromise = getUtilsConfig().appFetch(url, options); + const op1 = applyConsoleHeaders(url, options); + const allOptions = _.defaultsDeep({}, initDefaults, op1); + + const fetchPromise = async () => { + let res: Response; + let attempt = 0; + let retry = true; + + while (retry) { + retry = false; + attempt++; + try { + // eslint-disable-next-line no-await-in-loop, no-loop-func + res = await fetch(url, allOptions).then((resp) => + validateStatus(resp, url, allOptions.method, attempt < 3), + ); + } catch (e) { + if (e instanceof RetryError) { + retry = true; + } else { + // eslint-disable-next-line no-console + console.warn(`consoleFetch failed for url ${url}`, e); + throw e; + } + } + } + return res; + }; if (timeout <= 0) { - return fetchPromise; + return fetchPromise(); } - const timeoutPromise = new Promise((resolve, reject) => { + const timeoutPromise = new Promise((_resolve, reject) => { setTimeout(() => reject(new TimeoutError(url, timeout)), timeout); }); - return Promise.race([fetchPromise, timeoutPromise]); + return Promise.race([fetchPromise(), timeoutPromise]); }; const parseData = async (response) => { diff --git a/frontend/public/co-fetch.ts b/frontend/public/co-fetch.ts index 499a0d08d5b..3328b3eb4f3 100644 --- a/frontend/public/co-fetch.ts +++ b/frontend/public/co-fetch.ts @@ -1,159 +1,3 @@ -import * as _ from 'lodash'; -import { HttpError, RetryError } from '@console/dynamic-plugin-sdk/src/utils/error/http-error'; -import { authSvc } from './module/auth'; -import { getConsoleRequestHeaders } from '@console/dynamic-plugin-sdk/src/utils/fetch/console-fetch-utils'; - -export const applyConsoleHeaders = (url, options) => { - const consoleHeaders = getConsoleRequestHeaders(); - - if (!options.headers) { - options.headers = {}; - } - - // Apply console headers, handling array values for multiple headers - Object.entries(consoleHeaders || {}).forEach(([key, value]) => { - if (Array.isArray(value)) { - // For multiple Impersonate-Group headers, we need special handling - // because fetch() API combines them into a single comma-separated header - // which doesn't work for Kubernetes impersonation - if (key === 'Impersonate-Group') { - // Send as a special header that the backend will split - options.headers['X-Console-Impersonate-Groups'] = value.join(','); - } else { - // For other array headers, store as array - options.headers[key] = value; - } - } else if (value) { - options.headers[key] = value; - } - }); - - // X-CSRFToken is used only for non-GET requests targeting bridge - if (options.method === 'GET' || url.indexOf('://') >= 0) { - delete options.headers['X-CSRFToken']; - } - return options; -}; - -// TODO: url can be url or path, but shouldLogout only handles paths -export const shouldLogout = (url: string): boolean => { - const k8sRegex = new RegExp(`^${window.SERVER_FLAGS.basePath}api/kubernetes/`); - // 401 from k8s. show logout screen - if (k8sRegex.test(url)) { - // Don't let 401s from proxied services log out users - const proxyRegex = new RegExp(`^${window.SERVER_FLAGS.basePath}api/kubernetes/api/v1/proxy/`); - if (proxyRegex.test(url)) { - return false; - } - const serviceRegex = new RegExp( - `^${window.SERVER_FLAGS.basePath}api/kubernetes/api/v1/namespaces/\\w+/services/\\w+/proxy/`, - ); - if (serviceRegex.test(url)) { - return false; - } - return true; - } - return false; -}; - -export const validateStatus = async ( - response: Response, - url: string, - method: string, - retry: boolean, -) => { - if (response.ok || response.status === 304) { - return response; - } - - if (retry && response.status === 429) { - throw new RetryError(); - } - - if (response.status === 401 && shouldLogout(url)) { - const next = window.location.pathname + window.location.search + window.location.hash; - authSvc.logout(next); - } - - const contentType = response.headers.get('content-type'); - if (!contentType || contentType.indexOf('json') === -1) { - throw new HttpError(response.statusText, response.status, response); - } - - if (response.status === 403) { - return response.json().then((json) => { - throw new HttpError( - json.message || 'Access denied due to cluster policy.', - response.status, - response, - json, - ); - }); - } - - return response.json().then((json) => { - // retry 409 conflict errors due to ClustResourceQuota / ResourceQuota - // https://bugzilla.redhat.com/show_bug.cgi?id=1920699 - if ( - retry && - method === 'POST' && - response.status === 409 && - ['resourcequotas', 'clusterresourcequotas'].includes(json.details?.kind) - ) { - throw new RetryError(); - } - const cause = json.details?.causes?.[0]; - let reason; - if (cause) { - reason = `Error "${cause.message}" for field "${cause.field}".`; - } - if (!reason) { - reason = json.message; - } - if (!reason) { - reason = json.error; - } - if (!reason) { - reason = response.statusText; - } - - throw new HttpError(reason, response.status, response, json); - }); -}; - -const initDefaults = { - headers: {}, - credentials: 'same-origin', -}; - -export const appInternalFetch = async (url: string, options: RequestInit): Promise => { - let attempt = 0; - let response: Response; - let retry = true; - - const op1 = applyConsoleHeaders(url, options); - const allOptions = _.defaultsDeep({}, initDefaults, op1); - - while (retry) { - retry = false; - attempt++; - try { - response = await fetch(url, allOptions).then((resp) => - validateStatus(resp, url, allOptions.method, attempt < 3), - ); - } catch (e) { - if (e instanceof RetryError) { - retry = true; - } else { - // eslint-disable-next-line no-console - console.warn(`consoleFetch failed for url ${url}`, e); - throw e; - } - } - } - return response; -}; - export { consoleFetch as coFetch, consoleFetchJSON as coFetchJSON, diff --git a/frontend/public/components/app.tsx b/frontend/public/components/app.tsx index 2857c69ca6d..2849d9b5670 100644 --- a/frontend/public/components/app.tsx +++ b/frontend/public/components/app.tsx @@ -13,7 +13,6 @@ import store, { applyReduxExtensions, RootState } from '../redux'; import { useTranslation } from 'react-i18next'; import type { LoadedAndResolvedExtension } from '@openshift/dynamic-plugin-sdk'; import { PluginStoreProvider } from '@openshift/dynamic-plugin-sdk'; -import { appInternalFetch } from '../co-fetch'; import { detectFeatures } from '../actions/features'; import { setFlag } from '../actions/flags'; import AppContents from './app-contents'; @@ -41,13 +40,11 @@ import { isContextProvider, isReduxReducer, isStandaloneRoutePage, - AppInitSDK, getUser, useActivePerspective, ReduxReducer, ContextProvider, } from '@console/dynamic-plugin-sdk'; -import { initConsolePlugins } from '@console/dynamic-plugin-sdk/src/runtime/plugin-init'; import { GuidedTour } from '@console/app/src/components/tour'; import { QuickStartDrawer } from '@console/app/src/components/quick-starts/QuickStartDrawer'; import { ModalProvider } from '@console/dynamic-plugin-sdk/src/app/modal-support/ModalProvider'; @@ -453,16 +450,6 @@ const updateSwaggerDefinitionContinual = () => { }, 5 * 60 * 1000); }; -/** - * loading dynamic plugins from PluginStore has a dependency on coFetch, which - * only works after `AppInitSDK` has set the fetch implementation. - * - * TODO: Find a way to load this earlier in the app lifecycle - * TODO: Revert HAC-375 so coFetch isn't sourced via dependency injection (???) - */ -const initDynamicPlugins = () => { - initConsolePlugins(pluginStore); -}; // Load cached API resources from localStorage to speed up page load. const initApiDiscovery = (storeInstance) => { getCachedResources() @@ -481,6 +468,8 @@ graphQLReady.onReady(() => { const { productName } = getBrandingDetails(); store.dispatch(detectFeatures()); + initApiDiscovery(store); + // Global timer to ensure all components update in sync setInterval(() => store.dispatch(UIActions.updateTimestamps(Date.now())), 10000); @@ -542,19 +531,11 @@ graphQLReady.onReady(() => { - - - - - - - + + + + + diff --git a/frontend/public/module/auth.js b/frontend/public/module/auth.ts similarity index 91% rename from frontend/public/module/auth.js rename to frontend/public/module/auth.ts index 2a02f787b5f..2311e804f1c 100644 --- a/frontend/public/module/auth.js +++ b/frontend/public/module/auth.ts @@ -15,18 +15,18 @@ export const LOGIN_ERROR_PATH = loginErrorURL ? new URL(loginErrorURL, window.location.href).pathname : ''; -const isLoginErrorPath = (path) => path && path === LOGIN_ERROR_PATH; +const isLoginErrorPath = (path: string) => path && path === LOGIN_ERROR_PATH; -const loginState = (key) => localStorage.getItem(key); +const loginState = (key: string) => localStorage.getItem(key); -const loginStateItem = (key) => loginState(key); +const loginStateItem = (key: string) => loginState(key); const userID = 'userID'; const name = 'name'; const email = 'email'; const clearLocalStorageKeys = [userID, name, email]; -const setNext = (next) => { +const setNext = (next: string) => { if (!next) { return; } @@ -40,7 +40,7 @@ const setNext = (next) => { } }; -const clearLocalStorage = (keys) => { +const clearLocalStorage = (keys: string[]) => { keys.forEach((key) => { try { localStorage.removeItem(key); @@ -123,7 +123,7 @@ export const authSvc = { // Ensure that we don't redirect to the current URL in a loop // when using local bridge in development mode without authorization. if (![window.location.href, window.location.pathname].includes(loginURL)) { - window.location = loginURL; + window.location.assign(loginURL); } }, }; diff --git a/frontend/public/plugins.ts b/frontend/public/plugins.ts index cfe176b9893..129de945ebc 100644 --- a/frontend/public/plugins.ts +++ b/frontend/public/plugins.ts @@ -9,6 +9,7 @@ import { consoleFetch } from '@console/dynamic-plugin-sdk/src/utils/fetch/consol import { ValidationResult } from '@console/dynamic-plugin-sdk/src/validation/ValidationResult'; import { REMOTE_ENTRY_CALLBACK } from '@console/dynamic-plugin-sdk/src/constants'; import { noop } from 'lodash'; +import { initConsolePlugins } from '@console/dynamic-plugin-sdk/src/runtime/plugin-init'; /** * Set by `console-operator` or `./bin/bridge -release-version`. If this is @@ -115,6 +116,8 @@ export const pluginStore = new PluginStore({ localPlugins.forEach((plugin) => pluginStore.loadPlugin(plugin)); +initConsolePlugins(pluginStore); + /** Redux middleware that updates PluginStore FeatureFlags when redux actions are dispatched. */ export const featureFlagMiddleware: Middleware<{}, RootState> = (s) => { let prevFlags: RootState['FLAGS'] | undefined; diff --git a/frontend/public/redux.ts b/frontend/public/redux.ts index 9fa683bea99..506405ea9d1 100644 --- a/frontend/public/redux.ts +++ b/frontend/public/redux.ts @@ -13,6 +13,7 @@ import { featureReducer, featureReducerName } from './reducers/features'; import ObserveReducers, { ObserveState } from './reducers/observe'; import UIReducers, { UIState } from './reducers/ui'; import { dashboardsReducer, DashboardsState } from './reducers/dashboards'; +import storeHandler from '@console/dynamic-plugin-sdk/src/app/storeHandler'; const composeEnhancers = (process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose; @@ -41,6 +42,9 @@ const store = createStore( composeEnhancers(applyMiddleware(thunk, featureFlagMiddleware)), ); +// Provides redux store object to SDK components that can't import from here +storeHandler.setStore(store); + export const applyReduxExtensions = (reducerExtensions: ResolvedExtension[]) => { const pluginReducers: ReducersMapObject = {};