diff --git a/app/lib/katello/concerns/bookmark_controller_validator_extensions.rb b/app/lib/katello/concerns/bookmark_controller_validator_extensions.rb new file mode 100644 index 00000000000..e6d8d874150 --- /dev/null +++ b/app/lib/katello/concerns/bookmark_controller_validator_extensions.rb @@ -0,0 +1,13 @@ +module Katello + module Concerns + module BookmarkControllerValidatorExtensions + extend ActiveSupport::Concern + + def valid_controllers_list + @valid_controllers_list ||= (["dashboard", "common_parameters", "/katello/api/v2/host_bootc_images"] + + ActiveRecord::Base.connection.tables.map(&:to_s) + + Permission.resources.map(&:tableize)).uniq + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index e963a1cd5d8..1e816fb57af 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,8 @@ match '/alternate_content_sources' => 'react#index', :via => [:get] match '/alternate_content_sources/*page' => 'react#index', :via => [:get] + match '/booted_container_images' => 'react#index', :via => [:get] + Katello::RepositoryTypeManager.generic_ui_content_types(false).each do |type| get "/#{type.pluralize}", to: redirect("/content/#{type.pluralize}") get "/#{type.pluralize}/:page", to: redirect("/content/#{type.pluralize}/%{page}") diff --git a/lib/katello/engine.rb b/lib/katello/engine.rb index 84db902372a..ff63afd6a56 100644 --- a/lib/katello/engine.rb +++ b/lib/katello/engine.rb @@ -156,6 +156,9 @@ class Engine < ::Rails::Engine ::HttpProxy.include Katello::Concerns::HttpProxyExtensions ForemanTasks::RecurringLogic.include Katello::Concerns::RecurringLogicExtensions + # Validator extensions + ::BookmarkControllerValidator.singleton_class.send :prepend, Katello::Concerns::BookmarkControllerValidatorExtensions + #Controller extensions ::HostsController.include Katello::Concerns::HostsControllerExtensions ::SmartProxiesController.include Katello::Concerns::SmartProxiesControllerExtensions diff --git a/lib/katello/plugin.rb b/lib/katello/plugin.rb index aeda28a33f2..87b0f6f9337 100644 --- a/lib/katello/plugin.rb +++ b/lib/katello/plugin.rb @@ -70,6 +70,15 @@ :engine => Katello::Engine, :turbolinks => false + menu :top_menu, + :booted_container_images, + :caption => N_('Booted container images'), + :url_hash => {:controller => 'katello/api/v2/host_bootc_images', + :action => 'bootc_images'}, + :url => '/booted_container_images', + :engine => Katello::Engine, + :turbolinks => false + divider :top_menu, :caption => N_('Lifecycle'), :parent => :content_menu menu :top_menu, diff --git a/webpack/containers/Application/config.js b/webpack/containers/Application/config.js index b23d84e8da8..618fbb2a918 100644 --- a/webpack/containers/Application/config.js +++ b/webpack/containers/Application/config.js @@ -15,6 +15,7 @@ import ContentDetails from '../../scenes/Content/Details'; import withHeader from './withHeaders'; import ChangeContentSource from '../../scenes/Hosts/ChangeContentSource'; import AlternateContentSource from '../../scenes/AlternateContentSources'; +import BootedContainerImages from '../../scenes/BootedContainerImages'; // eslint-disable-next-line import/prefer-default-export export const links = [ @@ -85,4 +86,8 @@ export const links = [ component: WithOrganization(withHeader(AlternateContentSource, { title: __('Alternate Content Sources') })), exact: false, }, + { + path: 'booted_container_images', + component: WithOrganization(withHeader(BootedContainerImages, { title: __('Booted container images') })), + }, ]; diff --git a/webpack/scenes/BootedContainerImages/BootedContainerImagesConstants.js b/webpack/scenes/BootedContainerImages/BootedContainerImagesConstants.js new file mode 100644 index 00000000000..c13016707aa --- /dev/null +++ b/webpack/scenes/BootedContainerImages/BootedContainerImagesConstants.js @@ -0,0 +1,5 @@ +import { foremanApi } from '../../services/api'; + +const BOOTED_CONTAINER_IMAGES_KEY = 'BOOTED_CONTAINER_IMAGES'; +export const BOOTED_CONTAINER_IMAGES_API_PATH = foremanApi.getApiUrl('/hosts/bootc_images'); +export default BOOTED_CONTAINER_IMAGES_KEY; diff --git a/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js new file mode 100644 index 00000000000..39e31b1a7d9 --- /dev/null +++ b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js @@ -0,0 +1,240 @@ +import React from 'react'; +import { TableComposable, Thead, Th, Tbody, Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; +import TableIndexPage from 'foremanReact/components/PF4/TableIndexPage/TableIndexPage'; +import { + useSetParamsAndApiAndSearch, + useTableIndexAPIResponse, +} from 'foremanReact/components/PF4/TableIndexPage/Table/TableIndexHooks'; +import { + useUrlParams, + useSet, +} from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks'; +import { + getColumnHelpers, +} from 'foremanReact/components/PF4/TableIndexPage/Table/helpers'; +import { + useTableSort, +} from 'foremanReact/components/PF4/Helpers/useTableSort'; +import Pagination from 'foremanReact/components/Pagination'; +import EmptyPage from 'foremanReact/routes/common/EmptyPage'; +import { translate as __ } from 'foremanReact/common/I18n'; +import BOOTED_CONTAINER_IMAGES_KEY, { BOOTED_CONTAINER_IMAGES_API_PATH } from './BootedContainerImagesConstants'; + +const BootedContainerImagesPage = () => { + const columns = { + bootc_booted_image: { + title: __('Image name'), + isSorted: true, + }, + digest: { + title: __('Image digests'), + wrapper: ({ digests }) => digests.length, + }, + hosts: { + title: __('Hosts'), + wrapper: ({ bootc_booted_image: bootcBootedImage, digests }) => ( + {digests.reduce((total, digest) => total + digest.host_count, 0)} + ), + }, + }; + + const { + searchParam: urlSearchQuery = '', + page: urlPage, + per_page: urlPerPage, + } = useUrlParams(); + const defaultParams = { search: urlSearchQuery }; + if (urlPage) defaultParams.page = Number(urlPage); + if (urlPerPage) defaultParams.per_page = Number(urlPerPage); + const apiOptions = { key: BOOTED_CONTAINER_IMAGES_KEY }; + + const response = useTableIndexAPIResponse({ + apiUrl: BOOTED_CONTAINER_IMAGES_API_PATH, + apiOptions, + defaultParams, + }); + const columnsToSortParams = {}; + Object.keys(columns).forEach((key) => { + if (columns[key].isSorted) { + columnsToSortParams[columns[key].title] = key; + } + }); + const { setParamsAndAPI, params } = useSetParamsAndApiAndSearch({ + defaultParams, + apiOptions, + setAPIOptions: response.setAPIOptions, + }); + const onSort = (_event, index, direction) => { + setParamsAndAPI({ + ...params, + order: `${Object.keys(columns)[index]} ${direction}`, + }); + }; + const { pfSortParams } = useTableSort({ + allColumns: Object.keys(columns).map(k => columns[k].title), + columnsToSortParams, + onSort, + }); + const expandedImages = useSet([]); + const imageIsExpanded = bootcBootedImage => expandedImages.has(bootcBootedImage); + const STATUS = { + PENDING: 'PENDING', + RESOLVED: 'RESOLVED', + ERROR: 'ERROR', + }; + + const { + response: { + results = [], + subtotal, + message: errorMessage, + }, + status = STATUS.PENDING, + } = response; + + const [columnNamesKeys, keysToColumnNames] = getColumnHelpers(columns); + const onPagination = (newPagination) => { + setParamsAndAPI({ ...params, ...newPagination }); + }; + const getColumnWidth = (key) => { + if (key === 'bootc_booted_image') return 40; + if (key === 'digest') return 15; + return 45; + }; + + return ( + + <> + + + + <> + + {columnNamesKeys.map(k => ( + + {keysToColumnNames[k]} + + ))} + + + + + {status === STATUS.PENDING && results.length === 0 && ( + + + + + + )} + {!status === STATUS.PENDING && + results.length === 0 && + !errorMessage && ( + + + + + + )} + {errorMessage && ( + + + + + + )} + + {results?.map((result, rowIndex) => { + const { bootc_booted_image: bootcBootedImage, digests } = result; + const isExpanded = imageIsExpanded(bootcBootedImage); + return ( + + + <> + 0 && { + rowIndex, + isExpanded, + onToggle: (_event, _rInx, isOpen) => + expandedImages.onToggle(isOpen, bootcBootedImage), + expandId: `booted-containers-expander-${bootcBootedImage}`, + }} + /> + {columnNamesKeys.map(k => ( + + {columns[k].wrapper ? columns[k].wrapper(result) : result[k]} + + ))} + + + {digests ? + + + + + + + + {__('Image digest')} + {__('Hosts')} + + + + {digests.map((digest, index) => ( + + {digest.bootc_booted_digest} + + {digest.host_count} + + + ))} + + + + + : null} + + ); + })} + + {results.length > 0 && !errorMessage && + + } + + + ); +}; + +export default BootedContainerImagesPage; diff --git a/webpack/scenes/BootedContainerImages/__tests__/bootedContainerImages.fixtures.js b/webpack/scenes/BootedContainerImages/__tests__/bootedContainerImages.fixtures.js new file mode 100644 index 00000000000..2abdebe0547 --- /dev/null +++ b/webpack/scenes/BootedContainerImages/__tests__/bootedContainerImages.fixtures.js @@ -0,0 +1,42 @@ +import Immutable from 'seamless-immutable'; + +const bootedContainerImagesResponse = Immutable({ + total: 2, + page: 1, + per_page: 20, + subtotal: 2, + results: [ + { + bootc_booted_image: 'quay.io/centos-bootc/centos-bootc:stream10', + digests: [ + { + bootc_booted_digest: 'sha256:54256a998f0c62e16f3927c82b570f90bd8449a52e03daabd5fd16d6419fd572', + host_count: 1, + }, + { + bootc_booted_digest: 'sha256:54256a998f0c62e16f3927c82b570f90bd8449a52e03daabd5fd16d6419fd573', + host_count: 1, + }, + { + bootc_booted_digest: 'sha256:54256a998f0c62e16f3927c82b570f90bd8449a52e03daabd5fd16d6419fd574', + host_count: 1, + }, + { + bootc_booted_digest: 'sha256:54256a998f0c62e16f3927c82b570f90bd8449a52e03daabd5fd16d6419fd575', + host_count: 1, + }, + ], + }, + { + bootc_booted_image: 'quay.io/centos-bootc/centos-bootc:stream9', + digests: [ + { + bootc_booted_digest: 'sha256:54256a998f0c62e16f3927c82b570f90bd8449a52e03daabd5fd16d6419fd576', + host_count: 1, + }, + ], + }, + ], +}); + +export default bootedContainerImagesResponse; diff --git a/webpack/scenes/BootedContainerImages/__tests__/bootedContainerImagesPage.test.js b/webpack/scenes/BootedContainerImages/__tests__/bootedContainerImagesPage.test.js new file mode 100644 index 00000000000..b31e5875172 --- /dev/null +++ b/webpack/scenes/BootedContainerImages/__tests__/bootedContainerImagesPage.test.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { renderWithRedux, patientlyWaitFor, act } from 'react-testing-lib-wrapper'; +import { nockInstance, assertNockRequest, mockAutocomplete } from '../../../test-utils/nockWrapper'; +import api from '../../../services/api'; +import BOOTED_CONTAINER_IMAGES_KEY from '../BootedContainerImagesConstants'; +import BootedContainerImagesPage from '../BootedContainerImagesPage'; +import bootcImagesData from './bootedContainerImages.fixtures'; + +// const bootedContainerImagesIndexPath = api.getApiUrl('/booted_container_images'); +// const renderOptions = { apiNamespace: BOOTED_CONTAINER_IMAGES_KEY }; +const bootcImagesUrl = '/api/v2/hosts/bootc_images'; +const autocompleteUrl = '/host_bootc_images/auto_complete_search'; +const autocompleteQuery = { + search: '', +}; + +let firstImage; +let secondImage; +beforeEach(() => { + const { results } = bootcImagesData; + [firstImage, secondImage] = results; +}); + +test('BootedContainerImagesPage renders correctly', async (done) => { + const autocompleteScope = mockAutocomplete(nockInstance, autocompleteUrl, autocompleteQuery); + const scope = nockInstance + .get(bootcImagesUrl) + .query(true) + // Why does the page load twice? + .times(2) + .reply(200, bootcImagesData); + + const { queryByText, queryAllByText } = renderWithRedux(); + expect(queryByText(firstImage.bootc_booted_image)).toBeNull(); + + await patientlyWaitFor(() => { + expect(queryByText(firstImage.bootc_booted_image)).toBeInTheDocument(); + }); + + assertNockRequest(autocompleteScope); + assertNockRequest(scope, done); + act(done); +}); diff --git a/webpack/scenes/BootedContainerImages/index.js b/webpack/scenes/BootedContainerImages/index.js new file mode 100644 index 00000000000..7c72938a1ba --- /dev/null +++ b/webpack/scenes/BootedContainerImages/index.js @@ -0,0 +1,4 @@ +import { withRouter } from 'react-router-dom'; +import BootedContainerImagesPage from './BootedContainerImagesPage'; + +export default withRouter(BootedContainerImagesPage);