Repository: stolostron/console
Pull request #4518: [ACM-20247] Change vm action requests to use user token
Author: login: zlayne
Labels: name: lgtm
name: approved
name: dco-signoff: yes
Created: 2025-05-20T14:58:47Z | Merged: 2025-05-21T20:26:39Z

# 📝 Summary

**Ticket Summary (Title):**  
- VM actions should use user token for auth with the new fine grained RBAC tech preview.
- VM actions will be enabled by default in 2.14. With the support to disable actions via an env variable if necessary.
- Removes old Search availability hook as it did not always correctly identify search service health. (would indicate search is unavailable with evicted search service pods present). Reverting to the old search check based on whether the search href is available.

**Ticket Link:**  
https://issues.redhat.com/browse/ACM-20247

**Type of Change:**  
<!-- Select one -->
- [ ] 🐞 Bug Fix  
- [x] ✨ Feature  
- [ ] 🔧 Refactor
- [ ] 💸 Tech Debt
- [ ] 🧪 Test-related  
- [ ] 📄 Docs

---

## ✅ Checklist

### General

- [ ] PR title follows the convention (e.g. `ACM-12340 Fix bug with...`)
- [ ] Code builds and runs locally without errors
- [ ] No console logs, commented-out code, or unnecessary files
- [ ] All commits are meaningful and well-labeled
- [ ] All new display strings are externalized for localization (English only)
- [ ] *(Nice to have)* JSDoc comments added for new functions and interfaces

#### If Feature

- [ ] UI/UX reviewed (if applicable)
- [ ] All acceptance criteria met
- [ ] Unit test coverage added or updated
- [ ] Relevant documentation or comments included

#### If Bugfix

- [ ] Root cause and fix summary are documented in the ticket (for future reference / errata)
- [ ] Fix tested thoroughly and resolves the issue
- [ ] Test(s) added to prevent regression

---

### 🗒️ Notes for Reviewers
<!-- Optional: anything reviewers should know, special context, etc. -->


Diff:
diff --git a/backend/src/routes/virtualMachineProxy.ts b/backend/src/routes/virtualMachineProxy.ts
index ab986849dc0..7c2376d195f 100644
--- a/backend/src/routes/virtualMachineProxy.ts
+++ b/backend/src/routes/virtualMachineProxy.ts
@@ -3,15 +3,10 @@ import { constants, Http2ServerRequest, Http2ServerResponse } from 'http2'
 import { HeadersInit } from 'node-fetch'
 import { getServiceAgent } from '../lib/agent'
 import { fetchRetry } from '../lib/fetch-retry'
-import { jsonRequest } from '../lib/json-request'
 import { logger } from '../lib/logger'
 import { getMultiClusterEngine } from '../lib/multi-cluster-engine'
-import { respond, respondInternalServerError } from '../lib/respond'
-import { getServiceAccountToken } from '../lib/serviceAccountToken'
+import { respondInternalServerError } from '../lib/respond'
 import { getAuthenticatedToken } from '../lib/token'
-import { ResourceList } from '../resources/resource-list'
-import { Secret } from '../resources/secret'
-import { canAccess } from './events'
 
 const {
   HTTP2_HEADER_CONTENT_TYPE,
@@ -37,8 +32,6 @@ interface ActionBody {
 export async function virtualMachineProxy(req: Http2ServerRequest, res: Http2ServerResponse): Promise<void> {
   const token = await getAuthenticatedToken(req, res)
   if (token) {
-    const serviceAccountToken = getServiceAccountToken()
-
     try {
       const chucks: string[] = []
       req.on('data', (chuck: string) => {
@@ -46,111 +39,82 @@ export async function virtualMachineProxy(req: Http2ServerRequest, res: Http2Ser
       })
       req.on('end', async () => {
         const body = JSON.parse(chucks.join()) as ActionBody
+        const mce = await getMultiClusterEngine()
+        const proxyService = `https://cluster-proxy-addon-user.${mce?.spec?.targetNamespace || 'multicluster-engine'}.svc.cluster.local:9092`
+        const proxyURL = process.env.CLUSTER_PROXY_ADDON_USER_ROUTE || proxyService
 
-        // If user is not able to create an MCA in the managed cluster namespace -> they aren't authorized to trigger actions.
-        const hasAuth = await canAccess(
-          {
-            kind: 'ManagedClusterAction',
-            apiVersion: 'action.open-cluster-management.io/v1beta1',
-            metadata: { namespace: body.managedCluster },
-          },
-          'create',
-          token
-        ).then((allowed) => allowed)
-
-        if (hasAuth) {
-          // console-mce ClusterRole does not allow for GET on secrets. Have to list in a namespace
-          const secretPath = process.env.CLUSTER_API_URL + `/api/v1/namespaces/${body.managedCluster}/secrets`
-          const managedClusterToken: string = await jsonRequest(secretPath, serviceAccountToken)
-            .then((response: ResourceList<Secret>) => {
-              const secret = response.items.find((secret) => secret.metadata.name === 'vm-actor')
-              const proxyToken = secret.data?.token ?? ''
-              return Buffer.from(proxyToken, 'base64').toString('ascii')
-            })
-            .catch((err: Error): undefined => {
-              logger.error({ msg: `Error getting secret in namespace ${body.managedCluster}`, error: err.message })
-              return undefined
-            })
-
-          const mce = await getMultiClusterEngine()
-          const proxyService = `https://cluster-proxy-addon-user.${mce?.spec?.targetNamespace || 'multicluster-engine'}.svc.cluster.local:9092`
-          const proxyURL = process.env.CLUSTER_PROXY_ADDON_USER_ROUTE || proxyService
-
-          // req.url is one of:
-          //    /virtualmachines/<action>
-          //    /virtualmachineinstances/<action>
-          //    /virtualmachinesnapshots
-          //    /virtualmachinerestores
-          const action = req.url.split('/')[2]
-          let path = `${proxyURL}/${body.managedCluster}`
-          let reqBody = undefined
-          switch (req.url) {
-            case '/virtualmachines/start':
-            case '/virtualmachines/stop':
-            case '/virtualmachines/restart':
-              path = `${path}/apis/subresources.kubevirt.io/v1/namespaces/${body.vmNamespace}/virtualmachines/${body.vmName}/${action}`
-              break
-            case '/virtualmachineinstances/pause':
-            case '/virtualmachineinstances/unpause':
-              path = `${path}/apis/subresources.kubevirt.io/v1/namespaces/${body.vmNamespace}/virtualmachineinstances/${body.vmName}/${action}`
-              break
-            case '/virtualmachinesnapshots':
-              path = `${path}/apis/snapshot.kubevirt.io/v1beta1/namespaces/${body.vmNamespace}/virtualmachinesnapshots`
-              reqBody = JSON.stringify(body.reqBody)
-              break
-            case '/virtualmachinerestores':
-              path = `${path}/apis/snapshot.kubevirt.io/v1beta1/namespaces/${body.vmNamespace}/virtualmachinerestores`
-              reqBody = JSON.stringify(body.reqBody)
-              break
-          }
-
-          const headers: HeadersInit =
-            req.method === 'POST'
-              ? {
-                  [HTTP2_HEADER_AUTHORIZATION]: `Bearer ${managedClusterToken}`,
-                  [HTTP2_HEADER_ACCEPT]: 'application/json',
-                  [HTTP2_HEADER_CONTENT_TYPE]: 'application/json',
-                }
-              : {
-                  [HTTP2_HEADER_AUTHORIZATION]: `Bearer ${managedClusterToken}`,
-                }
+        // req.url is one of:
+        //    /virtualmachines/<action>
+        //    /virtualmachineinstances/<action>
+        //    /virtualmachinesnapshots
+        //    /virtualmachinerestores
+        const action = req.url.split('/')[2]
+        let path = `${proxyURL}/${body.managedCluster}`
+        let reqBody = undefined
+        switch (req.url) {
+          case '/virtualmachines/start':
+          case '/virtualmachines/stop':
+          case '/virtualmachines/restart':
+            path = `${path}/apis/subresources.kubevirt.io/v1/namespaces/${body.vmNamespace}/virtualmachines/${body.vmName}/${action}`
+            break
+          case '/virtualmachineinstances/pause':
+          case '/virtualmachineinstances/unpause':
+            path = `${path}/apis/subresources.kubevirt.io/v1/namespaces/${body.vmNamespace}/virtualmachineinstances/${body.vmName}/${action}`
+            break
+          case '/virtualmachinesnapshots':
+            path = `${path}/apis/snapshot.kubevirt.io/v1beta1/namespaces/${body.vmNamespace}/virtualmachinesnapshots`
+            reqBody = JSON.stringify(body.reqBody)
+            break
+          case '/virtualmachinerestores':
+            path = `${path}/apis/snapshot.kubevirt.io/v1beta1/namespaces/${body.vmNamespace}/virtualmachinerestores`
+            reqBody = JSON.stringify(body.reqBody)
+            break
+        }
 
-          await fetchRetry(path, {
-            method: req.method,
-            headers,
-            agent: getServiceAgent(),
-            body: reqBody,
-            compress: true,
-          })
-            .then(async (results) => {
-              if (results?.status > 300) {
-                logger.error({
-                  msg: 'Error in VirtualMachine action response',
-                  error: results,
-                })
-                res.setHeader('Content-Type', 'application/json')
-                res.writeHead(results.status ?? HTTP_STATUS_INTERNAL_SERVER_ERROR)
-                res.end(JSON.stringify(results))
+        const headers: HeadersInit =
+          req.method === 'POST'
+            ? {
+                [HTTP2_HEADER_AUTHORIZATION]: `Bearer ${token}`,
+                [HTTP2_HEADER_ACCEPT]: 'application/json',
+                [HTTP2_HEADER_CONTENT_TYPE]: 'application/json',
               }
-              let response = undefined
-              if (req.method === 'POST') {
-                response = (await results.json()) as unknown
-              } else {
-                response = { statusCode: results.status }
+            : {
+                [HTTP2_HEADER_AUTHORIZATION]: `Bearer ${token}`,
               }
-              logger.warn(response)
+
+        await fetchRetry(path, {
+          method: req.method,
+          headers,
+          agent: getServiceAgent(),
+          body: reqBody,
+          compress: true,
+        })
+          .then(async (results) => {
+            if (results?.status > 300) {
+              logger.error({
+                msg: 'Error in VirtualMachine action response',
+                error: results,
+              })
               res.setHeader('Content-Type', 'application/json')
-              res.end(JSON.stringify(response))
-            })
-            .catch((err: Error) => {
-              logger.error({ msg: 'Error on VirtualMachine action request', error: err.message })
-              respondInternalServerError(req, res)
-              return `Error on VirtualMachine action request: ${err.message}`
-            })
-        } else {
-          logger.error({ msg: `Unauthorized request ${req.url.split('/')[2]} on VirtualMachine ${body.vmName}` })
-          return respond(res, `Unauthorized request ${req.url.split('/')[2]} on VirtualMachine ${body.vmName}`, 401)
-        }
+              res.writeHead(results.status ?? HTTP_STATUS_INTERNAL_SERVER_ERROR)
+              res.end(JSON.stringify(results))
+              return 'Error on VirtualMachine action request'
+            }
+            let response = undefined
+            if (req.method === 'POST') {
+              response = (await results.json()) as unknown
+            } else {
+              response = { statusCode: results.status }
+            }
+            logger.warn(response)
+            res.setHeader('Content-Type', 'application/json')
+            res.end(JSON.stringify(response))
+          })
+          .catch((err: Error) => {
+            logger.error({ msg: 'Error on VirtualMachine action request', error: err.message })
+            respondInternalServerError(req, res)
+            return `Error on VirtualMachine action request: ${err.message}`
+          })
       })
     } catch (err) {
       logger.error(err)
diff --git a/frontend/src/atoms.ts b/frontend/src/atoms.ts
index 393d2ce063e..35228705080 100644
--- a/frontend/src/atoms.ts
+++ b/frontend/src/atoms.ts
@@ -193,22 +193,6 @@ export function useIsObservabilityInstalled() {
   }, [clusterManagementAddons])
 }
 
-// Search is available if api, collector, indexer & postgres are in ready state
-export function useIsSearchAvailable() {
-  const searchOperator = useRecoilValue(searchOperatorState)
-  return useMemo(() => {
-    const isReady = (type: string) =>
-      searchOperator[0]?.status?.conditions.some((c) => c.type.toLowerCase() === type && c.status === 'True')
-    const searchServices = [
-      'ready--search-api',
-      'ready--search-collector',
-      'ready--search-indexer',
-      'ready--search-postgres',
-    ]
-    return searchOperator.length > 0 && searchServices.every(isReady)
-  }, [searchOperator])
-}
-
 export function useSavedSearchLimit() {
   const settings = useRecoilValue(settingsState)
   return useMemo(() => parseInt(settings.SAVED_SEARCH_LIMIT ?? '10'), [settings])
@@ -234,6 +218,20 @@ export function useAppOCPSearchResultLimit() {
   return useMemo(() => parseInt(settings.APP_OCP_SEARCH_RESULT_LIMIT ?? '1000'), [settings])
 }
 
+export function useVirtualMachineActionsEnabled() {
+  const settings = useRecoilValue(settingsState)
+  return useMemo(
+    // default actions to enabled
+    () => {
+      if (settings.VIRTUAL_MACHINE_ACTIONS) {
+        return settings?.VIRTUAL_MACHINE_ACTIONS === 'enabled'
+      }
+      return true
+    },
+    [settings]
+  )
+}
+
 export function useVitualMachineSearchResultLimit() {
   const settings = useRecoilValue(settingsState)
   return useMemo(() => parseInt(settings.VM_RESULT_LIMIT ?? '-1'), [settings])
diff --git a/frontend/src/routes/Infrastructure/VirtualMachines/VirtualMachinesPage.test.tsx b/frontend/src/routes/Infrastructure/VirtualMachines/VirtualMachinesPage.test.tsx
index b32900fff8c..2beb4d9c365 100644
--- a/frontend/src/routes/Infrastructure/VirtualMachines/VirtualMachinesPage.test.tsx
+++ b/frontend/src/routes/Infrastructure/VirtualMachines/VirtualMachinesPage.test.tsx
@@ -2,94 +2,21 @@
 // Copyright (c) 2021 Red Hat, Inc.
 // Copyright Contributors to the Open Cluster Management project
 import { MockedProvider } from '@apollo/client/testing'
+import { UseK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'
 import { render, screen, waitFor } from '@testing-library/react'
 import userEvent from '@testing-library/user-event'
 import { GraphQLError } from 'graphql'
 import { MemoryRouter } from 'react-router-dom-v5-compat'
 import { RecoilRoot } from 'recoil'
-import { searchOperatorState } from '../../../atoms'
 import { nockPostRequest } from '../../../lib/nock-util'
 import { defaultPlugin, PluginContext } from '../../../lib/PluginContext'
+import { PluginDataContext } from '../../../lib/PluginDataContext'
 import { wait, waitForNocks } from '../../../lib/test-util'
 import { ActionExtensionProps, ListColumnExtensionProps } from '../../../plugin-extensions/properties'
 import { AcmExtension } from '../../../plugin-extensions/types'
-import { SearchOperator } from '../../../resources'
 import { SearchResultItemsAndRelatedItemsDocument, SearchSchemaDocument } from '../../Search/search-sdk/search-sdk'
 import VirtualMachinesPage from './VirtualMachinesPage'
 
-const mockHealthySearchOperator: SearchOperator = {
-  apiVersion: 'search.open-cluster-management.io/v1alpha1',
-  kind: 'Search',
-  metadata: {
-    name: 'search-v2-operator',
-    namespace: 'open-cluster-management',
-  },
-  status: {
-    conditions: [
-      {
-        message: 'None',
-        reason: 'None',
-        status: 'True',
-        type: 'Ready--search-postgres',
-      },
-      {
-        message: 'None',
-        reason: 'None',
-        status: 'True',
-        type: 'Ready--search-collector',
-      },
-      {
-        message: 'None',
-        reason: 'None',
-        status: 'True',
-        type: 'Ready--search-indexer',
-      },
-      {
-        message: 'None',
-        reason: 'None',
-        status: 'True',
-        type: 'Ready--search-api',
-      },
-    ],
-  },
-}
-const mockUNHealthySearchOperator: SearchOperator = {
-  apiVersion: 'search.open-cluster-management.io/v1alpha1',
-  kind: 'Search',
-  metadata: {
-    name: 'search-v2-operator',
-    namespace: 'open-cluster-management',
-  },
-  status: {
-    conditions: [
-      {
-        message: 'None',
-        reason: 'None',
-        status: 'True',
-        type: 'Ready--search-postgres',
-      },
-      {
-        message: 'None',
-        reason: 'None',
-        status: 'True',
-        type: 'Ready--search-collector',
-      },
-      {
-        message: 'None',
-        reason: 'None',
-        status: 'True',
-        type: 'Ready--search-indexer',
-      },
-      {
-        message: 'None',
-        reason: 'None',
-        status: 'False',
-        type: 'Ready--search-api',
-      },
-    ],
-  },
-}
-
 const vmActionProps: ActionExtensionProps[] = [
   {
     id: 'DR Action',
@@ -212,11 +139,7 @@ describe('VirtualMachinesPage Page', () => {
       },
     ]
     render(
-      <RecoilRoot
-        initializeState={(snapshot) => {
-          snapshot.set(searchOperatorState, [mockHealthySearchOperator])
-        }}
-      >
+      <RecoilRoot>
         <MemoryRouter>
           <MockedProvider mocks={mocks}>
             <PluginContext.Provider
@@ -263,6 +186,20 @@ describe('VirtualMachinesPage Page', () => {
   })
 
   it('should render page with search unavailable empty state', async () => {
+    const mockUseK8sWatchResource: UseK8sWatchResource = jest.fn()
+    const mockPluginContextValue = {
+      ocpApi: {
+        useK8sWatchResource: mockUseK8sWatchResource,
+      },
+      isACMAvailable: true,
+      isOverviewAvailable: true,
+      isSubmarinerAvailable: true,
+      isApplicationsAvailable: true,
+      isGovernanceAvailable: true,
+      isSearchAvailable: false,
+      dataContext: PluginDataContext,
+      acmExtensions: {},
+    }
     const metricNock = nockPostRequest('/metrics?virtual-machines', {})
     const mocks = [
       {
@@ -291,17 +228,15 @@ describe('VirtualMachinesPage Page', () => {
       },
     ]
     render(
-      <RecoilRoot
-        initializeState={(snapshot) => {
-          snapshot.set(searchOperatorState, [mockUNHealthySearchOperator])
-        }}
-      >
-        <MemoryRouter>
-          <MockedProvider mocks={mocks}>
-            <VirtualMachinesPage />
-          </MockedProvider>
-        </MemoryRouter>
-      </RecoilRoot>
+      <PluginContext.Provider value={mockPluginContextValue}>
+        <RecoilRoot>
+          <MemoryRouter>
+            <MockedProvider mocks={mocks}>
+              <VirtualMachinesPage />
+            </MockedProvider>
+          </MemoryRouter>
+        </RecoilRoot>
+      </PluginContext.Provider>
     )
     await waitForNocks([metricNock])
     // This wait pauses till apollo query is returning data
@@ -365,11 +300,7 @@ describe('VirtualMachinesPage Page', () => {
       },
     ]
     render(
-      <RecoilRoot
-        initializeState={(snapshot) => {
-          snapshot.set(searchOperatorState, [mockHealthySearchOperator])
-        }}
-      >
+      <RecoilRoot>
         <MemoryRouter>
           <MockedProvider mocks={mocks}>
             <VirtualMachinesPage />
diff --git a/frontend/src/routes/Infrastructure/VirtualMachines/VirtualMachinesPage.tsx b/frontend/src/routes/Infrastructure/VirtualMachines/VirtualMachinesPage.tsx
index 683961b2738..714dfa0bcf2 100644
--- a/frontend/src/routes/Infrastructure/VirtualMachines/VirtualMachinesPage.tsx
+++ b/frontend/src/routes/Infrastructure/VirtualMachines/VirtualMachinesPage.tsx
@@ -72,8 +72,8 @@ function VirtualMachineTable(props: Readonly<{ searchResultItems: ISearchResult[
   const { searchResultItems } = props
   const { t } = useTranslation()
   const navigate = useNavigate()
-  const { settingsState } = useSharedAtoms()
-  const vmActionsEnabled = useRecoilValue(settingsState)?.VIRTUAL_MACHINE_ACTIONS === 'enabled'
+  const { useVirtualMachineActionsEnabled } = useSharedAtoms()
+  const vmActionsEnabled = useVirtualMachineActionsEnabled()
   const { acmExtensions } = useContext(PluginContext)
   const allClusters = useAllClusters(true)
   const [deleteResource, setDeleteResource] = useState<IDeleteModalProps>(ClosedDeleteModalProps)
@@ -173,18 +173,16 @@ function VirtualMachineTable(props: Readonly<{ searchResultItems: ISearchResult[
 
 export default function VirtualMachinesPage() {
   const { t } = useTranslation()
-  const { dataContext } = useContext(PluginContext)
+  const { dataContext, isSearchAvailable } = useContext(PluginContext)
   const { loadStarted } = useContext(dataContext)
   const {
     clusterManagementAddonsState,
     configMapsState,
     useIsObservabilityInstalled,
-    useIsSearchAvailable,
     useSearchAutocompleteLimit,
     useVitualMachineSearchResultLimit,
   } = useSharedAtoms()
   const vmResultLimit = useVitualMachineSearchResultLimit()
-  const isSearchAvailable = useIsSearchAvailable()
   const searchAutocompleteLimit = useSearchAutocompleteLimit()
   const isObservabilityInstalled = useIsObservabilityInstalled()
   const configMaps = useRecoilValue(configMapsState)
diff --git a/frontend/src/routes/Search/Details/DetailsPage.tsx b/frontend/src/routes/Search/Details/DetailsPage.tsx
index 09ac4ed792c..1bbb056ac85 100644
--- a/frontend/src/routes/Search/Details/DetailsPage.tsx
+++ b/frontend/src/routes/Search/Details/DetailsPage.tsx
@@ -12,7 +12,7 @@ import { NavigationPath } from '../../../NavigationPath'
 import { IResource, IResourceDefinition } from '../../../resources'
 import { fireManagedClusterView } from '../../../resources/managedclusterview'
 import { getResource } from '../../../resources/utils/resource-request'
-import { useRecoilValue, useSharedAtoms } from '../../../shared-recoil'
+import { useSharedAtoms } from '../../../shared-recoil'
 import { AcmPage, AcmPageHeader, AcmSecondaryNav, AcmSecondaryNavItem } from '../../../ui-components'
 import {
   ClosedVMActionModalProps,
@@ -52,8 +52,8 @@ export default function DetailsPage() {
   usePageVisitMetricHandler(Pages.searchDetails)
   const { t } = useTranslation()
   const navigate = useNavigate()
-  const { settingsState } = useSharedAtoms()
-  const vmActionsEnabled = useRecoilValue(settingsState)?.VIRTUAL_MACHINE_ACTIONS === 'enabled'
+  const { useVirtualMachineActionsEnabled } = useSharedAtoms()
+  const vmActionsEnabled = useVirtualMachineActionsEnabled()
   const [resource, setResource] = useState<any>(undefined)
   const [containers, setContainers] = useState<string[]>()
   const [resourceVersion, setResourceVersion] = useState<string>('')
diff --git a/frontend/src/routes/Search/Details/SnapshotsTab.tsx b/frontend/src/routes/Search/Details/SnapshotsTab.tsx
index 9378367c279..d8e677f704d 100644
--- a/frontend/src/routes/Search/Details/SnapshotsTab.tsx
+++ b/frontend/src/routes/Search/Details/SnapshotsTab.tsx
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
 import { useNavigate } from 'react-router-dom-v5-compat'
 import { useTranslation } from '../../../lib/acm-i18next'
 import { fireManagedClusterView } from '../../../resources/managedclusterview'
-import { useRecoilValue, useSharedAtoms } from '../../../shared-recoil'
+import { useSharedAtoms } from '../../../shared-recoil'
 import { AcmLoadingPage, AcmTable } from '../../../ui-components'
 import { useAllClusters } from '../../Infrastructure/Clusters/ManagedClusters/components/useAllClusters'
 import {
@@ -32,10 +32,10 @@ import { getResourceParams } from './DetailsPage'
 export default function SnapshotsTab() {
   const { t } = useTranslation()
   const { cluster, kind, apiversion, namespace, name } = getResourceParams()
-  const { useSearchResultLimit, settingsState } = useSharedAtoms()
+  const { useSearchResultLimit, useVirtualMachineActionsEnabled } = useSharedAtoms()
   const searchResultLimit = useSearchResultLimit()
   const searchDefinitions = useSearchDefinitions()
-  const vmActionsEnabled = useRecoilValue(settingsState)?.VIRTUAL_MACHINE_ACTIONS === 'enabled'
+  const vmActionsEnabled = useVirtualMachineActionsEnabled()
   const navigate = useNavigate()
   const allClusters = useAllClusters(true)
   const [VMAction, setVMAction] = useState<IVMActionModalProps>(ClosedVMActionModalProps)
diff --git a/frontend/src/routes/Search/SearchResults/RelatedResults.tsx b/frontend/src/routes/Search/SearchResults/RelatedResults.tsx
index 8c3da1160dd..e0f5c8382a7 100644
--- a/frontend/src/routes/Search/SearchResults/RelatedResults.tsx
+++ b/frontend/src/routes/Search/SearchResults/RelatedResults.tsx
@@ -49,8 +49,8 @@ export function RenderItemContent(
   const { t } = useTranslation()
   const navigate = useNavigate()
   const allClusters = useAllClusters(true)
-  const { settingsState } = useSharedAtoms()
-  const vmActionsEnabled = useRecoilValue(settingsState)?.VIRTUAL_MACHINE_ACTIONS === 'enabled'
+  const { useVirtualMachineActionsEnabled } = useSharedAtoms()
+  const vmActionsEnabled = useVirtualMachineActionsEnabled()
   const { useSearchResultLimit } = useSharedAtoms()
   const searchResultLimit = useSearchResultLimit()
   const { acmExtensions } = useContext(PluginContext)
diff --git a/frontend/src/routes/Search/SearchResults/SearchResults.tsx b/frontend/src/routes/Search/SearchResults/SearchResults.tsx
index 4a07e773172..2bd400ffaa3 100644
--- a/frontend/src/routes/Search/SearchResults/SearchResults.tsx
+++ b/frontend/src/routes/Search/SearchResults/SearchResults.tsx
@@ -91,8 +91,8 @@ function RenderAccordionItem(
   const { t } = useTranslation()
   const navigate = useNavigate()
   const allClusters = useAllClusters(true)
-  const { settingsState } = useSharedAtoms()
-  const vmActionsEnabled = useRecoilValue(settingsState)?.VIRTUAL_MACHINE_ACTIONS === 'enabled'
+  const { useVirtualMachineActionsEnabled } = useSharedAtoms()
+  const vmActionsEnabled = useVirtualMachineActionsEnabled()
   const { acmExtensions } = useContext(PluginContext)
   const [isExpanded, setIsExpanded] = useState<boolean>(defaultIsExpanded)
   const searchDefinitions = useSearchDefinitions()


Reviews:
user: login: Randy424
body: /lgtm
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T19:06:35Z
user: login: KevinFCormier
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T19:30:04Z
user: login: zlayne
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T19:46:28Z

Review comments:
diff_hunk: @@ -234,6 +218,20 @@ export function useAppOCPSearchResultLimit() {
   return useMemo(() => parseInt(settings.APP_OCP_SEARCH_RESULT_LIMIT ?? '1000'), [settings])
 }
 
+export function useVitualMachineActionsEnabled() {
path: frontend/src/atoms.ts
user: login: KevinFCormier
body: Do you want to just fix this typo before merging?
created_at: 2025-05-21T19:30:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -234,6 +218,20 @@ export function useAppOCPSearchResultLimit() {
   return useMemo(() => parseInt(settings.APP_OCP_SEARCH_RESULT_LIMIT ?? '1000'), [settings])
 }
 
+export function useVitualMachineActionsEnabled() {
path: frontend/src/atoms.ts
user: login: zlayne
body: Yes, thank you!
created_at: 2025-05-21T19:46:28Z
author_association: CONTRIBUTOR

PR comments:
user: login: KevinFCormier
created_at: 2025-05-21T19:30:16Z
body: /hold
author_association: CONTRIBUTOR
user: login: KevinFCormier
created_at: 2025-05-21T20:12:17Z
body: /lgtm
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-21T20:12:18Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=open-cluster-management_console&pullRequest=4518) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=open-cluster-management_console&pullRequest=4518&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=open-cluster-management_console&pullRequest=4518&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=open-cluster-management_console&pullRequest=4518&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=open-cluster-management_console&pullRequest=4518&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=open-cluster-management_console&pullRequest=4518&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=open-cluster-management_console&pullRequest=4518)


author_association: NONE
user: login: openshift-ci[bot]
created_at: 2025-05-21T20:12:28Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/stolostron/console/pull/4518#issuecomment-2899118603" title="LGTM">KevinFCormier</a>*, *<a href="https://github.com/stolostron/console/pull/4518#pullrequestreview-2858839016" title="LGTM">Randy424</a>*, *<a href="https://github.com/stolostron/console/pull/4518#" title="Author self-approved">zlayne</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=stolostron%2Fconsole).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/stolostron/console/blob/main/OWNERS)~~ [KevinFCormier,Randy424,zlayne]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: NONE