diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index e4fcd83629c..b7e07a4ff06 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -428,6 +428,7 @@ "Node status": "Node status", "This node's {{conditionDescription}}. Performance may be degraded.": "This node's {{conditionDescription}}. Performance may be degraded.", "<0>To use host binaries, run <1>chroot /host": "<0>To use host binaries, run <1>chroot /host", + "Failed to load pod": "Failed to load pod", "The debug pod failed. ": "The debug pod failed. ", "This node has requested to join the cluster. After approving its certificate signing request the node will begin running workloads.": "This node has requested to join the cluster. After approving its certificate signing request the node will begin running workloads.", "This node has a pending server certificate signing request. Approve the request to enable all networking functionality on this node.": "This node has a pending server certificate signing request. Approve the request to enable all networking functionality on this node.", diff --git a/frontend/packages/console-app/src/actions/hooks/useBindingActions.ts b/frontend/packages/console-app/src/actions/hooks/useBindingActions.ts index 0693f3bd704..bb979972745 100644 --- a/frontend/packages/console-app/src/actions/hooks/useBindingActions.ts +++ b/frontend/packages/console-app/src/actions/hooks/useBindingActions.ts @@ -45,11 +45,11 @@ export const useBindingActions = ( const navigate = useNavigate(); const [commonActions] = useCommonActions(model, obj, [CommonActionCreator.Delete] as const); - const { subjectIndex, subjects = [] } = obj; + const { subjectIndex, subjects } = obj ?? {}; const subject = subjects?.[subjectIndex]; const deleteBindingSubject = useWarningModal({ title: t('public~Delete {{label}} subject?', { - label: model.kind, + label: model?.kind, }), children: t('public~Are you sure you want to delete subject {{name}} of type {{kind}}?', { name: subject?.name, @@ -146,9 +146,9 @@ export const useBindingActions = ( : []), factory.DuplicateBinding(), factory.EditBindingSubject(), - ...(subjects.length === 1 ? [commonActions.Delete] : [factory.DeleteBindingSubject()]), + ...(subjects?.length === 1 ? [commonActions.Delete] : [factory.DeleteBindingSubject()]), ]; - }, [memoizedFilterActions, subject?.kind, factory, subjects.length, commonActions.Delete]); + }, [memoizedFilterActions, subject?.kind, factory, subjects?.length, commonActions.Delete]); return actions; }; diff --git a/frontend/packages/console-app/src/components/nodes/NodeTerminal.tsx b/frontend/packages/console-app/src/components/nodes/NodeTerminal.tsx index 54d80780a55..e33c2ff49a5 100644 --- a/frontend/packages/console-app/src/components/nodes/NodeTerminal.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodeTerminal.tsx @@ -2,10 +2,10 @@ import type { ReactNode, FC } from 'react'; import { useState, useEffect } from 'react'; import { Alert } from '@patternfly/react-core'; import { useTranslation, Trans } from 'react-i18next'; +import { WatchK8sResource } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource'; import { PodConnectLoader } from '@console/internal/components/pod'; -import { Firehose } from '@console/internal/components/utils/firehose'; import { LoadingBox } from '@console/internal/components/utils/status-box'; -import type { FirehoseResource, FirehoseResult } from '@console/internal/components/utils/types'; import { ImageStreamTagModel, NamespaceModel, PodModel } from '@console/internal/models'; import { NodeKind, PodKind, k8sCreate, k8sGet, k8sKillByName } from '@console/internal/module/k8s'; import PaneBody from '@console/shared/src/components/layout/PaneBody'; @@ -15,7 +15,9 @@ type NodeTerminalErrorProps = { }; type NodeTerminalInnerProps = { - obj?: FirehoseResult; + pod: PodKind | undefined; + loaded: boolean; + loadError: any; }; type NodeTerminalProps = { @@ -124,7 +126,7 @@ const NodeTerminalError: FC = ({ error }) => { ); }; -const NodeTerminalInner: FC = ({ obj }) => { +const NodeTerminalInner: FC = ({ pod, loaded, loadError }) => { const { t } = useTranslation(); const message = ( @@ -133,32 +135,44 @@ const NodeTerminalInner: FC = ({ obj }) => {

); - switch (obj?.data?.status?.phase) { + + if (loadError) { + return ; + } + + if (!loaded || !pod) { + return ; + } + + switch (pod.status?.phase) { case 'Failed': return ( {t('console-app~The debug pod failed. ')} - {obj?.data?.status?.containerStatuses?.[0]?.state?.terminated?.message || - obj?.data?.status?.message} + {pod.status?.containerStatuses?.[0]?.state?.terminated?.message || + pod.status?.message} } /> ); case 'Running': - return ; + return ; default: return ; } }; const NodeTerminal: FC = ({ obj: node }) => { - const [resources, setResources] = useState([]); + const [isCreatingPod, setIsCreatingPod] = useState(true); + const [podWatchResource, setPodWatchResource] = useState(null); const [errorMessage, setErrorMessage] = useState(''); const nodeName = node.metadata.name; const isWindows = node.status?.nodeInfo?.operatingSystem === 'windows'; + const [pod, loaded, loadError] = useK8sWatchResource(podWatchResource); + useEffect(() => { let namespace; const name = `${nodeName?.replace(/\./g, '-')}-debug`; @@ -196,18 +210,17 @@ const NodeTerminal: FC = ({ obj: node }) => { await new Promise((resolve) => setTimeout(resolve, 1000)); const debugPod = await k8sCreate(PodModel, podToCreate); if (debugPod) { - setResources([ - { - isList: false, - kind: 'Pod', - name, - namespace: namespace.metadata.name, - prop: 'obj', - }, - ]); + setPodWatchResource({ + kind: 'Pod', + name, + namespace: namespace.metadata.name, + isList: false, + }); + setIsCreatingPod(false); } } catch (e) { setErrorMessage(e.message); + setIsCreatingPod(false); if (namespace) { deleteNamespace(namespace.metadata.name); } @@ -221,13 +234,15 @@ const NodeTerminal: FC = ({ obj: node }) => { }; }, [nodeName, isWindows]); - return errorMessage ? ( - - ) : ( - - - - ); + if (errorMessage) { + return ; + } + + if (isCreatingPod) { + return ; + } + + return ; }; export default NodeTerminal; diff --git a/frontend/packages/helm-plugin/src/components/details-page/resources/__tests__/HelmReleaseResources.spec.tsx b/frontend/packages/helm-plugin/src/components/details-page/resources/__tests__/HelmReleaseResources.spec.tsx index dce6f4187f4..f142ccb8eca 100644 --- a/frontend/packages/helm-plugin/src/components/details-page/resources/__tests__/HelmReleaseResources.spec.tsx +++ b/frontend/packages/helm-plugin/src/components/details-page/resources/__tests__/HelmReleaseResources.spec.tsx @@ -1,6 +1,7 @@ import type { ComponentProps } from 'react'; import { screen } from '@testing-library/react'; import * as ReactRouter from 'react-router-dom-v5-compat'; +import * as k8sWatchHook from '@console/internal/components/utils/k8s-watch-hook'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; import { mockHelmReleases } from '../../../__tests__/helm-release-mock-data'; import HelmReleaseResources from '../HelmReleaseResources'; @@ -10,15 +11,28 @@ jest.mock('react-router-dom-v5-compat', () => ({ useParams: jest.fn(), })); +jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ + useK8sWatchResources: jest.fn(() => ({})), + useK8sWatchResource: jest.fn(() => [null, true, null]), +})); + +const mockUseK8sWatchResources = k8sWatchHook.useK8sWatchResources as jest.Mock; + describe('HelmReleaseResources', () => { const helmReleaseResourcesProps: ComponentProps = { customData: mockHelmReleases[0], }; - it('should render the MultiListPage component', () => { + it('should render the MultiListPage component and display empty state when no resources exist', () => { jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ ns: 'test-helm' }); + + // mockHelmReleases[0] has an empty manifest, so no resources to watch renderWithProviders(); - // MultiListPage typically renders a list/table of resources - expect(screen.getByText('No Resources found')).toBeTruthy(); + + // Verify useK8sWatchResources hook was called (confirms migration from Firehose to hooks) + expect(mockUseK8sWatchResources).toHaveBeenCalled(); + + // Verify empty state message is displayed (user-visible content) + expect(screen.getByText('No Resources found')).toBeVisible(); }); }); diff --git a/frontend/packages/operator-lifecycle-manager-v1/locales/en/olm-v1.json b/frontend/packages/operator-lifecycle-manager-v1/locales/en/olm-v1.json index d61b0f1ff4d..76a1e8430bc 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/locales/en/olm-v1.json +++ b/frontend/packages/operator-lifecycle-manager-v1/locales/en/olm-v1.json @@ -48,6 +48,7 @@ "An error occurred. Please try again.": "An error occurred. Please try again.", "Create ServiceAccount": "Create ServiceAccount", "An error occurred": "An error occurred", + "Select service account": "Select service account", "Operator Lifecycle Management version 1": "Operator Lifecycle Management version 1", "Learn more about OLMv1": "Learn more about OLMv1", "With OLMv1, you'll get a much simpler API that's easier to work with and understand. Plus, you have more direct control over updates. You can define update ranges and decide exactly how they are rolled out.": "With OLMv1, you'll get a much simpler API that's easier to work with and understand. Plus, you have more direct control over updates. You can define update ranges and decide exactly how they are rolled out.", diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/components/cluster-extension/ServiceAccountDropdown.tsx b/frontend/packages/operator-lifecycle-manager-v1/src/components/cluster-extension/ServiceAccountDropdown.tsx index b478e1568ad..1c7ed3b2577 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/src/components/cluster-extension/ServiceAccountDropdown.tsx +++ b/frontend/packages/operator-lifecycle-manager-v1/src/components/cluster-extension/ServiceAccountDropdown.tsx @@ -65,7 +65,7 @@ export const ServiceAccountDropdown: FC = ({ : [] } desc={ServiceAccountModel.label} - placeholder={t('public~Select service account')} + placeholder={t('olm-v1~Select service account')} selectedKey={selectedKey} selectedKeyKind={ServiceAccountModel.kind} onChange={handleOnChange} diff --git a/frontend/public/components/RBAC/bindings.tsx b/frontend/public/components/RBAC/bindings.tsx index 2083c656991..ad9398727eb 100644 --- a/frontend/public/components/RBAC/bindings.tsx +++ b/frontend/public/components/RBAC/bindings.tsx @@ -47,7 +47,6 @@ import { TableColumn } from '@console/internal/module/k8s'; import { GetDataViewRows, ResourceFilters } from '@console/app/src/components/data-view/types'; import { tableFilters } from '../factory/table-filters'; import { ButtonBar } from '../utils/button-bar'; -import { Firehose } from '../utils/firehose'; import { getQueryArgument } from '../utils/router'; import { kindObj } from '../utils/inject'; import type { ListDropdownProps } from '../utils/list-dropdown'; @@ -57,7 +56,7 @@ import { ResourceName } from '../utils/resource-icon'; import { StatusBox, LoadingBox } from '../utils/status-box'; import { useAccessReview } from '../utils/rbac'; import { flagPending } from '../../reducers/features'; -import { useK8sWatchResources } from '../utils/k8s-watch-hook'; +import { useK8sWatchResource, useK8sWatchResources } from '../utils/k8s-watch-hook'; // Split each binding into one row per subject export const flatten = (resources): BindingKind[] => @@ -185,18 +184,19 @@ const bindingType = (binding: BindingKind) => { if (!binding) { return undefined; } - if (binding.roleRef.name.startsWith('system:')) { + if (binding.roleRef?.name?.startsWith('system:')) { return 'system'; } - return binding.metadata.namespace ? 'namespace' : 'cluster'; + return binding.metadata?.namespace ? 'namespace' : 'cluster'; }; const getDataViewRows: GetDataViewRows = (data, columns) => { - return data.map(({ obj: binding }) => { + return data.map((row) => { + const binding = row.obj; const rowCells = { [tableColumnInfo[0].id]: { cell: , - props: getNameCellProps(binding.metadata.name), + props: getNameCellProps(binding.metadata?.name), }, [tableColumnInfo[1].id]: { cell: , @@ -208,7 +208,7 @@ const getDataViewRows: GetDataViewRows = (data, columns) => { cell: binding.subject.name, }, [tableColumnInfo[4].id]: { - cell: binding.metadata.namespace ? ( + cell: binding.metadata?.namespace ? ( ) : ( i18next.t('public~All namespaces') @@ -362,9 +362,13 @@ export const RoleBindingsPage: FC = ({ const data = useMemo(() => flatten(resources), [resources]); - const loaded = Object.values(resources) - .filter((r) => !r.loadError) - .every((r) => r.loaded); + const loaded = useMemo( + () => + Object.values(resources) + .filter((r) => !r.loadError) + .every((r) => r.loaded), + [resources], + ); return ( <> @@ -784,52 +788,79 @@ const getSubjectIndex = () => { }; const BindingLoadingWrapper: FC = (props) => { + const { obj, loaded, loadError, fixedKeys } = props; const [, setActiveNamespace] = useActiveNamespace(); + + if (!loaded) { + return ; + } + + if (loadError) { + return ; + } + + if (!obj || _.isEmpty(obj)) { + return ; + } + const fixed: { [key: string]: any } = {}; - _.each(props.fixedKeys, (k) => (fixed[k] = _.get(props.obj.data, k))); + fixedKeys.forEach((k) => (fixed[k] = obj?.[k])); + return ( - - - + ); }; export const EditRoleBinding: FC = ({ kind }) => { const { t } = useTranslation(); const params = useParams(); + + const [obj, loaded, loadError] = useK8sWatchResource({ + kind, + name: params.name, + namespace: params.ns, + isList: false, + }); + return ( - - - + ); }; export const CopyRoleBinding: FC = ({ kind }) => { const { t } = useTranslation(); const params = useParams(); + + const [obj, loaded, loadError] = useK8sWatchResource({ + kind, + name: params.name, + namespace: params.ns, + isList: false, + }); + return ( - - - + ); }; @@ -881,9 +912,9 @@ type BindingLoadingWrapperProps = { titleVerbAndKind: string; saveButtonText?: string; isCreate?: boolean; - obj?: { - data: RoleBindingKind | ClusterRoleBindingKind; - }; + obj?: RoleBindingKind | ClusterRoleBindingKind; + loaded: boolean; + loadError: any; }; type EditRoleBindingProps = { diff --git a/frontend/public/components/__tests__/container.spec.tsx b/frontend/public/components/__tests__/container.spec.tsx index a9c44ff8bb4..875cce4515b 100644 --- a/frontend/public/components/__tests__/container.spec.tsx +++ b/frontend/public/components/__tests__/container.spec.tsx @@ -29,8 +29,8 @@ jest.mock('../utils/scroll-to-top-on-mount', () => ({ ScrollToTopOnMount: ({ children }: { children }) => children || null, })); -jest.mock('../utils/firehose', () => ({ - Firehose: jest.fn(({ children }) => children), +jest.mock('../utils/k8s-watch-hook', () => ({ + useK8sWatchResource: jest.fn(() => [null, false, null]), })); const mockUseParams = ReactRouter.useParams as jest.Mock; @@ -38,6 +38,7 @@ const mockUseLocation = ReactRouter.useLocation as jest.Mock; const mockReactRouterUseLocation = useLocation as jest.Mock; const mockUseFavoritesOptions = require('@console/internal/components/useFavoritesOptions') .useFavoritesOptions as jest.Mock; +const mockUseK8sWatchResource = require('../utils/k8s-watch-hook').useK8sWatchResource as jest.Mock; describe('ContainersDetailsPage', () => { beforeEach(() => { @@ -51,6 +52,7 @@ describe('ContainersDetailsPage', () => { }); it('verifies loading state while container data is being fetched', () => { + mockUseK8sWatchResource.mockReturnValue([null, false, null]); renderWithProviders(); expect(screen.getByRole('progressbar', { name: 'Contents' })).toBeVisible(); @@ -83,7 +85,8 @@ describe('ContainerDetails', () => { }); expect(screen.getByText('crash-app')).toBeVisible(); - expect(screen.getByText('Waiting')).toBeVisible(); + // Verify "Waiting" appears in both the page heading and details section + expect(screen.getAllByText('Waiting')).toHaveLength(2); }); it('verifies the 404 error page when user tries to access non-existent container', async () => { diff --git a/frontend/public/components/cluster-settings/cluster-settings.tsx b/frontend/public/components/cluster-settings/cluster-settings.tsx index abb4d6fe163..205935e6969 100644 --- a/frontend/public/components/cluster-settings/cluster-settings.tsx +++ b/frontend/public/components/cluster-settings/cluster-settings.tsx @@ -88,8 +88,6 @@ import { ExternalLink } from '@console/shared/src/components/links/ExternalLink' import { documentationURLs, getDocumentationURL, isManaged } from '../utils/documentation'; import { EmptyBox } from '../utils/status-box'; import { FieldLevelHelp } from '../utils/field-level-help'; -import { Firehose } from '../utils/firehose'; -import type { FirehoseResource } from '../utils/types'; import { HorizontalNav } from '../utils/horizontal-nav'; import { ReleaseNotesLink } from '../utils/release-notes-link'; import { ResourceLink, resourcePathFromModel } from '../utils/resource-link'; @@ -1168,23 +1166,24 @@ export const ClusterSettingsPage: FC = () => { const { t } = useTranslation(); const hasClusterAutoscaler = useFlag(FLAGS.CLUSTER_AUTOSCALER); const title = t('public~Cluster Settings'); - const resources: FirehoseResource[] = [ - { - kind: clusterVersionReference, - name: 'version', - isList: false, - prop: 'obj', - }, - ]; - if (hasClusterAutoscaler) { - resources.push({ - kind: clusterAutoscalerReference, - isList: true, - prop: 'autoscalers', - optional: true, - }); - } - const resourceKeys = _.map(resources, 'prop'); + + const [objData, objLoaded, objLoadError] = useK8sWatchResource({ + kind: clusterVersionReference, + name: 'version', + }); + + const [autoscalersData, autoscalersLoaded, autoscalersLoadError] = useK8sWatchResource< + K8sResourceKind[] + >( + hasClusterAutoscaler + ? { + kind: clusterAutoscalerReference, + isList: true, + } + : null, + ); + + const resourceKeys = hasClusterAutoscaler ? ['obj', 'autoscalers'] : ['obj']; const pages = [ { href: '', @@ -1209,12 +1208,25 @@ export const ClusterSettingsPage: FC = () => { telemetryPrefix: 'Cluster Settings', titlePrefix: title, }; + + const loaded = hasClusterAutoscaler ? objLoaded && autoscalersLoaded : objLoaded; + const loadError = objLoadError || autoscalersLoadError; + + const horizontalNavProps = { + pages, + resourceKeys, + obj: { data: objData, loaded: objLoaded }, + ...(hasClusterAutoscaler && { + autoscalers: { data: autoscalersData, loaded: autoscalersLoaded }, + }), + loaded, + loadError, + }; + return ( {title}} /> - - - + ); }; diff --git a/frontend/public/components/command-line-tools.tsx b/frontend/public/components/command-line-tools.tsx index 0f4a5a234ee..c251793dad5 100644 --- a/frontend/public/components/command-line-tools.tsx +++ b/frontend/public/components/command-line-tools.tsx @@ -10,13 +10,21 @@ import { useCopyLoginCommands } from '@console/shared/src/hooks/useCopyLoginComm import SecondaryHeading from '@console/shared/src/components/heading/SecondaryHeading'; import PaneBody from '@console/shared/src/components/layout/PaneBody'; import { PageHeading } from '@console/shared/src/components/heading/PageHeading'; -import { Firehose } from './utils/firehose'; -import { FirehoseResult } from './utils/types'; -import { connectToFlags } from '../reducers/connectToFlags'; +import { useFlag } from '@console/shared/src/hooks/flag'; import { ConsoleCLIDownloadModel } from '../models'; import { referenceForModel } from '../module/k8s'; import { SyncMarkdownView } from './markdown-view'; import { useCopyCodeModal } from '@console/shared/src/hooks/useCopyCodeModal'; +import { useK8sWatchResource } from './utils/k8s-watch-hook'; +import type { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; + +type CLIDownload = K8sResourceCommon & { + spec: { + displayName: string; + description?: string; + links: { href: string; text?: string }[]; + }; +}; export const CommandLineTools: FC = ({ obj }) => { const { t } = useTranslation(); @@ -26,7 +34,7 @@ export const CommandLineTools: FC = ({ obj }) => { externalLoginCommand, ); const showCopyLoginCommand = requestTokenURL || externalLoginCommand; - const data = _.sortBy(_.get(obj, 'data'), 'spec.displayName'); + const data = _.sortBy(obj.data, 'spec.displayName'); const cliData = _.remove(data, (item) => item.metadata.name === 'oc-cli-downloads'); const additionalCommandLineTools = _.map(cliData.concat(data), (tool, index) => { @@ -82,26 +90,24 @@ export const CommandLineTools: FC = ({ obj }) => { ); }; -export const CommandLineToolsPage = connectToFlags(FLAGS.CONSOLE_CLI_DOWNLOAD)( - ({ flags, ...props }) => { - const resources = flags[FLAGS.CONSOLE_CLI_DOWNLOAD] - ? [ - { - kind: referenceForModel(ConsoleCLIDownloadModel), - isList: true, - prop: 'obj', - }, - ] - : []; +export const CommandLineToolsPage = () => { + const shouldFetch = useFlag(FLAGS.CONSOLE_CLI_DOWNLOAD); + const [cliDownloads, loaded, loadError] = useK8sWatchResource( + shouldFetch + ? { + kind: referenceForModel(ConsoleCLIDownloadModel), + isList: true, + } + : null, + ); - return ( - - - - ); - }, -); + return ; +}; type CommandLineToolsProps = { - obj: FirehoseResult; + obj: { + data: CLIDownload[]; + loaded: boolean; + loadError: any; + }; }; diff --git a/frontend/public/components/console-notifier.tsx b/frontend/public/components/console-notifier.tsx index 575b840d5f2..d6fe2035f53 100644 --- a/frontend/public/components/console-notifier.tsx +++ b/frontend/public/components/console-notifier.tsx @@ -1,30 +1,48 @@ import type { FC } from 'react'; -import * as _ from 'lodash'; import { ExternalLink } from '@console/shared/src/components/links/ExternalLink'; import { Banner, Flex } from '@patternfly/react-core'; import { FLAGS } from '@console/shared/src/constants/common'; -import { connectToFlags, WithFlagsProps } from '../reducers/connectToFlags'; -import { Firehose } from './utils/firehose'; -import { FirehoseResult } from './utils/types'; +import { useFlag } from '@console/shared/src/hooks/flag'; import { referenceForModel } from '../module/k8s'; import { ConsoleNotificationModel } from '../models/index'; +import { useK8sWatchResource } from './utils/k8s-watch-hook'; +import type { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; + +type ConsoleNotification = K8sResourceCommon & { + spec: { + location?: 'BannerTop' | 'BannerBottom' | 'BannerTopBottom'; + backgroundColor?: string; + color?: string; + text: string; + link?: { + href: string; + text?: string; + }; + }; +}; type ConsoleNotifierProps = { location: 'BannerTop' | 'BannerBottom' | 'BannerTopBottom'; }; -type PrivateConsoleNotifierProps = ConsoleNotifierProps & { - obj: FirehoseResult; -}; +export const ConsoleNotifier: FC = ({ location }) => { + const shouldFetch = useFlag(FLAGS.CONSOLE_NOTIFICATION); + const [notifications] = useK8sWatchResource( + shouldFetch + ? { + kind: referenceForModel(ConsoleNotificationModel), + isList: true, + } + : null, + ); -const ConsoleNotifier_: FC = ({ obj, location }) => { - if (_.isEmpty(obj)) { + if (!notifications?.length) { return null; } return ( <> - {_.map(_.get(obj, 'data'), (notification) => + {notifications.map((notification) => notification.spec.location === location || notification.spec.location === 'BannerTopBottom' || // notification.spec.location is optional @@ -41,7 +59,7 @@ const ConsoleNotifier_: FC = ({ obj, location }) =>

{notification.spec.text}{' '} - {_.get(notification.spec, ['link', 'href']) && ( + {notification.spec.link?.href && ( = ({ obj, location }) => ); }; -ConsoleNotifier_.displayName = 'ConsoleNotifier_'; - -export const ConsoleNotifier = connectToFlags( - FLAGS.CONSOLE_NOTIFICATION, -)(({ flags, ...props }) => { - const resources = flags[FLAGS.CONSOLE_NOTIFICATION] - ? [ - { - kind: referenceForModel(ConsoleNotificationModel), - isList: true, - prop: 'obj', - }, - ] - : []; - return ( - - - - ); -}); ConsoleNotifier.displayName = 'ConsoleNotifier'; diff --git a/frontend/public/components/container.tsx b/frontend/public/components/container.tsx index 5b799742d74..d7b1fd43da6 100644 --- a/frontend/public/components/container.tsx +++ b/frontend/public/components/container.tsx @@ -35,7 +35,6 @@ import { getContainerStatus, getPullPolicyLabel, } from '../module/k8s/container'; -import { Firehose } from './utils/firehose'; import { HorizontalNav } from './utils/horizontal-nav'; import { ConsoleEmptyState, LoadingBox } from './utils/status-box'; import { NodeLink, resourcePath, ResourceLink } from './utils/resource-link'; @@ -46,6 +45,7 @@ import { getBreadcrumbPath } from '@console/internal/components/utils/breadcrumb import i18n from 'i18next'; import { ErrorPage404 } from './error'; import { ContainerLastState } from './pod'; +import { useK8sWatchResource } from './utils/k8s-watch-hook'; const formatComputeResources = (resources: ResourceList) => _.map(resources, (v, k) => `${k}: ${v}`).join(', '); @@ -460,21 +460,14 @@ ContainerDetailsList.displayName = 'ContainerDetailsList'; export const ContainersDetailsPage: FC = (props) => { const params = useParams(); - return ( - - - - ); + const [pod, loaded, loadError] = useK8sWatchResource({ + name: params.podName, + namespace: params.ns, + kind: 'Pod', + isList: false, + }); + + return ; }; ContainersDetailsPage.displayName = 'ContainersDetailsPage'; @@ -522,7 +515,7 @@ export const ContainerDetails: FC = (props) => { ); @@ -555,6 +548,7 @@ export type ContainerDetailsListProps = { }; export type ContainerDetailsProps = { - obj?: any; + obj?: { data: PodKind }; loaded?: boolean; + loadError?: any; }; diff --git a/frontend/public/components/create-yaml.tsx b/frontend/public/components/create-yaml.tsx index af03aa21ae3..2ecb12b0f70 100644 --- a/frontend/public/components/create-yaml.tsx +++ b/frontend/public/components/create-yaml.tsx @@ -10,8 +10,8 @@ import { import { getYAMLTemplates } from '../models/yaml-templates'; import { connectToPlural } from '../kinds'; import { AsyncComponent } from './utils/async'; -import { Firehose } from './utils/firehose'; import { LoadingBox } from './utils/status-box'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; import { K8sKind, apiVersionForModel, @@ -119,30 +119,28 @@ export const CreateYAML = (props) => { export const EditYAMLPage: FC = (props) => { const params = useParams(); - const Wrapper = (wrapperProps) => ( + const [obj, loaded, loadError] = useK8sWatchResource({ + kind: props.kind, + name: params.name, + namespace: params.ns, + }); + + if (!loaded && !loadError) { + return ; + } + + if (loadError) { + return ; + } + + return ( import('./edit-yaml').then((c) => c.EditYAML)} create={false} /> ); - return ( - - - - ); }; export type CreateYAMLProps = { diff --git a/frontend/public/components/cron-job.tsx b/frontend/public/components/cron-job.tsx index fce012c7971..45d1d49c92b 100644 --- a/frontend/public/components/cron-job.tsx +++ b/frontend/public/components/cron-job.tsx @@ -24,9 +24,8 @@ import { } from '../module/k8s'; import { ContainerTable } from './utils/container-table'; import { DetailsItem } from './utils/details-item'; -import { Firehose } from './utils/firehose'; -import { FirehoseResourcesResult } from './utils/types'; import { ResourceLink } from './utils/resource-link'; +import { useK8sWatchResources } from './utils/k8s-watch-hook'; import { ResourceSummary } from './utils/details-page'; import { SectionHeading } from './utils/headings'; import { navFactory } from './utils/horizontal-nav'; @@ -216,63 +215,58 @@ export type CronJobPodsComponentProps = { obj: K8sResourceKind; }; -const getJobsWatcher = (namespace: string) => { - return [ - { - prop: 'jobs', +export const CronJobPodsComponent: FC = ({ obj }) => { + const { t } = useTranslation(); + const podFilters = useMemo(() => getPodFilters(t), [t]); + + const resources = useK8sWatchResources<{ + jobs: K8sResourceCommon[]; + pods: PodKind[]; + }>({ + jobs: { isList: true, kind: 'Job', - namespace, + namespace: obj.metadata.namespace, }, - ]; -}; - -const getPodsWatcher = (namespace: string) => { - return [ - ...getJobsWatcher(namespace), - { - prop: 'pods', + pods: { isList: true, kind: 'Pod', - namespace, + namespace: obj.metadata.namespace, }, - ]; -}; + }); + + const flattenedPods = useMemo(() => { + if (!resources.jobs.loaded || !resources.pods.loaded) { + return []; + } + const jobsData = resources.jobs.data ?? []; + const podsData = resources.pods.data ?? []; + + const jobs = jobsData.filter((job) => + job.metadata?.ownerReferences?.find((ref) => ref.uid === obj.metadata.uid), + ); + + return jobs.reduce((acc, job) => { + acc.push( + ...getPodsForResource(job, { + jobs: { data: jobsData, loaded: true }, + pods: { data: podsData, loaded: true }, + }), + ); + return acc; + }, [] as PodKind[]); + }, [resources.jobs, resources.pods, obj.metadata.uid]); -export const CronJobPodsComponent: FC = ({ obj }) => { - const { t } = useTranslation(); - const podFilters = useMemo(() => getPodFilters(t), [t]); return ( - - , - ) => { - if (!_resources.jobs.loaded || !_resources.pods.loaded) { - return []; - } - const jobs = _resources.jobs.data.filter((job) => - job.metadata?.ownerReferences?.find((ref) => ref.uid === obj.metadata.uid), - ); - return ( - jobs && - jobs.reduce((acc, job) => { - acc.push(...getPodsForResource(job, _resources)); - return acc; - }, []) - ); - }} - kinds={['Pods']} - ListComponent={PodList} - rowFilters={podFilters} - hideColumnManagement={true} - omitFilterToolbar={true} - /> - + flattenedPods} + kinds={['Pods']} + ListComponent={PodList} + rowFilters={podFilters} + hideColumnManagement={true} + omitFilterToolbar={true} + /> ); }; @@ -281,26 +275,38 @@ export type CronJobJobsComponentProps = { obj: K8sResourceKind; }; -export const CronJobJobsComponent: FC = ({ obj }) => ( - - +export const CronJobJobsComponent: FC = ({ obj }) => { + const resources = useK8sWatchResources<{ + jobs: K8sResourceCommon[]; + }>({ + jobs: { + isList: true, + kind: 'Job', + namespace: obj.metadata.namespace, + }, + }); + + const flattenedJobs = useMemo(() => { + if (!resources.jobs.loaded) { + return []; + } + return (resources.jobs.data ?? []).filter((job) => + job.metadata?.ownerReferences?.find((ref) => ref.uid === obj.metadata.uid), + ); + }, [resources.jobs, obj.metadata.uid]); + + return ( + ) => { - if (!_resources.jobs.loaded) { - return []; - } - return _resources.jobs.data.filter((job) => - job.metadata?.ownerReferences?.find((ref) => ref.uid === obj.metadata.uid), - ); - }} + flatten={() => flattenedJobs} kinds={['Jobs']} ListComponent={JobsList} hideColumnManagement={true} omitFilterToolbar={true} /> - - -); + + ); +}; const useCronJobsColumns = (): TableColumn[] => { const { t } = useTranslation(); diff --git a/frontend/public/components/edit-yaml.tsx b/frontend/public/components/edit-yaml.tsx index 22471bf04e3..12828e9e71c 100644 --- a/frontend/public/components/edit-yaml.tsx +++ b/frontend/public/components/edit-yaml.tsx @@ -30,15 +30,15 @@ import { K8sResourceKind, } from '@console/dynamic-plugin-sdk'; import { useResolvedExtensions } from '@console/dynamic-plugin-sdk/src/api/useResolvedExtensions'; -import { connectToFlags, WithFlagsProps } from '../reducers/connectToFlags'; +import { useFlag } from '@console/shared/src/hooks/flag'; import { LazyManagedResourceSaveModalOverlay } from './modals'; import ReplaceCodeModal from './modals/replace-code-modal'; import { checkAccess } from './utils/rbac'; -import { Firehose } from './utils/firehose'; import { Loading } from './utils/status-box'; +import { useK8sWatchResources } from '@console/internal/components/utils/k8s-watch-hook'; import { resourceObjPath, resourceListPathFromModel } from './utils/resource-link'; -import { FirehoseResult } from './utils/types'; import { PageHeading } from '@console/shared/src/components/heading/PageHeading'; +import type { CodeEditorProps } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; import { referenceForModel, k8sCreate, @@ -66,7 +66,6 @@ import { RootState } from '@console/internal/redux'; import { getActiveNamespace } from '@console/internal/reducers/ui'; import { useOverlay } from '@console/dynamic-plugin-sdk/src/app/modal-support/useOverlay'; import { ErrorModal } from './modals/error-modal'; -import type { CodeEditorProps } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; const generateObjToLoad = ( templateExtensions: Parameters[0], @@ -96,9 +95,7 @@ export interface EditYAMLProps { /** Whether to allow multiple YAML documents in the editor */ allowMultiple?: boolean; /** Whether this is a create operation */ - create?: boolean; - /** List of YAML samples to display */ - yamlSamplesList?: FirehoseResult; + create: boolean; /** Custom CSS class for the editor */ customClass?: string; /** Callback function to handle changes in the YAML content */ @@ -144,8 +141,7 @@ type EditYAMLInnerProps = ReturnType & EditYAMLProps; const EditYAMLInner: FC = (props) => { const { allowMultiple, - create = false, - yamlSamplesList, + create, customClass, onChange = () => null, models, @@ -160,6 +156,22 @@ const EditYAMLInner: FC = (props) => { } = props; const navigate = useNavigate(); + const hasYAMLSampleFlag = useFlag(FLAGS.CONSOLE_YAML_SAMPLE); + + const watchResources = { + yamlSamplesList: hasYAMLSampleFlag + ? { + kind: referenceForModel(ConsoleYAMLSampleModel), + isList: true, + } + : null, + }; + + const resources = useK8sWatchResources<{ + yamlSamplesList: K8sResourceCommon[]; + }>(watchResources); + + const yamlSamplesList = resources.yamlSamplesList; const fireTelemetryEvent = useTelemetry(); const postFormSubmissionCallback = useResourceConnectionHandler(); const [errors, setErrors] = useState(null); @@ -980,24 +992,4 @@ const EditYAMLInner: FC = (props) => { * This component loads the entire Monaco editor library with it. * Consider using `AsyncComponent` to dynamically load this component when needed. */ -export const EditYAML_ = connect(stateToProps)(EditYAMLInner); - -export const EditYAML = connectToFlags(FLAGS.CONSOLE_YAML_SAMPLE)( - ({ flags, ...props }) => { - const resources = flags[FLAGS.CONSOLE_YAML_SAMPLE] - ? [ - { - kind: referenceForModel(ConsoleYAMLSampleModel), - isList: true, - prop: 'yamlSamplesList', - }, - ] - : []; - - return ( - - - - ); - }, -); +export const EditYAML = connect(stateToProps)(EditYAMLInner); diff --git a/frontend/public/components/factory/__tests__/details.spec.tsx b/frontend/public/components/factory/__tests__/details.spec.tsx index d155e8e98a7..c433061cf26 100644 --- a/frontend/public/components/factory/__tests__/details.spec.tsx +++ b/frontend/public/components/factory/__tests__/details.spec.tsx @@ -1,19 +1,17 @@ import { screen, act } from '@testing-library/react'; - import { DetailsPage } from '@console/internal/components/factory/details'; import { PodModel } from '@console/internal/models'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; import { K8sModel } from '@console/dynamic-plugin-sdk/src/api/common-types'; +import * as k8sWatchHook from '@console/internal/components/utils/k8s-watch-hook'; -let capturedFirehoseProps = null; - -jest.mock('@console/internal/components/utils/firehose', () => ({ - Firehose: (props) => { - capturedFirehoseProps = props; - return props.children; - }, +jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ + useK8sWatchResources: jest.fn(() => ({})), + useK8sWatchResource: jest.fn(() => [null, true, null]), })); +const mockUseK8sWatchResources = k8sWatchHook.useK8sWatchResources as jest.Mock; + const MockPageComponent = () =>

Mock Page Content
; const createMockPages = () => [ @@ -34,7 +32,8 @@ const defaultProps = { describe('Resource DetailsPage', () => { beforeEach(() => { - capturedFirehoseProps = null; + mockUseK8sWatchResources.mockClear(); + mockUseK8sWatchResources.mockReturnValue({ obj: { data: null, loaded: false } }); }); it('should verify the detail page basic information and navigation tabs', async () => { @@ -42,8 +41,11 @@ describe('Resource DetailsPage', () => { renderWithProviders(); }); - // Verify Firehose receives the expected resources - expect(capturedFirehoseProps.resources).toHaveLength(1); + // Verify hook was called + expect(mockUseK8sWatchResources).toHaveBeenCalled(); + const watchConfig = mockUseK8sWatchResources.mock.calls[0]?.[0] as any; + expect(watchConfig?.obj).toBeDefined(); + expect(watchConfig?.obj?.kind).toBe('Pod'); // Verify resource information is displayed expect(screen.getByText('Pod')).toBeVisible(); @@ -55,7 +57,7 @@ describe('Resource DetailsPage', () => { expect(screen.getByRole('tab', { name: 'Events' })).toBeVisible(); }); - it('should verify details page with extra resources passed to Firehose', async () => { + it('should verify details page with extra resources passed to useK8sWatchResources', async () => { const extraResource = [ { kind: 'ConfigMap', @@ -65,12 +67,21 @@ describe('Resource DetailsPage', () => { prop: 'configMap', }, ]; + mockUseK8sWatchResources.mockReturnValue({ + obj: { data: null, loaded: false }, + configMap: { data: null, loaded: false }, + }); + await act(async () => { renderWithProviders(); }); + // Verify hook was called with both resources + expect(mockUseK8sWatchResources).toHaveBeenCalled(); + const watchConfig = mockUseK8sWatchResources.mock.calls[0]?.[0] as any; + // Verify total resources count (1 from defaultProps + 1 extra) - expect(capturedFirehoseProps.resources).toHaveLength(2); + expect(Object.keys(watchConfig || {})).toHaveLength(2); // Verify basic UI elements are still present expect(screen.getByText('example-pod')).toBeVisible(); @@ -79,15 +90,29 @@ describe('Resource DetailsPage', () => { expect(screen.getByRole('tab', { name: 'Events' })).toBeVisible(); // Verify Pod resource from DetailsPage props - expect(capturedFirehoseProps.resources[0]).toEqual({ + expect(watchConfig?.obj).toEqual({ kind: defaultProps.kind, name: defaultProps.name, namespace: defaultProps.namespace, isList: false, - prop: 'obj', + selector: undefined, + fieldSelector: undefined, + limit: undefined, + namespaced: undefined, + optional: undefined, }); // Verify extra ConfigMap resource from DetailsPage props - expect(capturedFirehoseProps.resources[1]).toEqual(extraResource[0]); + expect(watchConfig?.configMap).toEqual({ + kind: 'ConfigMap', + name: 'example-configmap', + namespace: 'example-namespace', + isList: false, + selector: undefined, + fieldSelector: undefined, + limit: undefined, + namespaced: undefined, + optional: undefined, + }); }); }); diff --git a/frontend/public/components/factory/__tests__/list-page.spec.tsx b/frontend/public/components/factory/__tests__/list-page.spec.tsx index 61385e3af93..6f061fb1e2f 100644 --- a/frontend/public/components/factory/__tests__/list-page.spec.tsx +++ b/frontend/public/components/factory/__tests__/list-page.spec.tsx @@ -1,5 +1,4 @@ import { screen, fireEvent } from '@testing-library/react'; - import { TextFilter } from '@console/internal/components/factory/text-filter'; import { ListPageWrapper, @@ -7,16 +6,15 @@ import { MultiListPage, } from '@console/internal/components/factory/list-page'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import * as k8sWatchHook from '@console/internal/components/utils/k8s-watch-hook'; -let capturedFirehoseProps = null; - -jest.mock('@console/internal/components/utils/firehose', () => ({ - Firehose: (props) => { - capturedFirehoseProps = props; - return props.children; - }, +jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ + useK8sWatchResources: jest.fn(() => ({})), + useK8sWatchResource: jest.fn(() => [null, true, null]), })); +const mockUseK8sWatchResources = k8sWatchHook.useK8sWatchResources as jest.Mock; + describe('TextFilter component', () => { it('renders text input without label', () => { const onChange = jest.fn(); @@ -152,11 +150,12 @@ describe('ListPageWrapper component', () => { describe(' MultiListPage component', () => { beforeEach(() => { - capturedFirehoseProps = null; + mockUseK8sWatchResources.mockClear(); }); - it('renders with Firehose wrapper and displays ListComponent content', () => { + it('renders with useK8sWatchResources hook and displays ListComponent content', () => { const ListComponent = () =>
Multi List
; + mockUseK8sWatchResources.mockReturnValue({}); renderWithProviders( { ); expect(screen.getByText('Multi List')).toBeVisible(); - expect(capturedFirehoseProps.resources).toHaveLength(1); - expect(capturedFirehoseProps.resources[0].kind).toBe('Pod'); - expect(capturedFirehoseProps.resources[0].name).toBe('example-pod'); + expect(mockUseK8sWatchResources).toHaveBeenCalled(); + const watchConfig = mockUseK8sWatchResources.mock.calls[0]?.[0] as any; + expect(watchConfig?.Pod).toBeDefined(); + expect(watchConfig?.Pod?.kind).toBe('Pod'); + expect(watchConfig?.Pod?.name).toBe('example-pod'); }); }); diff --git a/frontend/public/components/factory/details.tsx b/frontend/public/components/factory/details.tsx index 5026e72d42a..bbe218a7b0e 100644 --- a/frontend/public/components/factory/details.tsx +++ b/frontend/public/components/factory/details.tsx @@ -22,9 +22,11 @@ import { FirehoseResult, K8sResourceKindReference, K8sResourceKind, + K8sResourceCommon, + WatchK8sResource, } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; -import { Firehose } from '../utils/firehose'; import { HorizontalNav } from '../utils/horizontal-nav'; +import { useK8sWatchResources } from '@console/internal/components/utils/k8s-watch-hook'; import type { Page } from '../utils/horizontal-nav'; import { ConnectedPageHeading, @@ -101,18 +103,48 @@ export const DetailsPage = withFallback(({ pages = [], ...prop }, []); let allPages = [...pages, ...pluginPages]; allPages = allPages.length ? allPages : null; - const objResource: FirehoseResource = { - kind: props.kind, - name: props.name, - namespace: props.namespace, - isList: false, - prop: 'obj', - }; + const objResource = useMemo( + () => ({ + kind: props.kind, + name: props.name, + namespace: props.namespace, + isList: false, + prop: 'obj', + }), + [props.kind, props.name, props.namespace], + ); + const titleProviderValues = { telemetryPrefix: props?.kindObj?.kind, titlePrefix: `${props.name} ยท ${getTitleForNodeKind(props?.kindObj?.kind)}`, }; + // Build resources to watch + const watchResources = useMemo(() => { + const allResources = [...(_.isNil(props.obj) ? [objResource] : []), ...(props.resources ?? [])]; + return allResources.reduce((acc, r) => { + const key = r.prop || r.kind; + acc[key] = { + kind: r.kind, + name: r.name, + namespace: r.namespace, + isList: r.isList, + selector: r.selector, + fieldSelector: r.fieldSelector, + limit: r.limit, + namespaced: r.namespaced, + optional: r.optional, + }; + return acc; + }, {} as Record); + }, [props.obj, props.resources, objResource]); + + const watchedResources = useK8sWatchResources< + Record + >(watchResources); + + const objData = _.isNil(props.obj) ? watchedResources.obj : props.obj; + return ( {resolvedBreadcrumbExtension && ( @@ -124,42 +156,40 @@ export const DetailsPage = withFallback(({ pages = [], ...prop /> )} - - - - + + ); }, ErrorBoundaryFallbackPage); diff --git a/frontend/public/components/factory/list-page.tsx b/frontend/public/components/factory/list-page.tsx index 230883d3e62..226dc3b69fc 100644 --- a/frontend/public/components/factory/list-page.tsx +++ b/frontend/public/components/factory/list-page.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import * as _ from 'lodash'; import type { ComponentType, FC, ReactNode } from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { useParams, useNavigate } from 'react-router-dom-v5-compat'; import { Button, Grid, GridItem } from '@patternfly/react-core'; @@ -13,6 +13,8 @@ import ErrorBoundaryFallbackPage from '@console/shared/src/components/error/fall import { ColumnLayout, K8sResourceCommon, + WatchK8sResource, + WatchK8sResultsObject, } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; import { filterList } from '@console/dynamic-plugin-sdk/src/app/k8s/actions/k8s'; import PaneBody from '@console/shared/src/components/layout/PaneBody'; @@ -21,13 +23,8 @@ import { ErrorPage404 } from '../error'; import { K8sKind } from '../../module/k8s/types'; import { getReferenceForModel as referenceForModel } from '@console/dynamic-plugin-sdk/src/utils/k8s/k8s-ref'; import { Selector } from '@console/dynamic-plugin-sdk/src/api/common-types'; -import { Firehose } from '../utils/firehose'; -import { - FirehoseResource, - FirehoseResourcesResult, - FirehoseResultObject, - FirehoseResult, -} from '../utils/types'; +import { useK8sWatchResources } from '../utils/k8s-watch-hook'; +import { FirehoseResource, FirehoseResourcesResult, FirehoseResultObject } from '../utils/types'; import { inject, kindObj } from '../utils/inject'; import { makeQuery, @@ -62,7 +59,11 @@ type ListPageWrapperProps = { hideLabelFilter?: boolean; columnLayout?: ColumnLayout; name?: string; + /** @deprecated - use watchedResources instead */ resources?: FirehoseResourcesResult; + /** Resources fetched via useK8sWatchResources */ + watchedResources?: Record>; + loaded?: boolean; reduxIDs?: string[]; textFilter?: string; nameFilterPlaceholder?: string; @@ -90,6 +91,7 @@ export const ListPageWrapper: FC = (props) => { columnLayout, name, resources, + watchedResources, nameFilter, omitFilterToolbar, } = props; @@ -102,7 +104,10 @@ export const ListPageWrapper: FC = (props) => { } }, [dispatch, nameFilter, memoizedIds]); - const data = flatten ? flatten(resources) : []; + // TODO: Remove the resources prop and the fallback ?? resources after all components are migrated from Firehose to hooks. + // Use watchedResources (from useK8sWatchResources) if available, fallback to resources (from Firehose) + const resourceData = watchedResources ?? resources; + const data = flatten ? flatten(resourceData) : []; const Filter = ( ((props) => { hideColumnManagement, columnLayout, omitFilterToolbar, - flatten = (_resources) => _.get(_resources, name || kind, {} as FirehoseResult).data, + flatten = (_resources) => + (_resources[name || kind] ?? ({} as WatchK8sResultsObject)).data, } = props; const { t } = useTranslation(); const params = useParams(); @@ -510,12 +516,55 @@ export const MultiListPage: FC = (props) => { } = props; const { t } = useTranslation(); - const resources = _.map(props.resources, (r) => ({ - ...r, - isList: r.isList !== undefined ? r.isList : true, - namespace: r.namespaced ? namespace : r.namespace, - prop: r.prop || r.kind, - })); + + // Build resources configuration for FireMan (needs prop for redux IDs) + const firehoseResources = useMemo( + () => + _.map(props.resources, (r) => ({ + ...r, + isList: r.isList !== undefined ? r.isList : true, + namespace: r.namespaced ? namespace : r.namespace, + prop: r.prop || r.kind, + })), + [props.resources, namespace], + ); + + // Build watch resources configuration for useK8sWatchResources + const watchResources = useMemo(() => { + if (mock) { + return {}; + } + return firehoseResources.reduce((acc, r) => { + const key = r.prop || r.kind; + acc[key] = { + kind: r.kind, + name: r.name, + namespace: r.namespace, + isList: r.isList, + selector: r.selector, + fieldSelector: r.fieldSelector, + limit: r.limit, + namespaced: r.namespaced, + optional: r.optional, + }; + return acc; + }, {} as Record); + }, [firehoseResources, mock]); + + const watchedResources = useK8sWatchResources< + Record + >(watchResources); + + // Aggregate individual resource loading states into a single boolean (true when all loaded, excluding errors) + const loaded = useMemo(() => { + const resourceValues = Object.values(watchedResources); + // If we expect resources but haven't received any yet, we're still loading + if (Object.keys(watchResources).length > 0 && resourceValues.length === 0) { + return false; + } + // Check if all resources (excluding errors) are loaded + return resourceValues.filter((r) => !r.loadError).every((r) => r.loaded); + }, [watchedResources, watchResources]); return ( = (props) => { filterLabel={filterLabel || t('public~by name')} helpText={helpText} helpAlert={helpAlert} - resources={mock ? [] : resources} + resources={mock ? [] : firehoseResources} textFilter={textFilter} title={showTitle ? title : undefined} badge={badge} > - - - + ); }; diff --git a/frontend/public/components/instantiate-template.tsx b/frontend/public/components/instantiate-template.tsx index 952eeec1320..9f863dfdbc9 100644 --- a/frontend/public/components/instantiate-template.tsx +++ b/frontend/public/components/instantiate-template.tsx @@ -35,9 +35,9 @@ import { normalizeIconClass, } from './catalog/catalog-item-icon'; import { ButtonBar } from './utils/button-bar'; -import { Firehose } from './utils/firehose'; import { LoadError, LoadingBox } from './utils/status-box'; import { NsDropdown } from './utils/list-dropdown'; +import { useK8sWatchResource } from './utils/k8s-watch-hook'; import { PageHeading } from '@console/shared/src/components/heading/PageHeading'; import { SecretModel, TemplateInstanceModel } from '../models'; import { @@ -327,6 +327,14 @@ export const TemplateForm: FC = (props) => { return ; } + if (!obj.data) { + return ( + + {t('public~Template not found or invalid URL parameters.')} + + ); + } + const template: TemplateKind = obj.data; const params = template.parameters || []; @@ -387,31 +395,34 @@ export const TemplateForm: FC = (props) => { ); }; -export const InstantiateTemplatePage: FC<{}> = (props) => { +export const InstantiateTemplatePage: FC<{}> = () => { const title = 'Instantiate Template'; const location = useLocation(); const searchParams = new URLSearchParams(location.search); const templateName = searchParams.get('template'); const templateNamespace = searchParams.get('template-ns'); const preselectedNamespace = searchParams.get('preselected-ns'); - const resources = [ - { - kind: 'Template', - name: templateName, - namespace: templateNamespace, - isList: false, - prop: 'obj', - }, - ]; + + const [template, loaded, loadError] = useK8sWatchResource( + templateName && templateNamespace + ? { + kind: 'Template', + name: templateName, + namespace: templateNamespace, + isList: false, + } + : null, + ); return ( <> {title} - - - + ); diff --git a/frontend/public/components/monitoring/alertmanager/alertmanager-config.tsx b/frontend/public/components/monitoring/alertmanager/alertmanager-config.tsx index dd60446fabf..ce6aea05ce9 100644 --- a/frontend/public/components/monitoring/alertmanager/alertmanager-config.tsx +++ b/frontend/public/components/monitoring/alertmanager/alertmanager-config.tsx @@ -33,10 +33,10 @@ import { K8sResourceKind } from '../../../module/k8s'; import { LazyAlertRoutingModalOverlay } from '../../modals'; import { useWarningModal } from '@console/shared/src/hooks/useWarningModal'; import { useOverlay } from '@console/dynamic-plugin-sdk/src/app/modal-support/useOverlay'; -import { Firehose } from '../../utils/firehose'; import { Kebab } from '../../utils/kebab'; import { SectionHeading } from '../../utils/headings'; import { StatusBox } from '../../utils/status-box'; +import { useK8sWatchResource } from '../../utils/k8s-watch-hook'; import { getAlertmanagerConfig, patchAlertmanagerConfig, @@ -598,6 +598,13 @@ export const AlertmanagerConfig: FC = () => { const breadcrumbs = breadcrumbsForGlobalConfig('Alertmanager', configPath); + const [secret, loaded, loadError] = useK8sWatchResource({ + kind: 'Secret', + name: 'alertmanager-main', + namespace: 'openshift-monitoring', + isList: false, + }); + return ( <> @@ -613,19 +620,7 @@ export const AlertmanagerConfig: FC = () => { }, ]} /> - - - + ); }; diff --git a/frontend/public/components/monitoring/alertmanager/alertmanager-yaml-editor.tsx b/frontend/public/components/monitoring/alertmanager/alertmanager-yaml-editor.tsx index ee3981c2075..ac6fc6a426a 100644 --- a/frontend/public/components/monitoring/alertmanager/alertmanager-yaml-editor.tsx +++ b/frontend/public/components/monitoring/alertmanager/alertmanager-yaml-editor.tsx @@ -11,9 +11,9 @@ import { breadcrumbsForGlobalConfig } from '../../cluster-settings/global-config import { K8sResourceKind } from '../../../module/k8s'; import { AsyncComponent } from '../../utils/async'; -import { Firehose } from '../../utils/firehose'; import { StatusBox } from '../../utils/status-box'; import { patchAlertmanagerConfig, getAlertmanagerYAML } from './alertmanager-utils'; +import { useK8sWatchResource } from '../../utils/k8s-watch-hook'; const EditAlertmanagerYAML = (props) => ( = () => { const breadcrumbs = breadcrumbsForGlobalConfig('Alertmanager', configPath); + const [secret, loaded, loadError] = useK8sWatchResource({ + kind: 'Secret', + name: 'alertmanager-main', + namespace: 'openshift-monitoring', + isList: false, + }); + return ( <> @@ -142,19 +149,7 @@ export const AlertmanagerYAML: FC<{}> = () => { }, ]} /> - - - + ); }; diff --git a/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx b/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx index 12876eaea05..1276211de48 100644 --- a/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx +++ b/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx @@ -26,8 +26,8 @@ import { safeLoad } from 'js-yaml'; import { APIError } from '@console/shared/src/types/resource'; import PaneBody from '@console/shared/src/components/layout/PaneBody'; import { ButtonBar } from '../../utils/button-bar'; -import { Firehose } from '../../utils/firehose'; import { StatusBox } from '../../utils/status-box'; +import { useK8sWatchResource } from '../../utils/k8s-watch-hook'; import { getAlertmanagerConfig, patchAlertmanagerConfig, @@ -575,36 +575,41 @@ const ReceiverWrapper: FC = memo(({ obj, ...props }) ); }); -const resources = [ - { +export const CreateReceiver = () => { + const { t } = useTranslation(); + const [secret, loaded, loadError] = useK8sWatchResource({ kind: 'Secret', name: 'alertmanager-main', namespace: 'openshift-monitoring', isList: false, - prop: 'obj', - }, -]; + }); -export const CreateReceiver = () => { - const { t } = useTranslation(); return ( - - - + ); }; export const EditReceiver = () => { const { t } = useTranslation(); const params = useParams(); + const [secret, loaded, loadError] = useK8sWatchResource({ + kind: 'Secret', + name: 'alertmanager-main', + namespace: 'openshift-monitoring', + isList: false, + }); + return ( - - - + ); }; diff --git a/frontend/public/components/namespace-bar.tsx b/frontend/public/components/namespace-bar.tsx index a1f87f6e62b..dced54c1535 100644 --- a/frontend/public/components/namespace-bar.tsx +++ b/frontend/public/components/namespace-bar.tsx @@ -18,9 +18,12 @@ import { k8sGet } from '@console/internal/module/k8s'; import { setFlag } from '../actions/flags'; import { NamespaceModel, ProjectModel } from '../models'; import { flagPending } from '../reducers/features'; -import { Firehose } from './utils/firehose'; -import { FirehoseResult } from './utils/types'; import { useQueryParamsMutator } from './utils/router'; +import { useK8sWatchResource } from './utils/k8s-watch-hook'; +import type { + WatchK8sResultsObject, + K8sResourceCommon, +} from '@console/dynamic-plugin-sdk/src/extensions/console-types'; import { useCreateNamespaceOrProjectModal } from '@console/shared/src/hooks/useCreateNamespaceOrProjectModal'; import type { RootState } from '../redux'; import { setActiveApplication } from '../actions/ui'; @@ -28,7 +31,7 @@ import { setActiveApplication } from '../actions/ui'; export type NamespaceBarDropdownsProps = { children: ReactNode; isDisabled: boolean; - namespace?: FirehoseResult; + namespace?: WatchK8sResultsObject; onNamespaceChange: (namespace: string) => void; useProjects: boolean; }; @@ -50,10 +53,10 @@ export const NamespaceBarDropdowns: FC = ({ const [activeNamespaceError, setActiveNamespaceError] = useState(false); const canListNS = useFlag(FLAGS.CAN_LIST_NS); useEffect(() => { - if (namespace.loaded) { + if (namespace?.loaded) { dispatch(setFlag(FLAGS.SHOW_OPENSHIFT_START_GUIDE, _.isEmpty(namespace.data))); } - }, [dispatch, namespace.data, namespace.loaded]); + }, [dispatch, namespace?.data, namespace?.loaded]); /* Check if the activeNamespace is present in the cluster */ useEffect(() => { @@ -124,6 +127,16 @@ export const NamespaceBar: FC = const useProjects = useSelector(({ k8s }) => k8s.hasIn(['RESOURCES', 'models', ProjectModel.kind]), ); + + const [namespaces, loaded, loadError] = useK8sWatchResource( + hideProjects + ? null + : { + kind: getModel(useProjects).kind, + isList: true, + }, + ); + return (
{hideProjects ? ( @@ -131,20 +144,17 @@ export const NamespaceBar: FC = {children}
) : ( - // Data from Firehose is not used directly by the NamespaceDropdown nor the children. + // Data from useK8sWatchResource is not used directly by the NamespaceDropdown nor the children. // Data is used to determine if the StartGuide should be shown. // See NamespaceBarDropdowns_ above. - - - {children} - - + {children} + )} ); diff --git a/frontend/public/components/storage-class-form.tsx b/frontend/public/components/storage-class-form.tsx index 4727babec07..3c1261ca014 100755 --- a/frontend/public/components/storage-class-form.tsx +++ b/frontend/public/components/storage-class-form.tsx @@ -24,9 +24,9 @@ import { PageHeading } from '@console/shared/src/components/heading/PageHeading' import { ConsoleSelect } from '@console/internal/components/utils/console-select'; import { AsyncComponent } from './utils/async'; import { ButtonBar } from './utils/button-bar'; -import { Firehose } from './utils/firehose'; -import type { FirehoseResult } from './utils/types'; +import type { WatchK8sResultsObject } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; import { NameValueEditorPair } from './utils/types'; +import { useK8sWatchResources } from './utils/k8s-watch-hook'; import { resourceObjPath } from './utils/resource-link'; import { ExternalLink } from '@console/shared/src/components/links/ExternalLink'; import { k8sCreate, K8sResourceKind, referenceForModel, referenceFor } from './../module/k8s'; @@ -734,7 +734,7 @@ const mapDispatchToProps = (): DispatchProps => ({ export type StorageClassFormProps = StateProps & DispatchProps & { resources?: { - [key: string]: FirehoseResult; + [key: string]: WatchK8sResultsObject; }; } & { extensions?: [ResolvedExtension[], boolean, any[]]; @@ -782,14 +782,27 @@ export const ConnectedStorageClassForm = connect( }); export const StorageClassForm = (props) => { - const resources = [ - { kind: StorageClassModel.kind, isList: true, prop: 'sc' }, - { kind: referenceForModel(CSIDriverModel), isList: true, prop: 'csi' }, - ]; + const watchedResources = useK8sWatchResources({ + sc: { kind: StorageClassModel.kind, isList: true }, + csi: { kind: referenceForModel(CSIDriverModel), isList: true }, + }); + return ( - - - + ); }; diff --git a/frontend/public/components/utils/horizontal-nav.tsx b/frontend/public/components/utils/horizontal-nav.tsx index 7e61d0915dc..bb4d17b6de4 100644 --- a/frontend/public/components/utils/horizontal-nav.tsx +++ b/frontend/public/components/utils/horizontal-nav.tsx @@ -37,12 +37,17 @@ import { import { useExtensions } from '@console/plugin-sdk/src/api/useExtensions'; export const editYamlComponent = (props) => ( - import('../edit-yaml').then((c) => c.EditYAML)} obj={props.obj} /> + import('../edit-yaml').then((c) => c.EditYAML)} + obj={props.obj} + create={false} + /> ); export const viewYamlComponent = (props) => ( import('../edit-yaml').then((c) => c.EditYAML)} obj={props.obj} + create={false} readOnly={true} /> ); diff --git a/frontend/public/components/utils/list-dropdown.tsx b/frontend/public/components/utils/list-dropdown.tsx index 96d9d2d75fd..94d34fd68d6 100644 --- a/frontend/public/components/utils/list-dropdown.tsx +++ b/frontend/public/components/utils/list-dropdown.tsx @@ -6,8 +6,8 @@ import { Alert } from '@patternfly/react-core'; import { useFlag } from '@console/shared/src/hooks/flag'; import { FLAGS } from '@console/shared/src/constants'; import { ActionItem, ConsoleSelect } from '@console/internal/components/utils/console-select'; -import { Firehose } from './firehose'; import { LoadingInline } from './status-box'; +import { useK8sWatchResources } from '@console/internal/components/utils/k8s-watch-hook'; import { ResourceName } from './resource-icon'; import { flagPending } from '../../reducers/features'; import { NamespaceModel, ProjectModel } from '@console/internal/models'; @@ -19,6 +19,7 @@ import { K8sResourceCommon, K8sModel, K8sResourceKind, + WatchK8sResource, } from '@console/dynamic-plugin-sdk/src'; const getKey = (key, keyKind) => { @@ -52,7 +53,11 @@ export interface ListDropdownProps { loadError?: boolean; } -const ListDropdown_: FC = ({ +interface ListDropdownInternalProps extends Omit { + resources?: Record; +} + +const ListDropdown_: FC = ({ desc, placeholder, loaded, @@ -197,13 +202,41 @@ const ListDropdown_: FC = ({ }; export const ListDropdown: FC = (props) => { - const resources = _.map(props.resources, (resource) => - _.assign({ isList: true, prop: resource.kind }, resource), + const watchResources = useMemo(() => { + if (!props.resources || props.resources.length === 0) { + return {}; + } + return props.resources.reduce((acc, resource) => { + // Use prop as key if provided, otherwise fallback to kind (matches original Firehose behavior) + const key = resource.prop || resource.kind; + acc[key] = { + kind: resource.kind, + isList: true, + namespace: resource.namespace, + selector: resource.selector, + fieldSelector: resource.fieldSelector, + limit: resource.limit, + namespaced: resource.namespaced, + optional: resource.optional, + }; + return acc; + }, {} as Record); + }, [props.resources]); + + const watchedResources = useK8sWatchResources>( + watchResources, ); + + const loaded = useMemo(() => { + return Object.values(watchedResources).every((r) => r.loaded); + }, [watchedResources]); + + const loadError = useMemo(() => { + return Object.values(watchedResources).some((r) => r.loadError); + }, [watchedResources]); + return ( - - - + ); }; diff --git a/frontend/public/components/utils/storage-class-dropdown.tsx b/frontend/public/components/utils/storage-class-dropdown.tsx index 689ca958d4e..a4f6e7b0970 100644 --- a/frontend/public/components/utils/storage-class-dropdown.tsx +++ b/frontend/public/components/utils/storage-class-dropdown.tsx @@ -7,8 +7,8 @@ import * as fuzzy from 'fuzzysearch'; import { WithTranslation, withTranslation } from 'react-i18next'; import { ConsoleSelect } from '@console/internal/components/utils/console-select'; -import { Firehose } from './firehose'; import { LoadingInline } from './status-box'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; import { ResourceName, ResourceIcon } from './resource-icon'; import { isDefaultClass } from '../storage-class'; import { css } from '@patternfly/react-styles'; @@ -212,10 +212,26 @@ export const StorageClassDropdownInner = withTranslation()( ); export const StorageClassDropdown = (props) => { + const [data, loaded, loadError] = useK8sWatchResource({ + kind: 'StorageClass', + isList: true, + }); + + const resources = { + StorageClass: { + data, + loaded, + loadError, + }, + }; + return ( - - - + ); }; diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 2884855fcee..daa1fed0677 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -704,6 +704,7 @@ "Compress to a single line of content. This may strip any new lines you have entered.": "Compress to a single line of content. This may strip any new lines you have entered.", "Expand to enter multiple lines of content. This is required if you need to include newline characters.": "Expand to enter multiple lines of content. This is required if you need to include newline characters.", "Template": "Template", + "Template not found or invalid URL parameters.": "Template not found or invalid URL parameters.", "(generated if empty)": "(generated if empty)", "{{jobsSucceeded}} of {{completions}}": "{{jobsSucceeded}} of {{completions}}", "Job status": "Job status", @@ -1804,7 +1805,6 @@ "Disable": "Disable", "Disabled": "Disabled", "Enabled": "Enabled", - "Select service account": "Select service account", "Failed to load kubecontrollermanager": "Failed to load kubecontrollermanager", "Failed to parse cloud provider config {{cm}}": "Failed to parse cloud provider config {{cm}}", "The following content was expected to be defined in the configMap: {{ expectedValues }}": "The following content was expected to be defined in the configMap: {{ expectedValues }}",