diff --git a/app/models/katello/concerns/host_managed_extensions.rb b/app/models/katello/concerns/host_managed_extensions.rb
index 8acf09c3c3f..94feb847f37 100644
--- a/app/models/katello/concerns/host_managed_extensions.rb
+++ b/app/models/katello/concerns/host_managed_extensions.rb
@@ -570,7 +570,11 @@ def deb_names_for_job_template(action:, search:)
end
def advisory_ids(search:)
- ::Katello::Erratum.installable_for_hosts([self]).search_for(search).pluck(:errata_id)
+ ids = ::Katello::Erratum.installable_for_hosts([self]).search_for(search).pluck(:errata_id)
+ if ids.empty?
+ fail _("Cannot install errata: No installable errata found for search term '%s'") % search
+ end
+ ids
end
def filtered_entitlement_quantity_consumed(pool)
diff --git a/webpack/components/extensions/HostDetails/Tabs/RemoteExecutionActions.js b/webpack/components/extensions/HostDetails/Tabs/RemoteExecutionActions.js
index 7a14eca4360..399b7e5fc35 100644
--- a/webpack/components/extensions/HostDetails/Tabs/RemoteExecutionActions.js
+++ b/webpack/components/extensions/HostDetails/Tabs/RemoteExecutionActions.js
@@ -98,11 +98,13 @@ const katelloTracerResolveParams = ({ hostname, search }) =>
});
const katelloHostErrataInstallParams = ({
- hostname, search,
+ hostname, hostSearch, search, descriptionFormat,
}) => baseParams({
hostname,
+ hostSearch,
inputs: { [ERRATA_SEARCH_QUERY]: search },
feature: REX_FEATURES.KATELLO_HOST_ERRATA_INSTALL_BY_SEARCH,
+ descriptionFormat,
});
const katelloModuleStreamActionsParams = ({ hostname, action, moduleSpec }) =>
@@ -218,13 +220,13 @@ export const resolveTraces = ({ hostname, search }) => post({
});
export const installErrata = ({
- hostname, search,
+ hostname, hostSearch, search, descriptionFormat,
}) => post({
type: API_OPERATIONS.POST,
key: REX_JOB_INVOCATIONS_KEY,
url: foremanApi.getApiUrl('/job_invocations'),
params: katelloHostErrataInstallParams({
- hostname, search,
+ hostname, search, hostSearch, descriptionFormat,
}),
handleSuccess: showRexToast,
errorToast,
diff --git a/webpack/components/extensions/HostDetails/Tabs/customizedRexUrlHelpers.js b/webpack/components/extensions/HostDetails/Tabs/customizedRexUrlHelpers.js
index ac274271ac7..910de12df8e 100644
--- a/webpack/components/extensions/HostDetails/Tabs/customizedRexUrlHelpers.js
+++ b/webpack/components/extensions/HostDetails/Tabs/customizedRexUrlHelpers.js
@@ -54,8 +54,9 @@ export const resolveTraceUrl = ({ hostname, search }) => createJob({
inputs: { [TRACES_SEARCH_QUERY]: search },
});
-export const errataInstallUrl = ({ hostname, search }) => createJob({
+export const errataInstallUrl = ({ hostname, search, hostSearch }) => createJob({
hostname,
+ hostSearch,
feature: REX_FEATURES.KATELLO_HOST_ERRATA_INSTALL_BY_SEARCH,
inputs: { [ERRATA_SEARCH_QUERY]: search },
});
diff --git a/webpack/components/extensions/Hosts/ActionsBar/index.js b/webpack/components/extensions/Hosts/ActionsBar/index.js
index e2288f83d5a..c71ca10c6c9 100644
--- a/webpack/components/extensions/Hosts/ActionsBar/index.js
+++ b/webpack/components/extensions/Hosts/ActionsBar/index.js
@@ -20,12 +20,14 @@ const HostActionsBar = () => {
[
'bulk-change-cv-modal',
'bulk-packages-wizard',
+ 'bulk-errata-wizard',
].forEach((id) => {
dispatch(addModal({ id }));
});
}, [dispatch]);
const { setModalOpen: openBulkChangeCVModal } = useForemanModal({ id: 'bulk-change-cv-modal' });
const { setModalOpen: openBulkPackagesWizardModal } = useForemanModal({ id: 'bulk-packages-wizard' });
+ const { setModalOpen: openBulkErrataWizardModal } = useForemanModal({ id: 'bulk-errata-wizard' });
const orgId = useForemanOrganization()?.id;
@@ -67,6 +69,15 @@ const HostActionsBar = () => {
>
{__('Manage packages')}
+
+ {__('Manage errata')}
+
+
>
);
};
diff --git a/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/02_BulkErrataTable.js b/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/02_BulkErrataTable.js
new file mode 100644
index 00000000000..f726c4260b0
--- /dev/null
+++ b/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/02_BulkErrataTable.js
@@ -0,0 +1,171 @@
+import React, { useEffect, useContext } from 'react';
+import {
+ Alert,
+ ToolbarItem,
+ Text,
+ TextContent,
+ TextVariants,
+} from '@patternfly/react-core';
+import { TableText } from '@patternfly/react-table';
+import TableIndexPage from 'foremanReact/components/PF4/TableIndexPage/TableIndexPage';
+import { translate as __ } from 'foremanReact/common/I18n';
+import SelectAllCheckbox from 'foremanReact/components/PF4/TableIndexPage/Table/SelectAllCheckbox';
+import { STATUS, getControllerSearchProps } from 'foremanReact/constants';
+import { RowSelectTd } from 'foremanReact/components/HostsIndex/RowSelectTd';
+import { getPageStats } from 'foremanReact/components/PF4/TableIndexPage/Table/helpers';
+import { BulkErrataWizardContext, ERRATA_URL } from './BulkErrataWizard';
+import { ErrataType, ErrataSeverity } from '../../../../../components/Errata';
+import katelloApi from '../../../../../services/api';
+
+const BulkErrataTable = () => {
+ const {
+ setShouldValidateStep2,
+ errataBulkSelect,
+ errataResults: results,
+ errataMetadata: {
+ total, per_page: perPage, page, subtotal,
+ },
+ errataResponse: response,
+ } = useContext(BulkErrataWizardContext);
+ const apiOptions = { key: 'BULK_HOST_ERRATA' };
+ const {
+ status: errataStatus,
+ } = response;
+
+
+ const origSearchProps = getControllerSearchProps('errata', 'searchBar-errata');
+ const customSearchProps = {
+ ...origSearchProps,
+ autocomplete: {
+ ...origSearchProps.autocomplete,
+ url: katelloApi.getApiUrl('/errata/auto_complete_search'),
+ },
+ };
+
+ const {
+ selectAll,
+ selectPage,
+ selectNone,
+ selectOne,
+ isSelected,
+ selectedCount,
+ areAllRowsSelected,
+ areAllRowsOnPageSelected,
+ updateSearchQuery,
+ hasInteracted,
+ } = errataBulkSelect;
+
+ useEffect(() => {
+ if (results?.length && hasInteracted) {
+ setShouldValidateStep2(true);
+ }
+ }, [setShouldValidateStep2, results, hasInteracted]);
+
+ const pageStats = getPageStats({ total: subtotal, page, perPage });
+ const selectionToolbar = (
+
+
+
+ );
+
+ const columns = {
+ id: {
+ title: __('Erratum'),
+ wrapper: ({ id, errata_id: errataId }) => (
+ {errataId}
+ ),
+ isSorted: true,
+ weight: 10,
+ },
+ title: {
+ title: __('Title'),
+ wrapper: ({ title }) => {title},
+ isSorted: true,
+ weight: 20,
+ },
+ type: {
+ title: __('Type'),
+ wrapper: erratum => ,
+ weight: 30,
+ isSorted: true,
+ },
+ severity: {
+ title: __('Severity'),
+ wrapper: erratum => ,
+ weight: 40,
+ isSorted: true,
+ },
+ affectedHosts: {
+ title: __('Affected hosts'),
+ wrapper: ({ affected_hosts_count: affectedHostsCount }) => affectedHostsCount,
+ weight: 50,
+ },
+ };
+
+ return (
+ <>
+
+
+ {__('Apply errata')}
+
+
+ {__('Select errata to apply on the selected hosts. Some errata may already be applied on some hosts.')}
+
+
+ {selectedCount === 0 && hasInteracted && (
+
+ )}
+ { errataStatus === STATUS.RESOLVED && !results?.length && (
+
+ )}
+
+ >
+ );
+};
+
+
+export default BulkErrataTable;
diff --git a/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/04_Review.js b/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/04_Review.js
new file mode 100644
index 00000000000..985556c0735
--- /dev/null
+++ b/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/04_Review.js
@@ -0,0 +1,160 @@
+import React, { useContext, useState } from 'react';
+import { translate as __ } from 'foremanReact/common/I18n';
+import { FormattedMessage } from 'react-intl';
+import { TreeView, Button, Text, TextContent, TextVariants, Flex, FlexItem, Dropdown, DropdownItem, DropdownToggle } from '@patternfly/react-core';
+import { useWizardContext } from '@patternfly/react-core/next';
+import { CaretDownIcon } from '@patternfly/react-icons';
+import { BulkErrataWizardContext } from './BulkErrataWizard';
+
+export const dropdownOptions = [
+ __('via remote execution'),
+ __('via customized remote execution'),
+];
+
+export const BulkErrataReview = () => {
+ const { goToStepById } = useWizardContext();
+ const {
+ finishButtonText,
+ selectedRexOption,
+ setSelectedRexOption,
+ finishButtonLoading,
+ errataBulkSelect: {
+ selectedResults: selectedErrataResults,
+ selectedCount: currentSelectedErrataCount,
+ areAllRowsSelected: allErrataSelected,
+ searchQuery: errataSearchQuery,
+ },
+ hostsBulkSelect: {
+ selectedCount: currentSelectedHostsCount,
+ },
+ } = useContext(BulkErrataWizardContext);
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const toggleDropdownOpen = () => setIsDropdownOpen(prev => !prev);
+ const handleSelect = () => {
+ setIsDropdownOpen(false);
+ };
+
+ const dropdownItems = dropdownOptions.map(text => (
+ setSelectedRexOption(text)}>{text}
+ ));
+
+ const treeViewTitle = __('Errata to apply');
+ const treeViewData = [
+ {
+ name: treeViewTitle,
+ id: 'errata-treeview-title',
+ customBadgeContent: allErrataSelected() && errataSearchQuery === '' ? 'All' : currentSelectedErrataCount,
+ children: allErrataSelected() ? undefined :
+ selectedErrataResults.map(({ id, name, errata_id: errataId }) => ({
+ name: `${errataId}: ${name}`,
+ id,
+ key: id,
+ })),
+ action: (
+
+ ),
+ actionProps: {
+ 'aria-label': 'Edit errata list',
+ },
+ },
+ ];
+
+ const hostTreeViewData = [
+ {
+ name: __('Hosts'),
+ id: 'errata-host-treeview-title',
+ customBadgeContent: currentSelectedHostsCount,
+ expandedIcon: null,
+ action: (
+
+ ),
+ actionProps: {
+ 'aria-label': 'Edit host selection',
+ },
+ },
+ ];
+
+ return (
+ <>
+
+
+ {__('Review')}
+
+
+ {finishButtonText},
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedRexOption}
+
+ }
+ onSelect={handleSelect}
+ isOpen={isDropdownOpen}
+ dropdownItems={dropdownItems}
+ menuAppendTo="parent"
+ />
+
+
+ >
+ );
+};
+
+export default BulkErrataReview;
diff --git a/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/04_ReviewFooter.js b/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/04_ReviewFooter.js
new file mode 100644
index 00000000000..a70e0c4932b
--- /dev/null
+++ b/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/04_ReviewFooter.js
@@ -0,0 +1,99 @@
+import React, { useContext } from 'react';
+import { translate as __ } from 'foremanReact/common/I18n';
+import { Button } from '@patternfly/react-core';
+import { WizardFooterWrapper, useWizardContext } from '@patternfly/react-core/next';
+import { BulkErrataWizardContext } from './BulkErrataWizard';
+import { dropdownOptions } from './04_Review';
+import { errataInstallUrl } from '../../../HostDetails/Tabs/customizedRexUrlHelpers';
+import { installErrata } from '../../../HostDetails/Tabs/RemoteExecutionActions';
+import { useRexJobPolling } from '../../../HostDetails/Tabs/RemoteExecutionHooks';
+
+export const BulkErrataReviewFooter = () => {
+ const {
+ finishButtonText,
+ finishButtonLoading,
+ setFinishButtonLoading,
+ selectedRexOption,
+ closeModal,
+ errataBulkSelect: {
+ fetchBulkParams: getErrataBulkParams,
+ selectedCount: errataSelectedCount,
+ },
+ hostsBulkSelect: {
+ fetchBulkParams: getHostsBulkParams,
+ selectedCount: hostsSelectedCount,
+ },
+ } = useContext(BulkErrataWizardContext);
+
+ const { goToStepById } = useWizardContext();
+
+ let errataBulkParams = '';
+ let hostsBulkParams = '';
+ if (errataSelectedCount) errataBulkParams = getErrataBulkParams();
+ if (hostsSelectedCount) hostsBulkParams = getHostsBulkParams();
+ // Customized REX
+ const [viaRex] = dropdownOptions;
+ const customizedRexUrl = errataInstallUrl({
+ hostSearch: hostsBulkParams,
+ search: errataBulkParams,
+ });
+
+ const errataBulkInstallAction = () => installErrata({
+ hostSearch: hostsBulkParams,
+ search: errataBulkParams,
+ });
+
+ const {
+ triggerJobStart: triggerBulkErrataInstall,
+ isPolling: isBulkInstallInProgress,
+ } = useRexJobPolling(errataBulkInstallAction);
+
+ const handleFinishButtonClick = () => {
+ setFinishButtonLoading(true);
+ triggerBulkErrataInstall();
+ closeModal();
+ };
+
+ const finishButton = (selectedRexOption === viaRex) ?
+ (
+
+ ) : (
+
+ );
+
+ return (
+
+ {finishButton}
+
+
+
+ );
+};
+
+export default BulkErrataReviewFooter;
diff --git a/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/BulkErrataWizard.js b/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/BulkErrataWizard.js
new file mode 100644
index 00000000000..cb9cbbfc8c3
--- /dev/null
+++ b/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/BulkErrataWizard.js
@@ -0,0 +1,158 @@
+import React, { useState, createContext, useContext } from 'react';
+import { Wizard, WizardHeader, WizardStep } from '@patternfly/react-core/next';
+import { translate as __ } from 'foremanReact/common/I18n';
+import { useForemanModal } from 'foremanReact/components/ForemanModal/ForemanModalHooks';
+import { useBulkSelect } from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks';
+import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/ActionsBar';
+import { useTableIndexAPIResponse } from 'foremanReact/components/PF4/TableIndexPage/Table/TableIndexHooks';
+import { STATUS } from 'foremanReact/constants';
+import { HOSTS_API_PATH } from 'foremanReact/routes/Hosts/constants';
+import HostReview from '../HostReview';
+import { BulkErrataReview, dropdownOptions } from './04_Review';
+import BulkErrataTable from './02_BulkErrataTable';
+import { BulkErrataReviewFooter } from './04_ReviewFooter';
+import katelloApi from '../../../../../services/api';
+
+export const BulkErrataWizardContext = createContext({});
+
+export const useErrataHostsBulkSelect = ({ initialSelectedHosts, modalIsOpen }) => {
+ const defaultParams = { search: initialSelectedHosts };
+ const apiOptions = { key: 'HOST_REVIEW' };
+ const replacementResponse = !modalIsOpen ? { response: {} } : false;
+ const hostsResponse = useTableIndexAPIResponse({
+ replacementResponse, // don't fetch data if modal is closed
+ apiUrl: `${HOSTS_API_PATH}?per_page=7`,
+ apiOptions,
+ defaultParams,
+ });
+
+ const {
+ response: {
+ results: hostsResults,
+ ...hostsMetadata
+ },
+ } = hostsResponse;
+
+ const { total, page, subtotal } = hostsMetadata;
+
+ return {
+ hostsBulkSelect: useBulkSelect({
+ results: hostsResults,
+ metadata: { total, page, selectable: subtotal },
+ initialSearchQuery: initialSelectedHosts,
+ initialSelectAllMode: true,
+ }),
+ hostsResponse,
+ hostsMetadata,
+ };
+};
+
+export const ERRATA_URL = `${katelloApi.getApiUrl('/errata')}?per_page=7&include_permissions=true`;
+
+const BulkErrataWizard = () => {
+ const { modalOpen, setModalClosed: closeModal } = useForemanModal({ id: 'bulk-errata-wizard' });
+ const { selectedCount: initialSelectedHostCount, fetchBulkParams }
+ = useContext(ForemanActionsBarContext);
+
+ const [shouldValidateStep2, setShouldValidateStep2] = useState(false);
+ const [shouldValidateStep3, setShouldValidateStep3] = useState(false);
+ const [finishButtonLoading, setFinishButtonLoading] = useState(false);
+ const [selectedRexOption, setSelectedRexOption] = useState(dropdownOptions[0]);
+ const finishButtonText = __('Apply');
+ const replacementResponse = !modalOpen ? { response: {} } : false;
+ const initialSelectedHosts = fetchBulkParams();
+ const apiOptions = { key: 'BULK_HOST_ERRATA' };
+ const defaultParams = { included: { search: initialSelectedHosts } };
+ const hostsBulkSelect =
+ useErrataHostsBulkSelect({ initialSelectedHosts, modalIsOpen: modalOpen });
+
+ const errataResponse = useTableIndexAPIResponse({
+ replacementResponse, // don't fetch data if modal is closed
+ apiUrl: ERRATA_URL,
+ apiOptions,
+ defaultParams,
+ });
+
+ const {
+ status: errataStatus,
+ response: {
+ results: errataResults,
+ ...errataMetadata
+ },
+ } = errataResponse;
+
+ const { total, page, subtotal } = errataMetadata;
+
+ const errataBulkSelect = useBulkSelect({
+ results: errataResults,
+ metadata: { total, page, selectable: subtotal },
+ idColumn: 'errata_id',
+ });
+
+ // eslint-disable-next-line no-restricted-globals
+ const selectionIsValid = count => count > 0 || isNaN(count);
+ const errataResultsPresent = errataResults?.length > 0;
+ const errataSelectionIsValid =
+ selectionIsValid(errataBulkSelect.selectedCount);
+ const hostSelectionIsValid = selectionIsValid(hostsBulkSelect.hostsBulkSelect.selectedCount);
+ let step2Valid = shouldValidateStep2 ? errataSelectionIsValid : true;
+ if (errataStatus === STATUS.RESOLVED && !errataResultsPresent) step2Valid = false;
+ const step3Valid = shouldValidateStep3 ? hostSelectionIsValid : true;
+ const step4Valid = hostSelectionIsValid && errataSelectionIsValid;
+
+ const BulkErrataWizardContextData = {
+ finishButtonText,
+ initialSelectedHostCount,
+ setShouldValidateStep2,
+ finishButtonLoading,
+ setFinishButtonLoading,
+ selectedRexOption,
+ setSelectedRexOption,
+ closeModal,
+ errataBulkSelect,
+ errataResults,
+ errataMetadata,
+ errataResponse,
+ hostsBulkSelect: hostsBulkSelect.hostsBulkSelect,
+ };
+ return (
+
+ }
+ >
+
+
+
+
+
+
+ }
+ isDisabled={!step4Valid || !errataResultsPresent}
+ >
+
+
+
+
+ );
+};
+
+export default BulkErrataWizard;
diff --git a/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/index.js b/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/index.js
new file mode 100644
index 00000000000..779979f9f00
--- /dev/null
+++ b/webpack/components/extensions/Hosts/BulkActions/BulkErrataWizard/index.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Modal, ModalVariant } from '@patternfly/react-core';
+import { useForemanModal } from 'foremanReact/components/ForemanModal/ForemanModalHooks';
+import BulkErrataWizard from './BulkErrataWizard';
+
+const BulkErrataWizardModal = () => {
+ const { modalOpen: isOpen } = useForemanModal({ id: 'bulk-errata-wizard' });
+
+ return (
+
+
+
+ );
+};
+
+export default BulkErrataWizardModal;
diff --git a/webpack/components/extensions/Hosts/BulkActions/BulkPackagesWizard/BulkPackagesWizard.js b/webpack/components/extensions/Hosts/BulkActions/BulkPackagesWizard/BulkPackagesWizard.js
index 7c5f0d4b855..56ca7d59d45 100644
--- a/webpack/components/extensions/Hosts/BulkActions/BulkPackagesWizard/BulkPackagesWizard.js
+++ b/webpack/components/extensions/Hosts/BulkActions/BulkPackagesWizard/BulkPackagesWizard.js
@@ -8,7 +8,7 @@ import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/Ac
import { useTableIndexAPIResponse } from 'foremanReact/components/PF4/TableIndexPage/Table/TableIndexHooks';
import { STATUS } from 'foremanReact/constants';
import { HOSTS_API_PATH } from 'foremanReact/routes/Hosts/constants';
-import HostReview from './03_HostReview';
+import HostReview from '../HostReview';
import { BulkPackagesReview, dropdownOptions } from './04_Review';
import { BulkPackagesUpgradeTable, BulkPackagesInstallTable } from './02_BulkPackagesTable';
import { BulkPackagesReviewFooter } from './04_ReviewFooter';
diff --git a/webpack/components/extensions/Hosts/BulkActions/BulkPackagesWizard/03_HostReview.js b/webpack/components/extensions/Hosts/BulkActions/HostReview.js
similarity index 100%
rename from webpack/components/extensions/Hosts/BulkActions/BulkPackagesWizard/03_HostReview.js
rename to webpack/components/extensions/Hosts/BulkActions/HostReview.js
diff --git a/webpack/global_index.js b/webpack/global_index.js
index af1ac7ed478..fc665fd0b8b 100644
--- a/webpack/global_index.js
+++ b/webpack/global_index.js
@@ -34,8 +34,7 @@ import RecentCommunicationCardExtensions from './components/extensions/HostDetai
import SystemPurposeCard from './components/extensions/HostDetails/Cards/SystemPurposeCard/SystemPurposeCard';
import BulkChangeHostCVModal from './components/extensions/Hosts/BulkActions/BulkChangeHostCVModal/index.js';
import BulkPackagesWizardModal from './components/extensions/Hosts/BulkActions/BulkPackagesWizard/index.js';
-
-
+import BulkErrataWizardModal from './components/extensions/Hosts/BulkActions/BulkErrataWizard/index.js';
import ActivationKeysSearch from './components/ActivationKeysSearch';
registerReducer('katelloExtends', extendReducer);
@@ -92,6 +91,7 @@ addGlobalFill('host-tab-details-cards', 'HW properties', , 100);
addGlobalFill('_all-hosts-modals', 'BulkPackagesWizardModal', , 200);
+addGlobalFill('_all-hosts-modals', 'BulkErrataWizardModal', , 200);
registerColumns(hostsIndexColumnExtensions);