diff --git a/app/controllers/katello/api/v2/host_packages_controller.rb b/app/controllers/katello/api/v2/host_packages_controller.rb index 8f9b60faff2..c779e655080 100644 --- a/app/controllers/katello/api/v2/host_packages_controller.rb +++ b/app/controllers/katello/api/v2/host_packages_controller.rb @@ -44,7 +44,18 @@ def installed_packages add_scoped_search_description_for(Katello::InstalledPackage) def index validate_index_params! - collection = scoped_search(index_relation, :name, :asc, :resource_class => ::Katello::InstalledPackage) + options = { :resource_class => ::Katello::InstalledPackage } + + # Handle persistence sorting (normal scoped_search cannot handle this join for multiple hosts) + if params[:sort_by] == 'persistence' + options[:custom_sort] = lambda do |query| + query.joins(:host_installed_packages) + .where(katello_host_installed_packages: {host_id: @host.id}) + .order("katello_host_installed_packages.persistence #{sanitize_sort_order(params[:sort_order])}") + end + end + + collection = scoped_search(index_relation, :name, :asc, options) include_upgradable = ::Foreman::Cast.to_bool(params[:include_latest_upgradable]) # Present packages with persistence and (if requested) latest upgradable info @@ -145,5 +156,13 @@ def validate_index_params! fail _("Status must be one of: %s" % VERSION_STATUSES.join(', ')) end end + + def sanitize_sort_order(sort_order) + if sort_order.present? && ['asc', 'desc'].include?(sort_order.downcase) + sort_order.downcase + else + 'asc' + end + end end end diff --git a/app/models/katello/concerns/host_managed_extensions.rb b/app/models/katello/concerns/host_managed_extensions.rb index f24d315b1ab..ef8b20f0f8f 100644 --- a/app/models/katello/concerns/host_managed_extensions.rb +++ b/app/models/katello/concerns/host_managed_extensions.rb @@ -610,6 +610,10 @@ def yum_or_yum_transient content_facet&.yum_or_yum_transient || "yum" end + def contains_package_with_reported_persistence? + host_installed_packages.where.not(persistence: nil).exists? + end + protected def update_trace_status diff --git a/app/views/katello/api/v2/hosts/base.json.rabl b/app/views/katello/api/v2/hosts/base.json.rabl index 28108a81dc6..f02111fad47 100644 --- a/app/views/katello/api/v2/hosts/base.json.rabl +++ b/app/views/katello/api/v2/hosts/base.json.rabl @@ -26,4 +26,7 @@ if @facet } end end + node :contains_package_with_reported_persistence do + @resource.contains_package_with_reported_persistence? + end end diff --git a/test/controllers/api/v2/host_packages_controller_test.rb b/test/controllers/api/v2/host_packages_controller_test.rb index 4c6efe14e84..9ac96544a51 100644 --- a/test/controllers/api/v2/host_packages_controller_test.rb +++ b/test/controllers/api/v2/host_packages_controller_test.rb @@ -67,6 +67,22 @@ def test_index_includes_persistence assert_equal 'transient', package['persistence'] end + def test_index_sort_by_persistence + pkg1 = @host.installed_packages.first + pkg2 = @host.installed_packages.second + Katello::HostInstalledPackage.where(host: @host, installed_package: pkg1).update_all(persistence: 'transient') + Katello::HostInstalledPackage.where(host: @host, installed_package: pkg2).update_all(persistence: 'persistent') + + get :index, params: { :host_id => @host.id, :sort_by => 'persistence', :sort_order => 'asc' } + + assert_response :success + response_data = JSON.parse(response.body) + results = response_data['results'] + + assert_equal 'persistent', results.first['persistence'] + assert_equal 'transient', results.last['persistence'] + end + def test_view_permissions ::Host.any_instance.stubs(:check_host_registration).returns(true) diff --git a/webpack/components/extensions/HostDetails/Tabs/PackagesTab/PackagesTab.js b/webpack/components/extensions/HostDetails/Tabs/PackagesTab/PackagesTab.js index a1cd2ff273a..2b2b28b9368 100644 --- a/webpack/components/extensions/HostDetails/Tabs/PackagesTab/PackagesTab.js +++ b/webpack/components/extensions/HostDetails/Tabs/PackagesTab/PackagesTab.js @@ -1,5 +1,5 @@ import React, { useCallback, useState, useRef } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; import { ActionList, ActionListItem, @@ -22,7 +22,6 @@ import { FormattedMessage } from 'react-intl'; import { TableVariant, Thead, Tbody, Tr, Th, Td, TableText, ActionsColumn } from '@patternfly/react-table'; import PropTypes from 'prop-types'; import { translate as __ } from 'foremanReact/common/I18n'; -import { HOST_DETAILS_KEY } from 'foremanReact/components/HostDetails/consts'; import { selectAPIResponse } from 'foremanReact/redux/API/APISelectors'; import { useSet, useBulkSelect, useUrlParams } from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks'; import { useTableSort } from 'foremanReact/components/PF4/Helpers/useTableSort'; @@ -123,6 +122,11 @@ UpdateVersionsSelect.defaultProps = { upgradableVersionSelectOpen: null, }; +const formatPersistence = (persistence) => { + if (!persistence) return '—'; + return persistence.charAt(0).toUpperCase() + persistence.slice(1); +}; + export const PackagesTab = () => { const hostDetails = useSelector(state => selectAPIResponse(state, 'HOST_DETAILS')); const { @@ -174,17 +178,32 @@ export const PackagesTab = () => { const emptySearchTitle = __('No matching packages found'); const emptySearchBody = __('Try changing your search settings.'); const errorSearchTitle = __('Problem searching packages'); - const columnHeaders = [ + + const isBootCHost = hostIsImageMode({ hostDetails }); + const columnHeaders = isBootCHost ? [ __('Package'), + __('Persistence'), __('Status'), __('Installed version'), __('Upgradable to'), - ]; - - const COLUMNS_TO_SORT_PARAMS = { - [columnHeaders[0]]: 'nvra', - [columnHeaders[2]]: 'version', - }; + ] : + [ + __('Package'), + __('Status'), + __('Installed version'), + __('Upgradable to'), + ]; + + const COLUMNS_TO_SORT_PARAMS = isBootCHost ? + { + [columnHeaders[0]]: 'nvra', + [columnHeaders[1]]: 'persistence', + [columnHeaders[3]]: 'version', + } : + { + [columnHeaders[0]]: 'nvra', + [columnHeaders[2]]: 'version', + }; const { pfSortParams, apiSortParams, @@ -211,7 +230,6 @@ export const PackagesTab = () => { const { results, ...metadata } = response; const { error: errorSearchBody } = metadata; const status = useSelector(state => selectHostPackagesStatus(state)); - const dispatch = useDispatch(); const { selectOne, isSelected, @@ -286,17 +304,9 @@ export const PackagesTab = () => { isPolling: isInstallInProgress, } = useRexJobPolling(packageInstallAction, getHostDetails({ hostname })); - const refreshHostDetails = () => dispatch({ - type: 'API_GET', - payload: { - key: HOST_DETAILS_KEY, - url: `/api/hosts/${hostname}`, - }, - }); - const { triggerJobStart: triggerRecalculate, lastCompletedJob: lastCompletedRecalculate, - } = useRexJobPolling(() => runSubmanRepos(hostname, refreshHostDetails)); + } = useRexJobPolling(() => runSubmanRepos(hostname), getHostDetails({ hostname })); const handleRefreshApplicabilityClick = () => { setIsBulkActionOpen(false); @@ -486,7 +496,7 @@ export const PackagesTab = () => { return (
- {hostIsImageMode({ hostDetails }) && } + {isBootCHost && } { : packageName } + {isBootCHost && ( + {formatPersistence(pkg.persistence)} + )} {installedVersion.replace(`${packageName}-`, '')} diff --git a/webpack/components/extensions/HostDetails/Tabs/__tests__/packages.fixtures.json b/webpack/components/extensions/HostDetails/Tabs/__tests__/packages.fixtures.json index 93a97d8a5a1..0980ab0c000 100644 --- a/webpack/components/extensions/HostDetails/Tabs/__tests__/packages.fixtures.json +++ b/webpack/components/extensions/HostDetails/Tabs/__tests__/packages.fixtures.json @@ -10,19 +10,22 @@ "id": 738, "name": "coreutils", "nvra": "coreutils-8.30-6.el8.x86_64", - "upgradable_versions": ["coreutils-9.0-1.el8.x86_64"] + "upgradable_versions": ["coreutils-9.0-1.el8.x86_64"], + "persistence": "persistent" }, { "id": 646, "name": "chrony", "nvra": "chrony-3.3-3.el8.x86_64", - "upgradable_versions": ["chrony-4.0-1.el8.x86_64"] + "upgradable_versions": ["chrony-4.0-1.el8.x86_64"], + "persistence": "transient" }, { "id": 676, "name": "acl", "nvra": "acl-2.2.53-1.el8.x86_64", - "upgradable_versions": null + "upgradable_versions": null, + "persistence": null } ] } diff --git a/webpack/components/extensions/HostDetails/Tabs/__tests__/packagesTab.test.js b/webpack/components/extensions/HostDetails/Tabs/__tests__/packagesTab.test.js index a023adfd420..6b8eed10738 100644 --- a/webpack/components/extensions/HostDetails/Tabs/__tests__/packagesTab.test.js +++ b/webpack/components/extensions/HostDetails/Tabs/__tests__/packagesTab.test.js @@ -510,7 +510,7 @@ test('Sets initial search query from url params', async (done) => { .query({ ...defaultQuery, search: `name=${firstPackage.name}` }) .reply(200, { ...mockPackagesData, results: [firstPackage] }); - jest.spyOn(hooks, 'useUrlParams').mockImplementation(() => ({ + const urlParamsSpy = jest.spyOn(hooks, 'useUrlParams').mockImplementation(() => ({ searchParam: `name=${firstPackage.name}`, })); @@ -521,6 +521,171 @@ test('Sets initial search query from url params', async (done) => { assertNockRequest(autocompleteScope); assertNockRequest(scope); + urlParamsSpy.mockRestore(); act(done); // Pass jest callback to confirm test is done }); +test('Shows persistence column for bootc hosts', async (done) => { + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + const bootcFacetAttributes = { + ...contentFacetAttributes, + bootc_booted_image: 'quay.io/someimage:latest', + }; + + const bootcRenderOptions = { + apiNamespace: HOST_PACKAGES_KEY, + initialState: { + API: { + HOST_DETAILS: { + response: { + id: 1, + name: hostname, + content_facet_attributes: { ...bootcFacetAttributes }, + display_name: hostname, + }, + status: 'RESOLVED', + }, + }, + }, + }; + + const scope = nockInstance + .get(hostPackages) + .query(defaultQuery) + .reply(200, mockPackagesData); + + const { getAllByText, getByText } = renderWithRedux( + , + bootcRenderOptions, + ); + + await patientlyWaitFor(() => expect(getAllByText(firstPackage.name)[0]).toBeInTheDocument()); + expect(getByText('Persistence')).toBeInTheDocument(); + expect(getByText('Persistent')).toBeInTheDocument(); + expect(getByText('Transient')).toBeInTheDocument(); + expect(getAllByText('—').length).toBeGreaterThan(0); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope); + act(done); +}); + +test('Does not show persistence column for non-bootc hosts', async (done) => { + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + const scope = nockInstance + .get(hostPackages) + .query(defaultQuery) + .reply(200, mockPackagesData); + + const { getAllByText, queryByText } = renderWithRedux(, renderOptions()); + + await patientlyWaitFor(() => expect(getAllByText(firstPackage.name)[0]).toBeInTheDocument()); + expect(queryByText('Persistence')).not.toBeInTheDocument(); + expect(queryByText('Persistent')).not.toBeInTheDocument(); + expect(queryByText('Transient')).not.toBeInTheDocument(); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope); + act(done); +}); + +test('Can sort by persistence column', async (done) => { + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + const bootcFacetAttributes = { + ...contentFacetAttributes, + bootc_booted_image: 'quay.io/someimage:latest', + }; + + const bootcRenderOptions = { + apiNamespace: HOST_PACKAGES_KEY, + initialState: { + API: { + HOST_DETAILS: { + response: { + id: 1, + name: hostname, + content_facet_attributes: { ...bootcFacetAttributes }, + display_name: hostname, + }, + status: 'RESOLVED', + }, + }, + }, + }; + + const scope = nockInstance + .get(hostPackages) + .query(defaultQuery) + .reply(200, mockPackagesData); + + const sortedQuery = { + ...defaultQueryWithoutSearch, + sort_by: 'persistence', + sort_order: 'asc', + }; + + const scope2 = nockInstance + .get(hostPackages) + .query(sortedQuery) + .reply(200, mockPackagesData); + + const { getAllByText, getByText } = renderWithRedux( + , + bootcRenderOptions, + ); + + await patientlyWaitFor(() => expect(getAllByText(firstPackage.name)[0]).toBeInTheDocument()); + + const persistenceHeader = getByText('Persistence'); + fireEvent.click(persistenceHeader); + + await patientlyWaitFor(() => { + assertNockRequest(scope2); + }); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope); + act(done); +}); + +test('Can trigger refresh package applicability', async (done) => { + const autocompleteScope = mockForemanAutocomplete(nockInstance, autocompleteUrl); + + const scope = nockInstance + .get(hostPackages) + .query(defaultQuery) + .reply(200, mockPackagesData); + + const refreshApplicabilityScope = nockInstance + .post(jobInvocations, { + job_invocation: { + inputs: {}, + search_query: `name ^ (${hostname})`, + feature: REX_FEATURES.KATELLO_UPLOAD_PROFILE, + }, + }) + .reply(201, { id: 123, description: 'Upload package profile' }); + + const { + getByText, + getAllByText, + getByRole, + } = renderWithRedux(, renderOptions()); + + await patientlyWaitFor(() => expect(getAllByText(firstPackage.name)[0]).toBeInTheDocument()); + + const kebabDropdown = getByRole('button', { name: 'bulk_actions' }); + await patientlyWaitFor(() => expect(kebabDropdown).toBeInTheDocument()); + fireEvent.click(kebabDropdown); + + const refreshAction = getByText('Refresh package applicability'); + await patientlyWaitFor(() => expect(refreshAction).toBeInTheDocument()); + await act(async () => { + fireEvent.click(refreshAction); + }); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope); + assertNockRequest(refreshApplicabilityScope, done); +}); +