Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion app/controllers/katello/api/v2/host_packages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions app/models/katello/concerns/host_managed_extensions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude assures me that .exists? is fast enough and a better solution than storing a new column which pre-computes whether an installed package has persistence. What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From reading online exists? seems to use a limit of 1 in the generated SQL, so it should be performant.

end

protected

def update_trace_status
Expand Down
3 changes: 3 additions & 0 deletions app/views/katello/api/v2/hosts/base.json.rabl
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,7 @@ if @facet
}
end
end
node :contains_package_with_reported_persistence do
@resource.contains_package_with_reported_persistence?
end
end
16 changes: 16 additions & 0 deletions test/controllers/api/v2/host_packages_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -486,7 +496,7 @@ export const PackagesTab = () => {
return (
<div>
<div id="packages-tab">
{hostIsImageMode({ hostDetails }) && <ImageModeHostAlert />}
{isBootCHost && <ImageModeHostAlert />}
<TableWrapper
{...{
metadata,
Expand Down Expand Up @@ -599,6 +609,9 @@ export const PackagesTab = () => {
: packageName
}
</Td>
{isBootCHost && (
<Td>{formatPersistence(pkg.persistence)}</Td>
)}
<Td><PackagesStatus {...pkg} /></Td>
<Td>{installedVersion.replace(`${packageName}-`, '')}</Td>
<Td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
}));

Expand All @@ -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(
<PackagesTab />,
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(<PackagesTab />, 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(
<PackagesTab />,
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(<PackagesTab />, 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);
});