From 4a8c724371bf9594c797b3b1610c0a74f145d1fd Mon Sep 17 00:00:00 2001 From: Nadja Heitmann Date: Thu, 31 Aug 2023 12:47:54 +0000 Subject: [PATCH] Fixes #35713 - Host details tab for Debian packages --- .../katello/api/v2/debs_controller.rb | 4 + .../katello/api/v2/host_debs_controller.rb | 13 +- .../concerns/host_managed_extensions.rb | 20 +- app/models/katello/deb.rb | 8 + app/models/katello/installed_deb.rb | 1 + app/presenters/katello/host_deb_presenter.rb | 23 + .../install_packages_by_search_query.erb | 13 +- .../remove_packages_by_search_query.erb | 11 +- .../update_packages_by_search_query.erb | 11 +- .../katello/api/v2/host_debs/base.json.rabl | 2 + .../Tabs/ContentTab/SecondaryTabsRoutes.js | 4 + .../HostDetails/Tabs/ContentTab/constants.js | 5 +- .../Tabs/DebsTab/DebInstallModal.js | 250 ++++++++ .../Tabs/DebsTab/DebInstallModal.scss | 3 + .../HostDetails/Tabs/DebsTab/DebsTab.js | 597 ++++++++++++++++++ .../HostDetails/Tabs/DebsTab/DebsTab.scss | 12 + .../Tabs/DebsTab/HostDebsActions.js | 13 + .../Tabs/DebsTab/HostDebsConstants.js | 13 + .../Tabs/DebsTab/HostDebsSelectors.js | 16 + .../Tabs/DebsTab/InstallableDebsActions.js | 17 + .../Tabs/DebsTab/InstallableDebsConstants.js | 3 + .../Tabs/DebsTab/InstallableDebsSelectors.js | 16 + .../Tabs/PackagesTab/PackagesTab.js | 7 + .../Tabs/__tests__/debs.fixtures.json | 28 + .../Tabs/__tests__/debsTab.test.js | 419 ++++++++++++ 25 files changed, 1499 insertions(+), 10 deletions(-) create mode 100644 app/presenters/katello/host_deb_presenter.rb create mode 100644 webpack/components/extensions/HostDetails/Tabs/DebsTab/DebInstallModal.js create mode 100644 webpack/components/extensions/HostDetails/Tabs/DebsTab/DebInstallModal.scss create mode 100644 webpack/components/extensions/HostDetails/Tabs/DebsTab/DebsTab.js create mode 100644 webpack/components/extensions/HostDetails/Tabs/DebsTab/DebsTab.scss create mode 100644 webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsActions.js create mode 100644 webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsConstants.js create mode 100644 webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsSelectors.js create mode 100644 webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsActions.js create mode 100644 webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsConstants.js create mode 100644 webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsSelectors.js create mode 100644 webpack/components/extensions/HostDetails/Tabs/__tests__/debs.fixtures.json create mode 100644 webpack/components/extensions/HostDetails/Tabs/__tests__/debsTab.test.js diff --git a/app/controllers/katello/api/v2/debs_controller.rb b/app/controllers/katello/api/v2/debs_controller.rb index 74de817e717..bc3b5dc3e39 100644 --- a/app/controllers/katello/api/v2/debs_controller.rb +++ b/app/controllers/katello/api/v2/debs_controller.rb @@ -88,9 +88,13 @@ def available_for_content_view_version(version) def custom_index_relation(collection) applicable = ::Foreman::Cast.to_bool(params[:packages_restrict_applicable]) || params[:host_id] upgradable = ::Foreman::Cast.to_bool(params[:packages_restrict_upgradable]) + not_installed = ::Foreman::Cast.to_bool(params[:packages_restrict_not_installed]) if upgradable collection = collection.installable_for_hosts(@hosts) + elsif not_installed && params[:host_id] + host = @hosts.first + collection = Katello::Deb.deb_installable_for_host(host) elsif applicable collection = collection.applicable_to_hosts(@hosts) end diff --git a/app/controllers/katello/api/v2/host_debs_controller.rb b/app/controllers/katello/api/v2/host_debs_controller.rb index b3084ef5252..7ef3282c249 100644 --- a/app/controllers/katello/api/v2/host_debs_controller.rb +++ b/app/controllers/katello/api/v2/host_debs_controller.rb @@ -11,14 +11,25 @@ class Api::V2::HostDebsController < Api::V2::ApiController api :GET, "/hosts/:host_id/debs", N_("List deb packages installed on the host") param :host_id, :number, :required => true, :desc => N_("ID of the host") + param :include_latest_upgradable, :boolean, :desc => N_("Also include the latest upgradable package version for each host package") + param :status, String, :desc => N_("Return only packages of a particular status (upgradable or up-to-date)"), :required => false param_group :search, Api::V2::ApiController def index collection = scoped_search(index_relation, :name, :asc, :resource_class => ::Katello::InstalledDeb) + collection[:results] = HostDebPresenter.with_latest(collection[:results], @host) if ::Foreman::Cast.to_bool(params[:include_latest_upgradable]) respond_for_index(:collection => collection) end def index_relation - @host.installed_debs + packages = @host.installed_debs + upgradable_packages = ::Katello::Deb.installable_for_hosts([@host]).select(:name) + if params[:status].present? + packages = case params[:status] + when 'up-to-date' then packages.where.not(name: upgradable_packages) + when 'upgradable' then packages.where(name: upgradable_packages) + end + end + packages end def resource_class diff --git a/app/models/katello/concerns/host_managed_extensions.rb b/app/models/katello/concerns/host_managed_extensions.rb index 58755bac1a8..5f80f5d0e56 100644 --- a/app/models/katello/concerns/host_managed_extensions.rb +++ b/app/models/katello/concerns/host_managed_extensions.rb @@ -551,6 +551,24 @@ def package_names_for_job_template(action:, search:, versions: nil) end end + def deb_names_for_job_template(action:, search:) + actions = %w(install remove update).freeze + case action + when 'install' + ::Katello::Deb.deb_installable_for_host(self).search_for(search).distinct.pluck(:name) + when 'remove' + return [] if search.empty? + + installed_debs.search_for(search).distinct.pluck(:name) + when 'update' + return [] if search.empty? + + installed_debs.search_for(search).distinct.pluck(:name) + else + fail ::Foreman::Exception.new(N_("package_names_for_job_template: Action must be one of %s"), actions.join(', ')) + end + end + def advisory_ids(search:) ::Katello::Erratum.installable_for_hosts([self]).search_for(search).pluck(:errata_id) end @@ -580,7 +598,7 @@ class ::Host::Managed::Jail < Safemode::Jail :host_collections, :pools, :hypervisor_host, :installed_debs, :installed_packages, :traces_helpers, :advisory_ids, :package_names_for_job_template, :filtered_entitlement_quantity_consumed, :bound_repositories, - :single_content_view, :single_lifecycle_environment + :single_content_view, :single_lifecycle_environment, :deb_names_for_job_template end class ActiveRecord::Associations::CollectionProxy::Jail < Safemode::Jail diff --git a/app/models/katello/deb.rb b/app/models/katello/deb.rb index 0384ff3184c..5a4d4e69d71 100644 --- a/app/models/katello/deb.rb +++ b/app/models/katello/deb.rb @@ -10,6 +10,7 @@ class Deb < Katello::Model :dependent => :destroy, :inverse_of => :deb has_many :content_facets, :through => :content_facet_applicable_debs, :class_name => "Katello::Host::ContentFacet" + scoped_search :on => :id, :complete_value => true scoped_search :on => :name, :complete_value => true scoped_search :on => :version, :complete_value => true scoped_search :on => :architecture, :complete_value => true @@ -95,6 +96,13 @@ def self.applicable_to_hosts(hosts) where("#{Katello::Host::ContentFacet.table_name}.host_id" => hosts).distinct end + # Return deb packages that are not installed on a host, but could be installed + # the word 'installable' has a different meaning here than elsewhere + def self.deb_installable_for_host(host) + repos = host.content_facet.bound_repositories.pluck(:id) + Katello::Deb.in_repositories(repos).where.not(name: host.installed_debs.pluck(:name)).order(:name) + end + def self.latest(_relation) fail 'NotImplemented' end diff --git a/app/models/katello/installed_deb.rb b/app/models/katello/installed_deb.rb index bb3eeb6d32e..76b7f77fdb9 100644 --- a/app/models/katello/installed_deb.rb +++ b/app/models/katello/installed_deb.rb @@ -3,6 +3,7 @@ class InstalledDeb < Katello::Model has_many :host_installed_debs, :class_name => "Katello::HostInstalledDeb", :dependent => :destroy, :inverse_of => :installed_deb has_many :hosts, :through => :host_installed_debs, :class_name => "::Host" + scoped_search :on => :id, :complete_value => true scoped_search :on => :name, :complete_value => true scoped_search :on => :version scoped_search :on => :architecture diff --git a/app/presenters/katello/host_deb_presenter.rb b/app/presenters/katello/host_deb_presenter.rb new file mode 100644 index 00000000000..adf4a0cf774 --- /dev/null +++ b/app/presenters/katello/host_deb_presenter.rb @@ -0,0 +1,23 @@ +module Katello + class HostDebPresenter < SimpleDelegator + attr_accessor :installed_package, :upgradable_versions, :deb_id + + def initialize(installed_package, upgradable_versions, deb_id) + @installed_package = installed_package + @upgradable_versions = upgradable_versions + @deb_id = deb_id + super(@installed_package) + end + + def self.with_latest(packages, host) + upgradable_packages_map = ::Katello::Deb.installable_for_hosts([host]).select(:id, :name, :architecture, :version).order(version: :desc).group_by { |i| [i.name, i.architecture] } + installed_packages_map = ::Katello::Deb.where(version: packages.map(&:version)).select(:id, :architecture, :name).group_by { |i| [i.name, i.architecture] } + + packages.map do |p| + upgrades = upgradable_packages_map[[p.name, p.architecture]]&.pluck(:version) + installed = installed_packages_map[[p.name, p.architecture]]&.first&.id + HostDebPresenter.new(p, upgrades, installed) + end + end + end +end diff --git a/app/views/foreman/job_templates/install_packages_by_search_query.erb b/app/views/foreman/job_templates/install_packages_by_search_query.erb index 7bac375a9b3..7813ffe6f05 100644 --- a/app/views/foreman/job_templates/install_packages_by_search_query.erb +++ b/app/views/foreman/job_templates/install_packages_by_search_query.erb @@ -11,9 +11,16 @@ template_inputs: input_type: user required: false %> -<% package_names = @host.package_names_for_job_template( +<% if @host.operatingsystem.family == 'Debian' + package_names = @host.deb_names_for_job_template( action: 'install', search: input('Package search query') -) -%> +) +else + package_names = @host.package_names_for_job_template( + action: 'install', + search: input('Package search query') +) +end -%> -<%= render_template('Package Action - Script Default', :action => 'install', :package => package_names.join(' ')) %> \ No newline at end of file +<%= render_template('Package Action - Script Default', :action => 'install', :package => package_names.join(' ')) %> diff --git a/app/views/foreman/job_templates/remove_packages_by_search_query.erb b/app/views/foreman/job_templates/remove_packages_by_search_query.erb index b6cc9917c3e..61ae3d06083 100644 --- a/app/views/foreman/job_templates/remove_packages_by_search_query.erb +++ b/app/views/foreman/job_templates/remove_packages_by_search_query.erb @@ -11,9 +11,16 @@ template_inputs: input_type: user required: true %> -<% package_names = @host.package_names_for_job_template( +<% if @host.operatingsystem.family == 'Debian' + package_names = @host.deb_names_for_job_template( action: 'remove', search: input('Packages search query') -) -%> +) +else + package_names = @host.package_names_for_job_template( + action: 'remove', + search: input('Packages search query') +) +end -%> <%= render_template('Package Action - Script Default', :action => 'remove', :package => package_names.join(' ')) %> diff --git a/app/views/foreman/job_templates/update_packages_by_search_query.erb b/app/views/foreman/job_templates/update_packages_by_search_query.erb index 4b63e0c0b2a..f5cf5e972a6 100644 --- a/app/views/foreman/job_templates/update_packages_by_search_query.erb +++ b/app/views/foreman/job_templates/update_packages_by_search_query.erb @@ -16,10 +16,17 @@ template_inputs: required: false value_type: plain %> -<% package_names = @host.package_names_for_job_template( +<% if @host.operatingsystem.family == 'Debian' + package_names = @host.deb_names_for_job_template( + action: 'update', + search: input('Packages search query') +) +else + package_names = @host.package_names_for_job_template( action: 'update', search: input('Packages search query'), versions: input('Selected update versions') -) -%> +) +end -%> <%= render_template('Package Action - Script Default', :action => 'update', :package => package_names.join(' ')) %> diff --git a/app/views/katello/api/v2/host_debs/base.json.rabl b/app/views/katello/api/v2/host_debs/base.json.rabl index 51fb3f0f5de..8045e74be79 100644 --- a/app/views/katello/api/v2/host_debs/base.json.rabl +++ b/app/views/katello/api/v2/host_debs/base.json.rabl @@ -2,3 +2,5 @@ object @resource attributes :id, :name attributes :version, :architecture +attributes :upgradable_versions +attributes :deb_id diff --git a/webpack/components/extensions/HostDetails/Tabs/ContentTab/SecondaryTabsRoutes.js b/webpack/components/extensions/HostDetails/Tabs/ContentTab/SecondaryTabsRoutes.js index 61ef63f7a59..399032d1dda 100644 --- a/webpack/components/extensions/HostDetails/Tabs/ContentTab/SecondaryTabsRoutes.js +++ b/webpack/components/extensions/HostDetails/Tabs/ContentTab/SecondaryTabsRoutes.js @@ -1,6 +1,7 @@ import React from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; import { PackagesTab } from '../PackagesTab/PackagesTab.js'; +import { DebsTab } from '../DebsTab/DebsTab.js'; import { ErrataTab } from '../ErrataTab/ErrataTab.js'; import { ModuleStreamsTab } from '../ModuleStreamsTab/ModuleStreamsTab'; import RepositorySetsTab from '../RepositorySetsTab/RepositorySetsTab'; @@ -11,6 +12,9 @@ const SecondaryTabRoutes = () => ( + + + diff --git a/webpack/components/extensions/HostDetails/Tabs/ContentTab/constants.js b/webpack/components/extensions/HostDetails/Tabs/ContentTab/constants.js index 324c475307a..e6289f46736 100644 --- a/webpack/components/extensions/HostDetails/Tabs/ContentTab/constants.js +++ b/webpack/components/extensions/HostDetails/Tabs/ContentTab/constants.js @@ -1,9 +1,12 @@ import { translate as __ } from 'foremanReact/common/I18n'; import { hideRepoSetsTab } from '../RepositorySetsTab/RepositorySetsTab'; import { hideModuleStreamsTab } from '../ModuleStreamsTab/ModuleStreamsTab'; +import { hideDebsTab } from '../DebsTab/DebsTab'; +import { hidePackagesTab } from '../PackagesTab/PackagesTab'; const SECONDARY_TABS = [ - { key: 'packages', title: __('Packages') }, + { key: 'debs', hideTab: hideDebsTab, title: __('Packages') }, + { key: 'packages', hideTab: hidePackagesTab, title: __('Packages') }, { key: 'errata', title: __('Errata') }, { key: 'module-streams', hideTab: hideModuleStreamsTab, title: __('Module streams') }, { key: 'Repository sets', hideTab: hideRepoSetsTab, title: __('Repository sets') }, diff --git a/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebInstallModal.js b/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebInstallModal.js new file mode 100644 index 00000000000..a3b285d99d1 --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebInstallModal.js @@ -0,0 +1,250 @@ +import React, { useState } from 'react'; +import { Modal, Button, Dropdown, DropdownItem, DropdownToggle, DropdownDirection, DropdownToggleAction } from '@patternfly/react-core'; +import { CaretDownIcon, CaretUpIcon } from '@patternfly/react-icons'; +import { useSelector } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import { Thead, Th, Tbody, Tr, Td, TableVariant } from '@patternfly/react-table'; +import { noop } from 'foremanReact/common/helpers'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { urlBuilder } from 'foremanReact/common/urlHelpers'; +import { selectAPIResponse } from 'foremanReact/redux/API/APISelectors'; +import PropTypes from 'prop-types'; +import TableWrapper from '../../../../Table/TableWrapper'; +import { useBulkSelect } from '../../../../Table/TableHooks'; +import { HOST_INSTALLABLE_DEBS_KEY } from './InstallableDebsConstants'; +import { selectHostInstallableDebsStatus } from './InstallableDebsSelectors'; +import { getHostInstallableDebs } from './InstallableDebsActions'; +import './DebInstallModal.scss'; +import { katelloPackageInstallBySearchUrl, katelloPackageInstallUrl } from '../customizedRexUrlHelpers'; +import hostIdNotReady from '../../HostDetailsActions'; + +const InstallDropdown = ({ + isDisabled, installViaRex, + bulkCustomizedRexUrl, +}) => { + const [isActionOpen, setIsActionOpen] = useState(false); + const onActionSelect = () => { + setIsActionOpen(false); + }; + const onActionToggle = () => { + setIsActionOpen(prev => !prev); + }; + + const dropdownItems = [ + + {__('Install via remote execution')} + , + + {__('Install via customized remote execution')} + , + ]; + + return ( + + Install + , + ]} + splitButtonVariant="action" + toggleVariant="primary" + toggleIndicator={isActionOpen ? CaretUpIcon : CaretDownIcon} + onToggle={onActionToggle} + /> + } + isOpen={isActionOpen} + dropdownItems={dropdownItems} + /> + ); +}; + +InstallDropdown.propTypes = { + isDisabled: PropTypes.bool, + installViaRex: PropTypes.func, + bulkCustomizedRexUrl: PropTypes.string, +}; + +InstallDropdown.defaultProps = { + isDisabled: false, + installViaRex: noop, + bulkCustomizedRexUrl: '', +}; + +const DebInstallModal = ({ + isOpen, closeModal, hostId, hostName, triggerPackageInstall, +}) => { + const emptyContentTitle = __('No packages available to install'); + const emptyContentBody = __('No packages available to install on this host. Please check the host\'s content view and lifecycle environment.'); + const emptySearchTitle = __('No matching packages found'); + const emptySearchBody = __('Try changing your search settings.'); + const columnHeaders = ['', __('Package'), __('Version')]; + const response = + useSelector(state => selectAPIResponse(state, HOST_INSTALLABLE_DEBS_KEY)); + const status = useSelector(state => selectHostInstallableDebsStatus(state)); + const { results, ...metadata } = response; + const [suppressFirstFetch, setSuppressFirstFetch] = useState(false); + + const { + searchQuery, + updateSearchQuery, + isSelected, + selectOne, + selectNone, + fetchBulkParams, + isSelectable, + selectedCount, + selectedResults, + ...selectAll + } = useBulkSelect({ results, metadata }); + + const fetchItems = (params) => { + if (!hostId) return hostIdNotReady; + + if (results?.length > 0 && suppressFirstFetch) { + // If the modal has already been opened, no need to re-fetch the data that's already present + setSuppressFirstFetch(false); + return { type: 'HOST_APPLICABLE_PACKAGES_NOOP' }; + } + return getHostInstallableDebs(hostId, params); + }; + + const selectedPackageNames = () => selectedResults.map(({ name }) => name); + + const installViaRex = () => { + triggerPackageInstall(fetchBulkParams()); + selectNone(); + closeModal(); + }; + + const handleModalClose = () => { + setSuppressFirstFetch(true); + closeModal(); + }; + + const bulkCustomizedRexUrl = selectedCount ? + katelloPackageInstallBySearchUrl({ hostname: hostName, search: fetchBulkParams() }) : + '#'; + const simpleBulkCustomizedRexUrl + = katelloPackageInstallUrl({ hostname: hostName, packages: selectedPackageNames() }); + const enableSimpleRexUrl = !!selectedResults.length; + + const modalActions = ([ + , + , + ]); + + return ( + + {hostName}, + }} + /> + + + + {columnHeaders.map(col => + {col})} + + + + + {results?.map((pkg, rowIndex) => { + const { + id, + name: packageName, + deb_id: debId, + version, + } = pkg; + return ( + + selectOne(selected, id, pkg), + rowIndex, + variant: 'checkbox', + }} + /> + + {debId + ? {packageName} + : packageName + } + + + {version} + + + ); + }) + } + + + + ); +}; + +DebInstallModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + closeModal: PropTypes.func.isRequired, + hostId: PropTypes.number.isRequired, + hostName: PropTypes.string.isRequired, + triggerPackageInstall: PropTypes.func.isRequired, +}; + +export default DebInstallModal; diff --git a/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebInstallModal.scss b/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebInstallModal.scss new file mode 100644 index 00000000000..148787c4d05 --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebInstallModal.scss @@ -0,0 +1,3 @@ +#package-install-modal .pf-c-input-group input { + min-width: 15em; +} \ No newline at end of file diff --git a/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebsTab.js b/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebsTab.js new file mode 100644 index 00000000000..45a66e70684 --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebsTab.js @@ -0,0 +1,597 @@ +import React, { useCallback, useState, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { + ActionList, + ActionListItem, + Dropdown, + DropdownItem, + DropdownSeparator, + DropdownToggle, + DropdownToggleAction, + KebabToggle, + Select, + SelectOption, + SelectVariant, + Skeleton, + Split, + SplitItem, +} from '@patternfly/react-core'; +import { TableVariant, Thead, Tbody, Tr, Th, Td, TableText } from '@patternfly/react-table'; +import PropTypes from 'prop-types'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { selectAPIResponse } from 'foremanReact/redux/API/APISelectors'; + +import { urlBuilder } from 'foremanReact/common/urlHelpers'; +import SelectableDropdown from '../../../../SelectableDropdown'; +import TableWrapper from '../../../../../components/Table/TableWrapper'; +import { useBulkSelect, useTableSort, useUrlParams, useSet } from '../../../../../components/Table/TableHooks'; +import PackagesStatus from '../../../../../components/Packages'; +import { + getInstalledDebs, +} from './HostDebsActions'; +import { selectHostDebsStatus } from './HostDebsSelectors'; +import { + HOST_DEBS_KEY, PACKAGES_VERSION_STATUSES, VERSION_STATUSES_TO_PARAM, +} from './HostDebsConstants'; +import { removePackage, updatePackage, removePackages, updatePackages, installPackageBySearch } from '../RemoteExecutionActions'; +import { katelloPackageUpdateUrl, packagesUpdateUrl } from '../customizedRexUrlHelpers'; +import './DebsTab.scss'; +import hostIdNotReady, { getHostDetails } from '../../HostDetailsActions'; +import DebInstallModal from './DebInstallModal'; +import { hasRequiredPermissions as can, + missingRequiredPermissions as cannot, + userPermissionsFromHostDetails } from '../../hostDetailsHelpers'; +import SortableColumnHeaders from '../../../../Table/components/SortableColumnHeaders'; +import { useRexJobPolling } from '../RemoteExecutionHooks'; + +export const hideDebsTab = ({ hostDetails }) => { + const osMatch = hostDetails?.operatingsystem_name?.match(/(\D+) (\d+|\D+)/); + if (!osMatch) return false; + const [, os, version] = osMatch; + return !(osMatch && os.match(/Debian|Ubuntu/i)); +}; + +const invokeRexJobs = ['create_job_invocations']; +const createBookmarks = ['create_bookmarks']; + +const UpdateVersionsSelect = ({ + packageName, + rowIndex, + selections, + upgradableVersions, + toggleUpgradableVersionSelect, + onUpgradableVersionSelect, + upgradableVersionSelectOpen, +}) => { + if (upgradableVersions === null) { + return ; + } else if (upgradableVersions.length === 1) { + return {upgradableVersions[0]}; + } + + return ( +
+ + + +
+ ); +}; + +UpdateVersionsSelect.propTypes = { + packageName: PropTypes.string.isRequired, + rowIndex: PropTypes.number.isRequired, + selections: PropTypes.string, + upgradableVersions: PropTypes.arrayOf(PropTypes.string), + toggleUpgradableVersionSelect: PropTypes.func, + onUpgradableVersionSelect: PropTypes.func, + upgradableVersionSelectOpen: PropTypes.shape({ + has: PropTypes.func, + rowIndex: PropTypes.number, + }), +}; + +UpdateVersionsSelect.defaultProps = { + selections: null, + upgradableVersions: null, + toggleUpgradableVersionSelect: undefined, + onUpgradableVersionSelect: undefined, + upgradableVersionSelectOpen: null, +}; + +export const DebsTab = () => { + const hostDetails = useSelector(state => selectAPIResponse(state, 'HOST_DETAILS')); + const { + id: hostId, + name: hostname, + } = hostDetails; + + const { searchParam, status: statusParam } = useUrlParams(); + const PACKAGE_STATUS = __('Status'); + const [packageStatusSelected, setPackageStatusSelected] = useState(statusParam ?? PACKAGE_STATUS); + const activeFilters = [packageStatusSelected]; + const defaultFilters = [PACKAGE_STATUS]; + const [isBulkActionOpen, setIsBulkActionOpen] = useState(false); + const toggleBulkAction = () => setIsBulkActionOpen(prev => !prev); + const [isModalOpen, setIsModalOpen] = useState(false); + const closeModal = () => setIsModalOpen(false); + const showActions = can(invokeRexJobs, userPermissionsFromHostDetails({ hostDetails })); + + const [isActionOpen, setIsActionOpen] = useState(false); + const onActionSelect = () => { + setIsActionOpen(false); + }; + const onActionToggle = () => { + setIsActionOpen(prev => !prev); + }; + + const upgradableVersionSelectOpen = useSet([]); + const toggleUpgradableVersionSelect = (isOpenState, rowIndex) => { + if (isOpenState) { + upgradableVersionSelectOpen.add(rowIndex); + } else { + upgradableVersionSelectOpen.delete(rowIndex); + } + }; + + const selectedNewVersions = useRef({}); + const onUpgradableVersionSelect = (_event, selected, rowIndex, packageName) => { + toggleUpgradableVersionSelect(false, rowIndex); + selectedNewVersions.current[packageName] = selected; + }; + const selectedPackageUpgradeVersion = ({ packageName, upgradableVersions }) => ( + selectedNewVersions.current[packageName] || upgradableVersions[0] + ); + const selectedNVRAVersions = Object.keys(selectedNewVersions.current).map(k => + selectedNewVersions.current[k]); + + const emptyContentTitle = __('This host does not have any packages.'); + const emptyContentBody = __('Packages will appear here when available.'); + const emptySearchTitle = __('No matching packages found'); + const emptySearchBody = __('Try changing your search settings.'); + const errorSearchTitle = __('Problem searching packages'); + const columnHeaders = [ + __('Package'), + __('Status'), + __('Installed version'), + __('Upgradable to'), + ]; + + const COLUMNS_TO_SORT_PARAMS = { + [columnHeaders[0]]: 'name', + [columnHeaders[2]]: 'version', + }; + + const { + pfSortParams, apiSortParams, + activeSortColumn, activeSortDirection, + } = useTableSort({ + allColumns: columnHeaders, + columnsToSortParams: COLUMNS_TO_SORT_PARAMS, + initialSortColumnName: 'Package', + }); + + const fetchItems = useCallback( + (params) => { + if (!hostId) return hostIdNotReady; + const modifiedParams = { ...params }; + if (packageStatusSelected !== PACKAGE_STATUS) { + modifiedParams.status = VERSION_STATUSES_TO_PARAM[packageStatusSelected]; + } + return getInstalledDebs(hostId, { ...apiSortParams, ...modifiedParams }); + }, + [hostId, PACKAGE_STATUS, packageStatusSelected, apiSortParams], + ); + + const response = useSelector(state => selectAPIResponse(state, HOST_DEBS_KEY)); + const { results, ...metadata } = response; + const { error: errorSearchBody } = metadata; + const status = useSelector(state => selectHostDebsStatus(state)); + const { + selectOne, + isSelected, + searchQuery, + updateSearchQuery, + selectedCount, + isSelectable, + selectedResults, + selectNone, + selectAllMode, + areAllRowsSelected, + fetchBulkParams, + ...selectAll + } = useBulkSelect({ + results, + metadata, + initialSearchQuery: searchParam || '', + }); + + const packageRemoveAction = packageName => removePackage({ + hostname, + packageName, + }); + + const { + triggerJobStart: triggerPackageRemove, lastCompletedJob: lastCompletedPackageRemove, + isPolling: isRemoveInProgress, + } = useRexJobPolling(packageRemoveAction); + + const packageBulkRemoveAction = bulkParams => removePackages({ + hostname, + search: bulkParams, + }); + + const { + triggerJobStart: triggerBulkPackageRemove, + lastCompletedJob: lastCompletedBulkPackageRemove, + isPolling: isBulkRemoveInProgress, + } = useRexJobPolling(packageBulkRemoveAction); + + const packageUpgradeAction = ({ packageName, upgradableVersions }) => updatePackage({ + hostname, + packageName: selectedPackageUpgradeVersion({ packageName, upgradableVersions }), + }); + + const { + triggerJobStart: triggerPackageUpgrade, + lastCompletedJob: lastCompletedPackageUpgrade, + isPolling: isUpgradeInProgress, + } = useRexJobPolling(packageUpgradeAction, getHostDetails({ hostname })); + + const packageBulkUpgradeAction = bulkParams => updatePackages({ + hostname, + search: bulkParams, + versions: JSON.stringify(selectedNVRAVersions || []), + }); + + const { + triggerJobStart: triggerBulkPackageUpgrade, + lastCompletedJob: lastCompletedBulkPackageUpgrade, + isPolling: isBulkUpgradeInProgress, + } = useRexJobPolling(packageBulkUpgradeAction, getHostDetails({ hostname })); + + const packageInstallAction + = bulkParams => installPackageBySearch({ hostname, search: bulkParams }); + + const { + triggerJobStart: triggerPackageInstall, + lastCompletedJob: lastCompletedPackageInstall, + isPolling: isInstallInProgress, + } = useRexJobPolling(packageInstallAction, getHostDetails({ hostname })); + + const actionInProgress = (isRemoveInProgress || isUpgradeInProgress + || isBulkRemoveInProgress || isBulkUpgradeInProgress || isInstallInProgress); + const disabledReason = __('A remote execution job is in progress.'); + + if (!hostId) return ; + + const handleInstallPackagesClick = () => { + setIsBulkActionOpen(false); + setIsModalOpen(true); + }; + + const removePackageViaRemoteExecution = packageName => triggerPackageRemove(packageName); + + const removePackagesViaRemoteExecution = () => { + const selected = fetchBulkParams(); + setIsBulkActionOpen(false); + selectNone(); + triggerBulkPackageRemove(selected); + }; + + const removeBulk = () => removePackagesViaRemoteExecution(); + + const handlePackageRemove = packageName => removePackageViaRemoteExecution(packageName); + + const upgradeViaRemoteExecution = ({ packageName, upgradableVersions }) => ( + triggerPackageUpgrade({ packageName, upgradableVersions }) + ); + + const upgradeBulkViaRemoteExecution = () => { + const selected = fetchBulkParams(); + setIsBulkActionOpen(false); + selectNone(); + triggerBulkPackageUpgrade(selected); + }; + + const upgradeBulk = () => upgradeBulkViaRemoteExecution(); + + const upgradeViaCustomizedRemoteExecution = selectedCount ? + packagesUpdateUrl({ + hostname, + search: fetchBulkParams(), + versions: JSON.stringify(selectedNVRAVersions), + }) : '#'; + + const disableRemove = () => selectedCount === 0 || selectAllMode; + + const allUpgradable = () => selectedResults.length > 0 && + selectedResults.every(item => item.upgradable_versions?.length > 0); + const disableUpgrade = () => selectedCount === 0 || + (selectAllMode && packageStatusSelected !== 'Upgradable') || + (!selectAllMode && !allUpgradable()); + + const readOnlyBookmarks = + cannot(createBookmarks, userPermissionsFromHostDetails({ hostDetails })); + + const dropdownUpgradeItems = [ + + {__('Upgrade via remote execution')} + , + + {__('Upgrade via customized remote execution')} + , + ]; + + const dropdownRemoveItems = [ + + {__('Remove')} + , + , + + {__('Install packages')} + , + ]; + + const handlePackageStatusSelected = newStatus => setPackageStatusSelected((prevStatus) => { + if (prevStatus === newStatus) { + return PACKAGE_STATUS; + } + return newStatus; + }); + + const actionButtons = showActions ? ( + + + + + + {__('Upgrade')} + , + ]} + isDisabled={actionInProgress || disableUpgrade()} + splitButtonVariant="action" + toggleVariant="primary" + onToggle={onActionToggle} + /> + } + isOpen={isActionOpen} + dropdownItems={dropdownUpgradeItems} + /> + + + } + isOpen={isBulkActionOpen} + isPlain + dropdownItems={dropdownRemoveItems} + ouiaId="bulk_actions_dropdown" + /> + + + + + ) : null; + + const statusFilters = ( + + + + + + ); + + const resetFilters = () => setPackageStatusSelected(PACKAGE_STATUS); + + return ( +
+
+ + + + + + + + + + {results?.map((pkg, rowIndex) => { + const { + id, + name: packageName, + version: installedVersion, + deb_id: debId, + upgradable_versions: upgradableVersions, + } = pkg; + + const rowActions = [ + { + title: __('Remove'), + isDisabled: actionInProgress, + onClick: () => handlePackageRemove(packageName), + }, + ]; + + if (upgradableVersions) { + rowActions.unshift( + { + title: __('Upgrade via remote execution'), + onClick: () => upgradeViaRemoteExecution({ packageName, upgradableVersions }), + isDisabled: actionInProgress, + }, + { + title: __('Upgrade via customized remote execution'), + component: 'a', + href: katelloPackageUpdateUrl({ + hostname, + packageName: selectedPackageUpgradeVersion({ + packageName, + upgradableVersions, + }), + }), + }, + ); + } + + return ( + + {showActions ? ( + selectOne(selected, id, pkg), + rowIndex, + variant: 'checkbox', + }} + title={actionInProgress ? disabledReason : undefined} + /> + ) :  } + + {debId + ? {packageName} + : packageName + } + + + {installedVersion.replace(`${packageName}-`, '')} + + + + {showActions ? ( + + ) : null} + + ); + }) + } + + +
+ {hostId && + + } +
+ ); +}; + +export default DebsTab; diff --git a/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebsTab.scss b/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebsTab.scss new file mode 100644 index 00000000000..b8f7049a892 --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/DebsTab/DebsTab.scss @@ -0,0 +1,12 @@ +#packages-tab { + margin: 16px 0; +} + +#packages-alert { + margin: 16px 24px; +} + +#style-select-id .pf-c-select button.pf-c-select__toggle { + font-size: 1em; + margin-left: -0.5rem; +} diff --git a/webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsActions.js b/webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsActions.js new file mode 100644 index 00000000000..7b4b315a528 --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsActions.js @@ -0,0 +1,13 @@ +import { API_OPERATIONS, get } from 'foremanReact/redux/API'; +import { foremanApi } from '../../../../../services/api'; +import { + HOST_DEBS_KEY, +} from './HostDebsConstants'; + +export const getInstalledDebs = (hostId, params) => get({ + type: API_OPERATIONS.GET, + key: HOST_DEBS_KEY, + url: foremanApi.getApiUrl(`/hosts/${hostId}/debs?include_latest_upgradable=true`), + params, +}); +export default getInstalledDebs; diff --git a/webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsConstants.js b/webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsConstants.js new file mode 100644 index 00000000000..146ad96bcb6 --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsConstants.js @@ -0,0 +1,13 @@ +export const HOST_DEBS_KEY = 'HOST_DEBS'; +export const PACKAGES_SEARCH_QUERY = 'Packages search query'; +export const SELECTED_UPDATE_VERSIONS = 'Selected update versions'; + +export const PACKAGES_VERSION_STATUSES = { + UPGRADABLE: 'Upgradable', + UP_TO_DATE: 'Up-to date', +}; + +export const VERSION_STATUSES_TO_PARAM = { + [PACKAGES_VERSION_STATUSES.UPGRADABLE]: 'upgradable', + [PACKAGES_VERSION_STATUSES.UP_TO_DATE]: 'up-to-date', +}; diff --git a/webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsSelectors.js b/webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsSelectors.js new file mode 100644 index 00000000000..3b4d5d8b16b --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/DebsTab/HostDebsSelectors.js @@ -0,0 +1,16 @@ +import { + selectAPIStatus, + selectAPIError, + selectAPIResponse, +} from 'foremanReact/redux/API/APISelectors'; +import { STATUS } from 'foremanReact/constants'; +import { HOST_DEBS_KEY } from './HostDebsConstants'; + +export const selectHostDebs = state => + selectAPIResponse(state, HOST_DEBS_KEY) || {}; + +export const selectHostDebsStatus = state => + selectAPIStatus(state, HOST_DEBS_KEY) || STATUS.PENDING; + +export const selectHostDebsError = state => + selectAPIError(state, HOST_DEBS_KEY); diff --git a/webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsActions.js b/webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsActions.js new file mode 100644 index 00000000000..cd66e7c9e34 --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsActions.js @@ -0,0 +1,17 @@ +import { API_OPERATIONS, get } from 'foremanReact/redux/API'; +import katelloApi from '../../../../../services/api'; +import { HOST_INSTALLABLE_DEBS_KEY } from './InstallableDebsConstants'; + +export const getHostInstallableDebs = (hostId, params) => get({ + type: API_OPERATIONS.GET, + key: HOST_INSTALLABLE_DEBS_KEY, + url: katelloApi.getApiUrl('/debs'), + params: { + ...params, + host_id: hostId, + packages_restrict_not_installed: true, + packages_restrict_applicable: false, + }, +}); +export default getHostInstallableDebs; + diff --git a/webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsConstants.js b/webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsConstants.js new file mode 100644 index 00000000000..ac2260950c4 --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsConstants.js @@ -0,0 +1,3 @@ +export const HOST_INSTALLABLE_DEBS_KEY = 'HOST_INSTALLABLE_DEBS'; +export const PACKAGE_SEARCH_QUERY = 'Package search query'; +export default HOST_INSTALLABLE_DEBS_KEY; diff --git a/webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsSelectors.js b/webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsSelectors.js new file mode 100644 index 00000000000..58b46a14f30 --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/DebsTab/InstallableDebsSelectors.js @@ -0,0 +1,16 @@ +import { + selectAPIStatus, + selectAPIError, + selectAPIResponse, +} from 'foremanReact/redux/API/APISelectors'; +import { STATUS } from 'foremanReact/constants'; +import { HOST_INSTALLABLE_DEBS_KEY } from './InstallableDebsConstants'; + +export const selectHostInstallableDebs = state => + selectAPIResponse(state, HOST_INSTALLABLE_DEBS_KEY) || {}; + +export const selectHostInstallableDebsStatus = state => + selectAPIStatus(state, HOST_INSTALLABLE_DEBS_KEY) || STATUS.PENDING; + +export const selectHostInstallableDebsError = state => + selectAPIError(state, HOST_INSTALLABLE_DEBS_KEY); diff --git a/webpack/components/extensions/HostDetails/Tabs/PackagesTab/PackagesTab.js b/webpack/components/extensions/HostDetails/Tabs/PackagesTab/PackagesTab.js index 99fa8c9776b..34b22d36108 100644 --- a/webpack/components/extensions/HostDetails/Tabs/PackagesTab/PackagesTab.js +++ b/webpack/components/extensions/HostDetails/Tabs/PackagesTab/PackagesTab.js @@ -47,6 +47,13 @@ import { useRexJobPolling } from '../RemoteExecutionHooks'; const invokeRexJobs = ['create_job_invocations']; const createBookmarks = ['create_bookmarks']; +export const hidePackagesTab = ({ hostDetails }) => { + const osMatch = hostDetails?.operatingsystem_name?.match(/(\D+) (\d+|\D+)/); + if (!osMatch) return false; + const [, os,] = osMatch; + return !(osMatch && os.match(/RedHat|RHEL|CentOS|Rocky|AlmaLinux|Oracle Linux|Suse Linux Enterprise Server/i)); +}; + const UpdateVersionsSelect = ({ packageName, rowIndex, diff --git a/webpack/components/extensions/HostDetails/Tabs/__tests__/debs.fixtures.json b/webpack/components/extensions/HostDetails/Tabs/__tests__/debs.fixtures.json new file mode 100644 index 00000000000..d182ca66f06 --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/__tests__/debs.fixtures.json @@ -0,0 +1,28 @@ +{ + "total": 3, + "subtotal": 3, + "page": 1, + "per_page": 20, + "error": null, + "search": null, + "results": [ + { + "id": 1682, + "name": "libmagic1", + "version": "1:5.41-3", + "upgradable_versions": ["1:5.41-3ubuntu0.1"] + }, + { + "id": 1562, + "name": "libapt-pkg6.0", + "version": "2.4.9", + "upgradable_versions": ["2.4.10"] + }, + { + "id": 676, + "name": "libacl1", + "version": "2.3.1-1", + "upgradable_versions": null + } + ] +} diff --git a/webpack/components/extensions/HostDetails/Tabs/__tests__/debsTab.test.js b/webpack/components/extensions/HostDetails/Tabs/__tests__/debsTab.test.js new file mode 100644 index 00000000000..fe3d143cc41 --- /dev/null +++ b/webpack/components/extensions/HostDetails/Tabs/__tests__/debsTab.test.js @@ -0,0 +1,419 @@ +import React from 'react'; +import { renderWithRedux, patientlyWaitFor, fireEvent } from 'react-testing-lib-wrapper'; +import { nockInstance, assertNockRequest, mockForemanAutocomplete } from '../../../../../test-utils/nockWrapper'; +import { foremanApi } from '../../../../../services/api'; +import { HOST_PACKAGES_KEY, PACKAGES_SEARCH_QUERY, SELECTED_UPDATE_VERSIONS } from '../DebsTab/HostDebsConstants'; +import { DebsTab } from '../DebsTab/DebsTab.js'; +import mockDebsData from './debs.fixtures.json'; +import { REX_FEATURES } from '../RemoteExecutionConstants'; +import * as hooks from '../../../../Table/TableHooks'; + +jest.mock('../../hostDetailsHelpers', () => ({ + ...jest.requireActual('../../hostDetailsHelpers'), + userPermissionsFromHostDetails: () => ({ + create_job_invocations: true, + edit_hosts: true, + }), +})); + +const contentFacetAttributes = { + id: 11, + uuid: 'e5761ea3-4117-4ecf-83d0-b694f99b389e', + content_view_default: false, + lifecycle_environment_library: false, +}; + +const hostname = 'test-host.example.com'; +const renderOptions = (facetAttributes = contentFacetAttributes) => ({ + apiNamespace: HOST_PACKAGES_KEY, + initialState: { + API: { + HOST_DETAILS: { + response: { + id: 1, + name: hostname, + content_facet_attributes: { ...facetAttributes }, + }, + status: 'RESOLVED', + }, + }, + }, +}); + +const hostDebs = foremanApi.getApiUrl('/hosts/1/debs'); +const jobInvocations = foremanApi.getApiUrl('/job_invocations'); +const autocompleteUrl = '/hosts/1/debs/auto_complete_search'; +const baseQuery = { + include_latest_upgradable: true, + per_page: 20, + page: 1, +}; + +const defaultQueryWithoutSearch = { + ...baseQuery, + sort_by: 'version', + sort_order: 'asc', +}; +const defaultQuery = { ...defaultQueryWithoutSearch, search: '' }; + +let firstDeb; +let secondDeb; + +beforeEach(() => { + const { results } = mockDebsData; + [firstDeb, secondDeb] = results; +}); + +test('Can call API for packages and show on screen on page load', async (done) => { + // Setup autocomplete with mockForemanAutoComplete since we aren't adding /katello + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + const scope = nockInstance + .get(hostDebs) + .query(defaultQuery) + .reply(200, mockDebsData); + + const { getAllByText } = renderWithRedux(, renderOptions()); + + // Assert that the packages are now showing on the screen, but wait for them to appear. + await patientlyWaitFor(() => expect(getAllByText(firstDebe.name)[0]).toBeInTheDocument()); + // Assert request was made and completed, see helper function + assertNockRequest(autocompleteScope); + assertNockRequest(scope, done); // Pass jest callback to confirm test is done +}); + +test('Can handle no packages being present', async (done) => { + // Setup autocomplete with mockForemanAutoComplete since we aren't adding /katello + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + + const noResults = { + total: 0, + subtotal: 0, + page: 1, + per_page: 20, + results: [], + }; + + const scope = nockInstance + .get(hostDebs) + .query(defaultQuery) + .reply(200, noResults); + + const { queryByText } = renderWithRedux(, renderOptions()); + + // Assert that there are not any packages showing on the screen. + await patientlyWaitFor(() => expect(queryByText('This host does not have any packages.')).toBeInTheDocument()); + // Assert request was made and completed, see helper function + assertNockRequest(autocompleteScope); + assertNockRequest(scope, done); // Pass jest callback to confirm test is done +}); + +test('Can filter by package status', async (done) => { + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + const scope = nockInstance + .get(hostDebs) + .query(defaultQuery) + .reply(200, mockDebsData); + + const scope2 = nockInstance + .get(hostDebs) + .query({ ...defaultQuery, status: 'upgradable' }) + .reply(200, { ...mockDebsData, results: [firstDeb] }); + + const { + queryByText, + getByRole, + getAllByText, + getByText, + } = renderWithRedux(, renderOptions()); + + await patientlyWaitFor(() => expect(getAllByText(firstDeb.name)[0]).toBeInTheDocument()); + // the Upgradable text in the table is just a text node, while the dropdown is a button + expect(getByText('Up-to date', { ignore: ['button', 'title'] })).toBeInTheDocument(); + expect(getByText('coreutils', { ignore: ['button', 'title'] })).toBeInTheDocument(); + expect(getByText('acl', { ignore: ['button', 'title'] })).toBeInTheDocument(); + + const statusDropdown = queryByText('Status', { ignore: 'th' }); + expect(statusDropdown).toBeInTheDocument(); + fireEvent.click(statusDropdown); + const upgradable = getByRole('option', { name: 'select Upgradable' }); + fireEvent.click(upgradable); + await patientlyWaitFor(() => { + expect(queryByText('coreutils')).toBeInTheDocument(); + expect(queryByText('acl')).not.toBeInTheDocument(); + }); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope); + assertNockRequest(scope2, done); // Pass jest callback to confirm test is done +}); + +test('Can upgrade a package via remote execution', async (done) => { + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + + const scope = nockInstance + .get(hostDebs) + .query(defaultQuery) + .reply(200, mockDebsData); + + const statusScope = nockInstance + .get(hostDebs) + .query({ ...defaultQuery, status: 'upgradable' }) + .reply(200, { ...mockDebsData, results: [firstDeb] }); + + const upgradeScope = nockInstance + .post(jobInvocations, { + job_invocation: { + inputs: { + package: firstDeb.name, + }, + search_query: `name ^ (${hostname})`, + feature: REX_FEATURES.KATELLO_PACKAGE_UPDATE, + }, + }) + .reply(201); + + const { + getByRole, + getAllByText, + getByLabelText, + getByText, + } = renderWithRedux(, renderOptions()); + + await patientlyWaitFor(() => expect(getAllByText(firstDeb.name)[0]).toBeInTheDocument()); + + const statusDropdown = getByText('Status', { ignore: 'th' }); + expect(statusDropdown).toBeInTheDocument(); + fireEvent.click(statusDropdown); + const upgradable = getByRole('option', { name: 'select Upgradable' }); + fireEvent.click(upgradable); + await patientlyWaitFor(() => { + expect(getByText('coreutils')).toBeInTheDocument(); + }); + + const kebabDropdown = getByLabelText('Actions'); + kebabDropdown.click(); + + const rexAction = getByText('Upgrade via remote execution'); + await patientlyWaitFor(() => expect(rexAction).toBeInTheDocument()); + fireEvent.click(rexAction); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope); + assertNockRequest(statusScope); + assertNockRequest(upgradeScope, done); +}); + +test('Can upgrade a package via customized remote execution', async (done) => { + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + + const scope = nockInstance + .get(hostDebs) + .query(defaultQuery) + .reply(200, mockDebsData); + + const statusScope = nockInstance + .get(hostDebs) + .query({ ...defaultQuery, status: 'upgradable' }) + .reply(200, { ...mockDebsData, results: [firstDeb] }); + + const { + getByRole, + getAllByText, + getByLabelText, + getByText, + } = renderWithRedux(, renderOptions()); + + await patientlyWaitFor(() => expect(getAllByText(firstDeb.name)[0]).toBeInTheDocument()); + + const statusDropdown = getByText('Status', { ignore: 'th' }); + expect(statusDropdown).toBeInTheDocument(); + fireEvent.click(statusDropdown); + const upgradable = getByRole('option', { name: 'select Upgradable' }); + fireEvent.click(upgradable); + await patientlyWaitFor(() => { + expect(getByText('coreutils')).toBeInTheDocument(); + }); + + const kebabDropdown = getByLabelText('Actions'); + kebabDropdown.click(); + + const rexAction = getByText('Upgrade via customized remote execution'); + const feature = REX_FEATURES.KATELLO_PACKAGE_UPDATE; + const packageName = firstDeb.upgradable_versions[0]; + + expect(rexAction).toBeInTheDocument(); + expect(rexAction).toHaveAttribute( + 'href', + `/job_invocations/new?feature=${feature}&search=name%20%5E%20(${hostname})&inputs%5Bpackage%5D=${packageName}`, + ); + + fireEvent.click(rexAction); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope); + assertNockRequest(statusScope, done); +}); + +test('Can bulk upgrade via remote execution', async (done) => { + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + + const scope = nockInstance + .get(hostDebs) + .query(defaultQuery) + .reply(200, mockDebsData); + + const upgradeScope = nockInstance + .post(jobInvocations, { + job_invocation: { + inputs: { + [PACKAGES_SEARCH_QUERY]: `id ^ (${firstDeb.id},${secondDeb.id})`, + [SELECTED_UPDATE_VERSIONS]: JSON.stringify([]), + }, + search_query: `name ^ (${hostname})`, + feature: REX_FEATURES.KATELLO_PACKAGES_UPDATE_BY_SEARCH, + }, + }) + .reply(201); + + const { + getAllByRole, + getAllByText, + getByRole, + getByLabelText, + } = renderWithRedux(, renderOptions()); + + await patientlyWaitFor(() => expect(getAllByText(firstDeb.name)[0]).toBeInTheDocument()); + + getByRole('checkbox', { name: 'Select row 0' }).click(); + expect(getByLabelText('Select row 0').checked).toEqual(true); + getByRole('checkbox', { name: 'Select row 1' }).click(); + expect(getByLabelText('Select row 1').checked).toEqual(true); + + const upgradeDropdown = getAllByRole('button', { name: 'Select' })[1]; + fireEvent.click(upgradeDropdown); + + const rexAction = getByLabelText('bulk_upgrade_rex'); + expect(rexAction).toBeInTheDocument(); + fireEvent.click(rexAction); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope); + assertNockRequest(upgradeScope, done); +}); + +test('Can bulk upgrade via customized remote execution', async (done) => { + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + + const scope = nockInstance + .get(hostDebs) + .query(defaultQuery) + .reply(200, mockDebsData); + + const { + getAllByRole, + getAllByText, + getByRole, + getByLabelText, + } = renderWithRedux(, renderOptions()); + + await patientlyWaitFor(() => expect(getAllByText(firstDeb.name)[0]).toBeInTheDocument()); + + const feature = REX_FEATURES.KATELLO_PACKAGES_UPDATE_BY_SEARCH; + const packages = `${firstDeb.id},${secondDeb.id}`; + const job = + `/job_invocations/new?feature=${feature}&search=name%20%5E%20(${hostname})&inputs%5BDebs%20search%20query%5D=id%20%5E%20(${packages})&inputs%5BSelected%20update%20versions%5D=%5B%5D`; + + getByRole('checkbox', { name: 'Select row 0' }).click(); + expect(getByLabelText('Select row 0').checked).toEqual(true); + getByRole('checkbox', { name: 'Select row 1' }).click(); + expect(getByLabelText('Select row 1').checked).toEqual(true); + + const upgradeDropdown = getAllByRole('button', { name: 'Select' })[1]; + fireEvent.click(upgradeDropdown); + expect(upgradeDropdown).not.toHaveAttribute('disabled'); + + const rexAction = getByLabelText('bulk_upgrade_customized_rex'); + expect(rexAction).toBeInTheDocument(); + expect(rexAction).toHaveAttribute('href', job); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope, done); +}); + +test('Upgrade is disabled when there are non-upgradable packages selected', async (done) => { + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + + const scope = nockInstance + .get(hostDebs) + .query(defaultQuery) + .reply(200, mockDebsData); + + const { + getAllByRole, + getAllByText, + getByLabelText, + getByRole, + } = renderWithRedux(, renderOptions()); + + await patientlyWaitFor(() => expect(getAllByText(firstDeb.name)[0]).toBeInTheDocument()); + + // select an upgradable package + getByRole('checkbox', { name: 'Select row 0' }).click(); + // select an up-to-date package + getByRole('checkbox', { name: 'Select row 2' }).click(); + expect(getByLabelText('Select row 2').checked).toEqual(true); + + const upgradeDropdown = getAllByRole('button', { name: 'Select' })[1]; + expect(upgradeDropdown).toHaveAttribute('disabled'); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope, done); +}); + +test('Remove is disabled when in select all mode', async (done) => { + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + const scope = nockInstance + .get(hostDebs) + .query(defaultQuery) + .reply(200, mockDebsData); + + const { + getAllByText, getByRole, + } = renderWithRedux(, renderOptions()); + + await patientlyWaitFor(() => expect(getAllByText(firstDeb.name)[0]).toBeInTheDocument()); + + // find and click the select all checkbox + const selectAllCheckbox = getByRole('checkbox', { name: 'Select all' }); + fireEvent.click(selectAllCheckbox); + getByRole('button', { name: 'bulk_actions' }).click(); + + const removeButton = getByRole('menuitem', { name: 'bulk_remove' }); + await patientlyWaitFor(() => expect(removeButton).toBeInTheDocument()); + expect(removeButton).toHaveAttribute('aria-disabled', 'true'); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope, done); +}); + +test('Sets initial search query from url params', async (done) => { + // Setup autocomplete with mockForemanAutoComplete since we aren't adding /katello + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + const scope = nockInstance + .get(hostDebs) + .query({ ...defaultQuery, search: `name=${firstDeb.name}` }) + .reply(200, { ...mockDebsData, results: [firstDeb] }); + + jest.spyOn(hooks, 'useUrlParams').mockImplementation(() => ({ + searchParam: `name=${firstDeb.name}`, + })); + + const { getAllByText, queryByText } = renderWithRedux(, renderOptions()); + + await patientlyWaitFor(() => expect(getAllByText(firstDeb.name)[0]).toBeInTheDocument()); + expect(queryByText(secondDeb.name)).not.toBeInTheDocument(); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope, done); // Pass jest callback to confirm test is done +}); +