From 5335ba145d3e3c851f5f54fb09a45a9de06355f2 Mon Sep 17 00:00:00 2001 From: Ian Ballou Date: Mon, 16 Dec 2024 22:44:50 +0000 Subject: [PATCH 1/6] Fixes #38107 - add booted container images page --- config/routes.rb | 2 ++ lib/katello/plugin.rb | 9 +++++++ webpack/containers/Application/config.js | 5 ++++ .../BootedContainerImagesConstants.js | 6 +++++ .../BootedContainerImagesPage.js | 25 +++++++++++++++++++ webpack/scenes/BootedContainerImages/index.js | 4 +++ 6 files changed, 51 insertions(+) create mode 100644 webpack/scenes/BootedContainerImages/BootedContainerImagesConstants.js create mode 100644 webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js create mode 100644 webpack/scenes/BootedContainerImages/index.js 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/plugin.rb b/lib/katello/plugin.rb index 15001859612..2909cd452bd 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..b64816c6d91 --- /dev/null +++ b/webpack/scenes/BootedContainerImages/BootedContainerImagesConstants.js @@ -0,0 +1,6 @@ +import { translate as __ } from 'foremanReact/common/I18n'; +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..24ecd43e7f8 --- /dev/null +++ b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js @@ -0,0 +1,25 @@ +import React from 'react'; +import TableIndexPage from 'foremanReact/components/PF4/TableIndexPage/TableIndexPage'; +import { translate as __ } from 'foremanReact/common/I18n'; +import BOOTED_CONTAINER_IMAGES_KEY, { BOOTED_CONTAINER_IMAGES_API_PATH } from './BootedContainerImagesConstants'; + +const BootedContainerImagesPage = () => { + const columns = { + image_name: { + title: __('Image name'), + }, + }; + return ( + + ); +}; + +export default BootedContainerImagesPage; 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); From 15119dc43c55737cb2679b740e85d9cd2aad2d34 Mon Sep 17 00:00:00 2001 From: Ian Ballou Date: Wed, 18 Dec 2024 21:37:33 +0000 Subject: [PATCH 2/6] Refs #38107 - use Table on bootc images page --- .../BootedContainerImagesPage.js | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js index 24ecd43e7f8..e699cb8e161 100644 --- a/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js +++ b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js @@ -1,5 +1,13 @@ import React from 'react'; import TableIndexPage from 'foremanReact/components/PF4/TableIndexPage/TableIndexPage'; +import { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table'; +import { + useSetParamsAndApiAndSearch, + useTableIndexAPIResponse, +} from 'foremanReact/components/PF4/TableIndexPage/Table/TableIndexHooks'; +import { + useUrlParams, +} from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks'; import { translate as __ } from 'foremanReact/common/I18n'; import BOOTED_CONTAINER_IMAGES_KEY, { BOOTED_CONTAINER_IMAGES_API_PATH } from './BootedContainerImagesConstants'; @@ -9,6 +17,46 @@ const BootedContainerImagesPage = () => { title: __('Image name'), }, }; + + const STATUS = { + PENDING: 'PENDING', + RESOLVED: 'RESOLVED', + ERROR: 'ERROR', + }; + + 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 { + response: { + results, + per_page: perPage, + page, + subtotal, + message: errorMessage, + }, + status = STATUS.PENDING, + setAPIOptions, + } = response; + + const { setParamsAndAPI, params } = useSetParamsAndApiAndSearch({ + defaultParams, + apiOptions, + setAPIOptions: response.setAPIOptions, + }); + return ( { createable={false} isDeleteable={false} controller="/katello/api/v2/host_bootc_images" - columns={columns} - /> + > + + setAPIOptions({ + ...apiOptions, + params: { urlSearchQuery }, + }) + } + columns={columns} + errorMessage={ + status === STATUS.ERROR && errorMessage ? errorMessage : null + } + isPending={status === STATUS.PENDING} + /> + ); }; From d1226ee9e10bd82280969c3e40be3cd623984a00 Mon Sep 17 00:00:00 2001 From: Ian Ballou Date: Thu, 19 Dec 2024 19:03:24 +0000 Subject: [PATCH 3/6] Refs #38107 - add digests + hosts columns to bootc containers page --- .../BootedContainerImagesPage.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js index e699cb8e161..7129ec48b02 100644 --- a/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js +++ b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js @@ -15,6 +15,17 @@ const BootedContainerImagesPage = () => { const columns = { image_name: { title: __('Image name'), + isSorted: true, + }, + digest: { + title: __('Image digests'), + wrapper: ({digests}) => digests.length, + }, + hosts: { + title: __('Hosts'), + wrapper: ({image_name, digests}) => ( + {digests.reduce((total, digest) => total + digest.host_count, 0)} + ), }, }; From 9cd26de0c333148d77163e7007ced6e00f6467c3 Mon Sep 17 00:00:00 2001 From: Ian Ballou Date: Thu, 19 Dec 2024 23:11:26 +0000 Subject: [PATCH 4/6] Refs #38107 - Make booted container images rows expandable --- .../BootedContainerImagesPage.js | 195 +++++++++++++++--- 1 file changed, 161 insertions(+), 34 deletions(-) diff --git a/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js index 7129ec48b02..e3836e9e751 100644 --- a/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js +++ b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js @@ -1,13 +1,22 @@ 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 { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table'; 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'; @@ -29,12 +38,6 @@ const BootedContainerImagesPage = () => { }, }; - const STATUS = { - PENDING: 'PENDING', - RESOLVED: 'RESOLVED', - ERROR: 'ERROR', - }; - const { searchParam: urlSearchQuery = '', page: urlPage, @@ -44,15 +47,34 @@ const BootedContainerImagesPage = () => { 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 { pfSortParams } = useTableSort({ + allColumns: Object.keys(columns).map(k => columns[k].title), + columnsToSortParams, + onSort, + }); + const expandedImages = useSet([]); + const imageIsExpanded = image_name => expandedImages.has(image_name); + const STATUS = { + PENDING: 'PENDING', + RESOLVED: 'RESOLVED', + ERROR: 'ERROR', + }; const { response: { - results, + results = [], per_page: perPage, page, subtotal, @@ -68,40 +90,145 @@ const BootedContainerImagesPage = () => { setAPIOptions: response.setAPIOptions, }); + const [columnNamesKeys, keysToColumnNames] = getColumnHelpers(columns); + const onSort = (_event, index, direction) => { + setParamsAndAPI({ + ...params, + order: `${Object.keys(columns)[index]} ${direction}`, + }); + }; + const onPagination = newPagination => { + setParamsAndAPI({ ...params, ...newPagination }); + }; + const bottomPagination = ( + + ); + return ( -
- setAPIOptions({ - ...apiOptions, - params: { urlSearchQuery }, - }) - } - columns={columns} - errorMessage={ - status === STATUS.ERROR && errorMessage ? errorMessage : null - } - isPending={status === STATUS.PENDING} - /> + <> + + + + <> + + ))} + + + + + {status === STATUS.PENDING && results.length === 0 && ( + + + + )} + {!status === STATUS.PENDING && + results.length === 0 && + !errorMessage && ( + + + + )} + {errorMessage && ( + + + + )} + + {results?.map((result, rowIndex) => { + const { image_name, digests } = result; + const isExpanded = imageIsExpanded(image_name); + return ( + + + <> + + ))} + + + {digests ? + + + + + + + + {digests.map((digest, index) => ( + + + + + ))} + + + + + : null} + + ); + })} + + {results.length > 0 && !errorMessage && bottomPagination} + ); }; From efa7f3e9d8807492080e691e341534acec9cc68c Mon Sep 17 00:00:00 2001 From: Ian Ballou Date: Mon, 23 Dec 2024 15:45:03 +0000 Subject: [PATCH 5/6] Refs #38107 - sorting on booted container images page --- .../BootedContainerImagesPage.js | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js index e3836e9e751..cc634c0aee5 100644 --- a/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js +++ b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js @@ -22,7 +22,7 @@ import BOOTED_CONTAINER_IMAGES_KEY, { BOOTED_CONTAINER_IMAGES_API_PATH } from '. const BootedContainerImagesPage = () => { const columns = { - image_name: { + bootc_booted_image: { title: __('Image name'), isSorted: true, }, @@ -32,8 +32,8 @@ const BootedContainerImagesPage = () => { }, hosts: { title: __('Hosts'), - wrapper: ({image_name, digests}) => ( - {digests.reduce((total, digest) => total + digest.host_count, 0)} + wrapper: ({bootc_booted_image, digests}) => ( + {digests.reduce((total, digest) => total + digest.host_count, 0)} ), }, }; @@ -59,13 +59,19 @@ const BootedContainerImagesPage = () => { columnsToSortParams[columns[key].title] = key; } }); + 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 = image_name => expandedImages.has(image_name); + const imageIsExpanded = bootc_booted_image => expandedImages.has(bootc_booted_image); const STATUS = { PENDING: 'PENDING', RESOLVED: 'RESOLVED', @@ -91,12 +97,6 @@ const BootedContainerImagesPage = () => { }); const [columnNamesKeys, keysToColumnNames] = getColumnHelpers(columns); - const onSort = (_event, index, direction) => { - setParamsAndAPI({ - ...params, - order: `${Object.keys(columns)[index]} ${direction}`, - }); - }; const onPagination = newPagination => { setParamsAndAPI({ ...params, ...newPagination }); }; @@ -175,17 +175,17 @@ const BootedContainerImagesPage = () => { )} {results?.map((result, rowIndex) => { - const { image_name, digests } = result; - const isExpanded = imageIsExpanded(image_name); + const { bootc_booted_image, digests } = result; + const isExpanded = imageIsExpanded(bootc_booted_image); return ( - + <> - )} + )} {errorMessage && ( {results?.map((result, rowIndex) => { - const { bootc_booted_image, digests } = result; - const isExpanded = imageIsExpanded(bootc_booted_image); + const { bootcBootedImage, digests } = result; + const isExpanded = imageIsExpanded(bootcBootedImage); return ( - - + + <> - {digests ? - - - - - - - - {digests.map((digest, index) => ( - - - + {digests ? + + + + + - ))} - - - - - : null} + + + {digests.map((digest, index) => ( + + + + + ))} + + + + + : null} ); })}
+ {columnNamesKeys.map(k => ( + + {keysToColumnNames[k]} +
+ +
+ +
+ +
0 && { + rowIndex, + isExpanded, + onToggle: (_event, _rInx, isOpen,) => expandedImages.onToggle(isOpen, image_name), + expandId: 'booted-containers-expander' + }} + /> + {columnNamesKeys.map(k => ( + + {columns[k].wrapper ? columns[k].wrapper(result) : result[k]} +
+ + +
{__('Image digest')}{__('Hosts')}
{digest.bootc_booted_digest} + {digest.host_count} +
0 && { rowIndex, isExpanded, - onToggle: (_event, _rInx, isOpen,) => expandedImages.onToggle(isOpen, image_name), + onToggle: (_event, _rInx, isOpen,) => expandedImages.onToggle(isOpen, bootc_booted_image), expandId: 'booted-containers-expander' }} /> From 5af40f2efe9a9fc25a448560b201a44507032636 Mon Sep 17 00:00:00 2001 From: Ian Ballou Date: Fri, 10 Jan 2025 21:48:54 +0000 Subject: [PATCH 6/6] Refs #38107 - fix bootc bookmarks + adjust table format --- ...ookmark_controller_validator_extensions.rb | 13 +++ lib/katello/engine.rb | 3 + .../BootedContainerImagesConstants.js | 1 - .../BootedContainerImagesPage.js | 98 ++++++++++--------- 4 files changed, 68 insertions(+), 47 deletions(-) create mode 100644 app/lib/katello/concerns/bookmark_controller_validator_extensions.rb 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/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/webpack/scenes/BootedContainerImages/BootedContainerImagesConstants.js b/webpack/scenes/BootedContainerImages/BootedContainerImagesConstants.js index b64816c6d91..c13016707aa 100644 --- a/webpack/scenes/BootedContainerImages/BootedContainerImagesConstants.js +++ b/webpack/scenes/BootedContainerImages/BootedContainerImagesConstants.js @@ -1,4 +1,3 @@ -import { translate as __ } from 'foremanReact/common/I18n'; import { foremanApi } from '../../services/api'; const BOOTED_CONTAINER_IMAGES_KEY = 'BOOTED_CONTAINER_IMAGES'; diff --git a/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js index cc634c0aee5..1b24633f384 100644 --- a/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js +++ b/webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js @@ -28,12 +28,12 @@ const BootedContainerImagesPage = () => { }, digest: { title: __('Image digests'), - wrapper: ({digests}) => digests.length, + wrapper: ({ digests }) => digests.length, }, hosts: { title: __('Hosts'), - wrapper: ({bootc_booted_image, digests}) => ( - {digests.reduce((total, digest) => total + digest.host_count, 0)} + wrapper: ({ bootc_booted_image: bootcBootedImage, digests }) => ( + {digests.reduce((total, digest) => total + digest.host_count, 0)} ), }, }; @@ -54,11 +54,16 @@ const BootedContainerImagesPage = () => { defaultParams, }); const columnsToSortParams = {}; - Object.keys(columns).forEach(key => { + 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, @@ -71,7 +76,7 @@ const BootedContainerImagesPage = () => { onSort, }); const expandedImages = useSet([]); - const imageIsExpanded = bootc_booted_image => expandedImages.has(bootc_booted_image); + const imageIsExpanded = bootcBootedImage => expandedImages.has(bootcBootedImage); const STATUS = { PENDING: 'PENDING', RESOLVED: 'RESOLVED', @@ -81,23 +86,14 @@ const BootedContainerImagesPage = () => { const { response: { results = [], - per_page: perPage, - page, subtotal, message: errorMessage, }, status = STATUS.PENDING, - setAPIOptions, } = response; - const { setParamsAndAPI, params } = useSetParamsAndApiAndSearch({ - defaultParams, - apiOptions, - setAPIOptions: response.setAPIOptions, - }); - const [columnNamesKeys, keysToColumnNames] = getColumnHelpers(columns); - const onPagination = newPagination => { + const onPagination = (newPagination) => { setParamsAndAPI({ ...params, ...newPagination }); }; const bottomPagination = ( @@ -107,10 +103,16 @@ const BootedContainerImagesPage = () => { perPage={params.perPage} itemCount={subtotal} onChange={onPagination} - updateParamsByUrl={true} + updateParamsByUrl /> ); + const getColumnWidth = (key) => { + if (key === 'bootc_booted_image') return 40; + if (key === 'digest') return 15; + return 45; + }; + return ( { {columnNamesKeys.map(k => ( { />
@@ -175,18 +178,19 @@ const BootedContainerImagesPage = () => { )}
0 && { rowIndex, isExpanded, - onToggle: (_event, _rInx, isOpen,) => expandedImages.onToggle(isOpen, bootc_booted_image), - expandId: 'booted-containers-expander' + onToggle: (_event, _rInx, isOpen) => + expandedImages.onToggle(isOpen, bootcBootedImage), + expandId: 'booted-containers-expander', }} /> {columnNamesKeys.map(k => ( @@ -199,30 +203,32 @@ const BootedContainerImagesPage = () => { ))}
- - -
{__('Image digest')}{__('Hosts')}
{digest.bootc_booted_digest} - {digest.host_count} -
+ + + +
{__('Image digest')}{__('Hosts')}
{digest.bootc_booted_digest} + {digest.host_count} +