diff --git a/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.jsx b/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.jsx
index 496859b27c..bebae176d9 100644
--- a/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.jsx
+++ b/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.jsx
@@ -25,11 +25,10 @@ function UpgradablePackagesList({
key: 'patches',
render: (content, { to_package_id }) => (
- {content.map(({ advisory_name }) => (
-
- {advisory_name}
-
- ))}
+ {content &&
+ content.map(({ advisory }) => (
+
{advisory}
+ ))}
),
},
@@ -47,11 +46,7 @@ function UpgradablePackagesList({
};
});
- return (
-
- );
+ return ;
}
export default UpgradablePackagesList;
diff --git a/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.test.jsx b/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.test.jsx
index 50bd9f3a99..98ca43f778 100644
--- a/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.test.jsx
+++ b/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.test.jsx
@@ -16,8 +16,8 @@ describe('UpgradablePackagesList component', () => {
expect(screen.getByText(expectedInstalledPackage)).toBeVisible();
expect(screen.getByText(expectedLatestPackage)).toBeVisible();
- patches.forEach(({ advisory_name }) => {
- expect(screen.getByText(advisory_name)).toBeVisible();
+ patches.forEach(({ advisory }) => {
+ expect(screen.getByText(advisory)).toBeVisible();
});
});
});
diff --git a/assets/js/common/UpgradablePackagesList/index.js b/assets/js/common/UpgradablePackagesList/index.js
new file mode 100644
index 0000000000..b1b71744ac
--- /dev/null
+++ b/assets/js/common/UpgradablePackagesList/index.js
@@ -0,0 +1,3 @@
+import UpgradablePackagesList from './UpgradablePackagesList';
+
+export default UpgradablePackagesList;
diff --git a/assets/js/lib/api/softwareUpdates.js b/assets/js/lib/api/softwareUpdates.js
index f3169ee30a..94562bdc2a 100644
--- a/assets/js/lib/api/softwareUpdates.js
+++ b/assets/js/lib/api/softwareUpdates.js
@@ -2,3 +2,8 @@ import { networkClient } from '@lib/network';
export const getSoftwareUpdates = (hostID) =>
networkClient.get(`/hosts/${hostID}/software_updates`);
+
+export const getPatchesForPackages = (packageIDs) =>
+ networkClient.get(`/software_updates/packages`, {
+ params: { package_ids: packageIDs },
+ });
diff --git a/assets/js/lib/test-utils/factories/relevantPatches.js b/assets/js/lib/test-utils/factories/relevantPatches.js
index 21fb31abaf..76148f3940 100644
--- a/assets/js/lib/test-utils/factories/relevantPatches.js
+++ b/assets/js/lib/test-utils/factories/relevantPatches.js
@@ -12,3 +12,12 @@ export const relevantPatchFactory = Factory.define(() => ({
date: faker.date.anytime(),
update_date: faker.date.anytime(),
}));
+
+export const patchForPackageFactory = Factory.define(() => ({
+ advisory: faker.animal.cat(),
+ type: faker.helpers.arrayElement(advisoryType),
+ synopsis: faker.lorem.sentence(),
+ issue_date: faker.date.anytime().toString(),
+ update_date: faker.date.anytime().toString(),
+ last_modified_date: faker.date.anytime().toString(),
+}));
diff --git a/assets/js/lib/test-utils/factories/upgradablePackage.js b/assets/js/lib/test-utils/factories/upgradablePackage.js
index 1c6b7d215d..bd5273b1c1 100644
--- a/assets/js/lib/test-utils/factories/upgradablePackage.js
+++ b/assets/js/lib/test-utils/factories/upgradablePackage.js
@@ -1,6 +1,6 @@
import { faker } from '@faker-js/faker';
import { Factory } from 'fishery';
-import { relevantPatchFactory } from './relevantPatches';
+import { patchForPackageFactory } from './relevantPatches';
const releaseVersionFactory = () =>
`${faker.number.int({ min: 100000, max: 160000 })}.${faker.system.semver()}`;
@@ -17,5 +17,5 @@ export const upgradablePackageFactory = Factory.define(() => ({
to_version: faker.system.semver(),
from_arch: faker.airline.flightNumber(),
to_arch: faker.airline.flightNumber(),
- patches: relevantPatchFactory.buildList(2),
+ patches: patchForPackageFactory.buildList(2),
}));
diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx
new file mode 100644
index 0000000000..2b2983d7b4
--- /dev/null
+++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx
@@ -0,0 +1,44 @@
+import React, { useEffect } from 'react';
+import { useParams } from 'react-router-dom';
+import { useSelector, useDispatch } from 'react-redux';
+
+import { getUpgradablePackages } from '@state/selectors/softwareUpdates';
+import { fetchSoftwareUpdatesSettings } from '@state/softwareUpdatesSettings';
+import {
+ fetchSoftwareUpdates,
+ fetchUpgradablePackagesPatches,
+} from '@state/softwareUpdates';
+
+import BackButton from '@common/BackButton';
+import UpgradablePackagesList from '@common/UpgradablePackagesList';
+
+function UpgradablePackagesPage() {
+ const { hostID } = useParams();
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ dispatch(fetchSoftwareUpdates(hostID));
+ dispatch(fetchSoftwareUpdatesSettings());
+ }, []);
+
+ const upgradablePackages = useSelector((state) =>
+ getUpgradablePackages(state, hostID)
+ );
+
+ useEffect(() => {
+ const packageIDs = upgradablePackages.map(
+ ({ to_package_id: packageID }) => packageID
+ );
+
+ dispatch(fetchUpgradablePackagesPatches({ hostID, packageIDs }));
+ }, [upgradablePackages.length, hostID]);
+
+ return (
+ <>
+ Back to Host Details
+
+ >
+ );
+}
+
+export default UpgradablePackagesPage;
diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx
new file mode 100644
index 0000000000..c2818d79c0
--- /dev/null
+++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+
+import { faker } from '@faker-js/faker';
+
+import {
+ renderWithRouterMatch,
+ withState,
+ defaultInitialState,
+} from '@lib/test-utils';
+import { upgradablePackageFactory } from '@lib/test-utils/factories/upgradablePackage';
+import { patchForPackageFactory } from '@lib/test-utils/factories/relevantPatches';
+
+import UpgradablePackagesPage from './UpgradablePackagesPage';
+
+describe('UpgradablePackagesPage', () => {
+ it('should render correctly', () => {
+ const hostID = faker.string.uuid();
+ const patch = patchForPackageFactory.build();
+ const upgradablePackages = upgradablePackageFactory.buildList(10, {
+ patches: [patch],
+ });
+ const [{ name }] = upgradablePackages;
+
+ const [StatefulPage] = withState(, {
+ ...defaultInitialState,
+ softwareUpdates: {
+ softwareUpdates: {
+ [hostID]: {
+ loading: false,
+ errors: [],
+ upgradable_packages: upgradablePackages,
+ },
+ },
+ },
+ });
+
+ renderWithRouterMatch(StatefulPage, {
+ path: 'hosts/:hostID/packages',
+ route: `/hosts/${hostID}/packages`,
+ });
+
+ expect(screen.getAllByText(name, { exact: false })).toHaveLength(2);
+ });
+});
diff --git a/assets/js/pages/UpgradablePackagesPage/index.js b/assets/js/pages/UpgradablePackagesPage/index.js
new file mode 100644
index 0000000000..3fde2855a7
--- /dev/null
+++ b/assets/js/pages/UpgradablePackagesPage/index.js
@@ -0,0 +1,3 @@
+import UpgradablePackagesPage from './UpgradablePackagesPage';
+
+export default UpgradablePackagesPage;
diff --git a/assets/js/state/sagas/softwareUpdates.js b/assets/js/state/sagas/softwareUpdates.js
index 0a5c1e895b..8e4cbf695f 100644
--- a/assets/js/state/sagas/softwareUpdates.js
+++ b/assets/js/state/sagas/softwareUpdates.js
@@ -1,13 +1,18 @@
import { get } from 'lodash';
import { put, call, takeEvery } from 'redux-saga/effects';
-import { getSoftwareUpdates } from '@lib/api/softwareUpdates';
+import {
+ getSoftwareUpdates,
+ getPatchesForPackages,
+} from '@lib/api/softwareUpdates';
import {
+ FETCH_UPGRADABLE_PACKAGES_PATCHES,
FETCH_SOFTWARE_UPDATES,
startLoadingSoftwareUpdates,
setSoftwareUpdates,
setEmptySoftwareUpdates,
setSoftwareUpdatesErrors,
+ setPatchesForPackages,
} from '@state/softwareUpdates';
export function* fetchSoftwareUpdates({ payload: hostID }) {
@@ -24,6 +29,23 @@ export function* fetchSoftwareUpdates({ payload: hostID }) {
}
}
+export function* fetchUpgradablePackagesPatches({
+ payload: { hostID, packageIDs },
+}) {
+ try {
+ const {
+ data: { patches },
+ } = yield call(getPatchesForPackages, packageIDs);
+ yield put(setPatchesForPackages({ hostID, patches }));
+ } catch (error) {
+ yield put(setPatchesForPackages({ hostID, patches: [] }));
+ }
+}
+
export function* watchSoftwareUpdates() {
yield takeEvery(FETCH_SOFTWARE_UPDATES, fetchSoftwareUpdates);
+ yield takeEvery(
+ FETCH_UPGRADABLE_PACKAGES_PATCHES,
+ fetchUpgradablePackagesPatches
+ );
}
diff --git a/assets/js/state/sagas/softwareUpdates.test.js b/assets/js/state/sagas/softwareUpdates.test.js
index ec2ecbb7dc..8304df1e4d 100644
--- a/assets/js/state/sagas/softwareUpdates.test.js
+++ b/assets/js/state/sagas/softwareUpdates.test.js
@@ -2,6 +2,7 @@ import { faker } from '@faker-js/faker';
import MockAdapter from 'axios-mock-adapter';
import { recordSaga } from '@lib/test-utils';
+import { patchForPackageFactory } from '@lib/test-utils/factories';
import { networkClient } from '@lib/network';
@@ -10,9 +11,13 @@ import {
setSoftwareUpdates,
setSoftwareUpdatesErrors,
setEmptySoftwareUpdates,
+ setPatchesForPackages,
} from '@state/softwareUpdates';
-import { fetchSoftwareUpdates } from './softwareUpdates';
+import {
+ fetchSoftwareUpdates,
+ fetchUpgradablePackagesPatches,
+} from './softwareUpdates';
describe('Software Updates saga', () => {
describe('Fetching Software Updates', () => {
@@ -87,4 +92,29 @@ describe('Software Updates saga', () => {
}
);
});
+
+ describe('Fetching patches for packages', () => {
+ it('sets patches for upgradable packages', async () => {
+ const axiosMock = new MockAdapter(networkClient);
+ const hostID = faker.string.uuid();
+ const packageIDs = [faker.string.uuid(), faker.string.uuid()];
+ const patches = patchForPackageFactory.buildList(3);
+ const response = {
+ patches: [
+ { package_id: packageIDs[0], patches },
+ { package_id: packageIDs[1], patches },
+ ],
+ };
+
+ axiosMock.onGet(`/api/v1/software_updates/packages`).reply(200, response);
+
+ const dispatched = await recordSaga(fetchUpgradablePackagesPatches, {
+ payload: { hostID, packageIDs },
+ });
+
+ expect(dispatched).toEqual([
+ setPatchesForPackages({ hostID, patches: response.patches }),
+ ]);
+ });
+ });
});
diff --git a/assets/js/state/selectors/softwareUpdates.js b/assets/js/state/selectors/softwareUpdates.js
index 991a700a8c..a565aec2b3 100644
--- a/assets/js/state/selectors/softwareUpdates.js
+++ b/assets/js/state/selectors/softwareUpdates.js
@@ -22,6 +22,11 @@ export const getSoftwareUpdatesPatches = createSelector(
(softwareUpdates) => get(softwareUpdates, ['relevant_patches'], [])
);
+export const getUpgradablePackages = createSelector(
+ [(state, id) => getSoftwareUpdatesForHost(id)(state)],
+ (softwareUpdates) => get(softwareUpdates, ['upgradable_packages'], [])
+);
+
export const getSoftwareUpdatesLoading = createSelector(
[(state, id) => getSoftwareUpdatesForHost(id)(state)],
(softwareUpdates) => get(softwareUpdates, ['loading'], false)
diff --git a/assets/js/state/selectors/softwareUpdates.test.js b/assets/js/state/selectors/softwareUpdates.test.js
index a8c012de5e..8432d329b5 100644
--- a/assets/js/state/selectors/softwareUpdates.test.js
+++ b/assets/js/state/selectors/softwareUpdates.test.js
@@ -4,6 +4,7 @@ import {
getSoftwareUpdatesStats,
getSoftwareUpdatesLoading,
getSoftwareUpdatesPatches,
+ getUpgradablePackages,
} from './softwareUpdates';
describe('Software Updates selector', () => {
@@ -134,4 +135,10 @@ describe('Software Updates selector', () => {
softwareUpdates[hostID].relevant_patches
);
});
+
+ it('should return the upgradable packages', () => {
+ expect(getUpgradablePackages(state, hostID)).toEqual(
+ softwareUpdates[hostID].upgradable_packages
+ );
+ });
});
diff --git a/assets/js/state/softwareUpdates.js b/assets/js/state/softwareUpdates.js
index 53c0475a27..2653705f66 100644
--- a/assets/js/state/softwareUpdates.js
+++ b/assets/js/state/softwareUpdates.js
@@ -1,4 +1,5 @@
import { createAction, createSlice } from '@reduxjs/toolkit';
+import { find, get, isEmpty } from 'lodash';
const initialState = {
softwareUpdates: {},
@@ -44,18 +45,47 @@ export const softwareUpdatesSlice = createSlice({
[hostID]: { ...initialHostState, errors },
};
},
+ setPatchesForPackages: (state, { payload: { hostID, patches } }) => {
+ const packages = get(
+ state,
+ ['softwareUpdates', hostID, 'upgradable_packages'],
+ []
+ );
+
+ const newPackages = packages.map((currentPackage) => {
+ const { to_package_id: packageID } = currentPackage;
+
+ const packageInfo = find(patches, { package_id: packageID });
+ const packagePatches = get(packageInfo, 'patches', []);
+
+ return {
+ ...currentPackage,
+ patches: packagePatches,
+ };
+ });
+
+ if (!isEmpty(packages)) {
+ state.softwareUpdates[hostID].upgradable_packages = newPackages;
+ }
+ },
},
});
export const FETCH_SOFTWARE_UPDATES = 'FETCH_SOFTWARE_UPDATES';
+export const FETCH_UPGRADABLE_PACKAGES_PATCHES =
+ 'FETCH_UPGRADABLE_PACKAGES_PATCHES';
export const fetchSoftwareUpdates = createAction(FETCH_SOFTWARE_UPDATES);
+export const fetchUpgradablePackagesPatches = createAction(
+ FETCH_UPGRADABLE_PACKAGES_PATCHES
+);
export const {
startLoadingSoftwareUpdates,
setSoftwareUpdates,
setEmptySoftwareUpdates,
setSoftwareUpdatesErrors,
+ setPatchesForPackages,
} = softwareUpdatesSlice.actions;
export default softwareUpdatesSlice.reducer;
diff --git a/assets/js/state/softwareUpdates.test.js b/assets/js/state/softwareUpdates.test.js
index 4c59c539fd..64cf3b6043 100644
--- a/assets/js/state/softwareUpdates.test.js
+++ b/assets/js/state/softwareUpdates.test.js
@@ -1,10 +1,13 @@
import { faker } from '@faker-js/faker';
+import { patchForPackageFactory } from '@lib/test-utils/factories';
+
import softwareUpdatesReducer, {
startLoadingSoftwareUpdates,
setSoftwareUpdates,
setEmptySoftwareUpdates,
setSoftwareUpdatesErrors,
+ setPatchesForPackages,
} from './softwareUpdates';
describe('SoftwareUpdates reducer', () => {
@@ -231,4 +234,78 @@ describe('SoftwareUpdates reducer', () => {
},
});
});
+
+ it('should set patches that cover a specific package upgrade', () => {
+ const host1 = faker.string.uuid();
+
+ const initialState = {
+ softwareUpdates: {
+ [host1]: {
+ loading: false,
+ errors: [],
+ relevant_patches: [
+ {
+ date: '2024-03-11',
+ advisory_name: 'SUSE-15-SP4-2024-833',
+ advisory_type: 'security_advisory',
+ advisory_status: 'stable',
+ id: 4244,
+ advisory_synopsis: 'moderate: Security update for openssl-1_1',
+ update_date: '2024-03-11',
+ },
+ ],
+ upgradable_packages: [
+ {
+ from_epoch: ' ',
+ to_release: '150100.8.33.1',
+ name: 'saptune',
+ from_release: '150400.3.208.1',
+ to_epoch: ' ',
+ arch: 'x86_64',
+ to_package_id: 39942,
+ from_version: '3.1.0',
+ to_version: '3.1.2',
+ from_arch: 'x86_64',
+ to_arch: 'x86_64',
+ },
+ ],
+ },
+ },
+ };
+
+ const patch = patchForPackageFactory.build();
+ const action = setPatchesForPackages({
+ hostID: host1,
+ patches: [{ package_id: 39942, patches: [patch] }],
+ });
+
+ const {
+ softwareUpdates: {
+ [host1]: {
+ upgradable_packages: [{ patches }],
+ },
+ },
+ } = softwareUpdatesReducer(initialState, action);
+
+ expect(patches).toHaveLength(1);
+ expect(patches[0]).toEqual(patch);
+ });
+
+ it('should not apply any patch if the host does not exist', () => {
+ const host1 = faker.string.uuid();
+
+ const initialState = {
+ softwareUpdates: {},
+ };
+
+ const patch = patchForPackageFactory.build();
+ const action = setPatchesForPackages({
+ hostID: host1,
+ patches: [{ package_id: 39942, patches: [patch] }],
+ });
+
+ const { softwareUpdates } = softwareUpdatesReducer(initialState, action);
+
+ expect(softwareUpdates).toEqual({});
+ });
});
diff --git a/assets/js/trento.jsx b/assets/js/trento.jsx
index 6fbdcd4a66..fa1be7c400 100644
--- a/assets/js/trento.jsx
+++ b/assets/js/trento.jsx
@@ -22,6 +22,7 @@ import Home from '@pages/Home';
import HostDetailsPage from '@pages/HostDetailsPage';
import HostSettingsPage from '@pages/HostSettingsPage';
import HostRelevanPatchesPage from '@pages/HostRelevantPatches';
+import UpgradablePackagesPage from '@pages/UpgradablePackagesPage';
import HostsList from '@pages/HostsList';
import Layout from '@pages/Layout';
import Login from '@pages/Login';
@@ -74,6 +75,10 @@ function App() {
path="hosts/:hostID/patches"
element={}
/>
+ }
+ />
} />