Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes #38107 - Booted container images page #11269

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
3 changes: 3 additions & 0 deletions lib/katello/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions lib/katello/plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@
:engine => Katello::Engine,
:turbolinks => false

menu :top_menu,
:booted_container_images,
:caption => N_('Booted container images'),
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
:caption => N_('Booted container images'),
:caption => N_('Booted Container Images'),

Main nav and main page title are some of the few exceptions where we don't use sentence case.

: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,
Expand Down
5 changes: 5 additions & 0 deletions webpack/containers/Application/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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') })),
},
];
Original file line number Diff line number Diff line change
@@ -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;
240 changes: 240 additions & 0 deletions webpack/scenes/BootedContainerImages/BootedContainerImagesPage.js
Original file line number Diff line number Diff line change
@@ -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'),
Copy link
Member

Choose a reason for hiding this comment

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

Can we change this to Bootc - booted image name so it's also easier to figure out the serach query?

Copy link
Member Author

Choose a reason for hiding this comment

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

I changed all of the references to image_name to bootc_booted_image like in the API.

isSorted: true,
},
digest: {
title: __('Image digests'),
wrapper: ({ digests }) => digests.length,
},
hosts: {
title: __('Hosts'),
wrapper: ({ bootc_booted_image: bootcBootedImage, digests }) => (
<a href={`/hosts?search=bootc_booted_image%20=%20${bootcBootedImage}`}>{digests.reduce((total, digest) => total + digest.host_count, 0)}</a>
),
},
};

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 (
<TableIndexPage
apiUrl={BOOTED_CONTAINER_IMAGES_API_PATH}
apiOptions={apiOptions}
header={__('Booted container images')}
createable={false}
isDeleteable={false}
controller="/katello/api/v2/host_bootc_images"
>
<>
<TableComposable variant="compact" ouiaId="booted-containers-table" isStriped>
<Thead>
<Tr ouiaId="table-header">
<>
<Th />
{columnNamesKeys.map(k => (
<Th
width={getColumnWidth(k)}
key={k}
sort={
Object.values(columnsToSortParams).includes(k) &&
pfSortParams(keysToColumnNames[k])
}
>
{keysToColumnNames[k]}
</Th>
))}
</>
</Tr>
</Thead>
<Tbody>
{status === STATUS.PENDING && results.length === 0 && (
<Tr ouiaId="table-loading">
<Td colSpan={100}>
<EmptyPage
message={{
type: 'loading',
text: __('Loading...'),
}}
/>
</Td>
</Tr>
)}
{!status === STATUS.PENDING &&
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
{!status === STATUS.PENDING &&
{!(status === STATUS.PENDING) &&

don't wanna check the opposite of a string ;)

results.length === 0 &&
!errorMessage && (
<Tr ouiaId="table-empty">
<Td colSpan={100}>
<EmptyPage
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure why, but this empty state page does not want to get rendered. The loading page renders even, just not this one.

Copy link
Member

Choose a reason for hiding this comment

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

see above, maybe that's it?

message={{
type: 'empty',
}}
/>
</Td>
</Tr>
)}
{errorMessage && (
<Tr ouiaId="table-error">
<Td colSpan={100}>
<EmptyPage message={{ type: 'error', text: errorMessage }} />
</Td>
</Tr>
)}
</Tbody>
{results?.map((result, rowIndex) => {
const { bootc_booted_image: bootcBootedImage, digests } = result;
const isExpanded = imageIsExpanded(bootcBootedImage);
return (
<Tbody key={`bootable-container-images-body-${bootcBootedImage}`} isExpanded={isExpanded}>
<Tr key={bootcBootedImage} ouiaId={`table-row-${rowIndex}`}>
<>
<Td
expand={digests.length > 0 && {
rowIndex,
isExpanded,
onToggle: (_event, _rInx, isOpen) =>
expandedImages.onToggle(isOpen, bootcBootedImage),
expandId: `booted-containers-expander-${bootcBootedImage}`,
}}
/>
{columnNamesKeys.map(k => (
<Td
key={`${bootcBootedImage}-${keysToColumnNames[k]}`}
dataLabel={keysToColumnNames[k]}
>
{columns[k].wrapper ? columns[k].wrapper(result) : result[k]}
</Td>
))}
</>
</Tr>
{digests ?
<Tr isExpanded={isExpanded} ouiaId={`table-row-outer-expandable-${rowIndex}`}>
<Td />
<Td colSpan={3}>
<ExpandableRowContent>
<TableComposable variant="compact" isStriped ouiaId={`table-composable-expanded-${rowIndex}`}>
<Thead>
<Tr ouiaId={`table-row-inner-expandable-${rowIndex}`}>
<Th width={50}>{__('Image digest')}</Th>
Copy link
Member Author

Choose a reason for hiding this comment

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

I tried to use the width to align the inner "Hosts" data with the outer "Hosts" data, but they are always off by a little bit. So, for now, I distanced them enough to not look completely weird. Ideally I could remove the Hosts header in the inner expanded table and have that data be perfectly lined up with the outer table's hosts count. However, I'm not sure if that's easily possible with these PF components.

Copy link
Member

Choose a reason for hiding this comment

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

Hard-coding width or height in pixels is usually not advisable, because everything can change depending on the user's text size in the browser. 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

The width there that I'm setting is a percentage, so it should adapt.

<Th width={50}>{__('Hosts')}</Th>
</Tr>
</Thead>
<Tbody>
{digests.map((digest, index) => (
<Tr key={digest.bootc_booted_digest} ouiaId={`table-row-expandable-content-${index}`}>
<Td>{digest.bootc_booted_digest}</Td>
<Td>
<a href={`/hosts?search=bootc_booted_digest%20=%20${digest.bootc_booted_digest}`}>{digest.host_count}</a>
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
</ExpandableRowContent>
</Td>
</Tr> : null}
</Tbody>
);
})}
</TableComposable>
{results.length > 0 && !errorMessage &&
<Pagination
key="table-bottom-pagination"
page={params.page}
perPage={params.perPage}
itemCount={subtotal}
onChange={onPagination}
updateParamsByUrl
/>
}
</>
</TableIndexPage>
);
};

export default BootedContainerImagesPage;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react';
Copy link
Member Author

Choose a reason for hiding this comment

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

This test is in-progress.

import { renderWithRedux, patientlyWaitFor, act } from 'react-testing-lib-wrapper';
import { nockInstance, assertNockRequest, mockAutocomplete } from '../../../test-utils/nockWrapper';
import api from '../../../services/api';

Check failure on line 4 in webpack/scenes/BootedContainerImages/__tests__/bootedContainerImagesPage.test.js

View workflow job for this annotation

GitHub Actions / react-tests / Foreman develop Ruby 2.7 and Node 18

'api' is defined but never used
import BOOTED_CONTAINER_IMAGES_KEY from '../BootedContainerImagesConstants';

Check failure on line 5 in webpack/scenes/BootedContainerImages/__tests__/bootedContainerImagesPage.test.js

View workflow job for this annotation

GitHub Actions / react-tests / Foreman develop Ruby 2.7 and Node 18

'BOOTED_CONTAINER_IMAGES_KEY' is defined but never used
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;

Check failure on line 18 in webpack/scenes/BootedContainerImages/__tests__/bootedContainerImagesPage.test.js

View workflow job for this annotation

GitHub Actions / react-tests / Foreman develop Ruby 2.7 and Node 18

'secondImage' is assigned a value but never used
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(<BootedContainerImagesPage />);

Check failure on line 33 in webpack/scenes/BootedContainerImages/__tests__/bootedContainerImagesPage.test.js

View workflow job for this annotation

GitHub Actions / react-tests / Foreman develop Ruby 2.7 and Node 18

'queryAllByText' is assigned a value but never used
expect(queryByText(firstImage.bootc_booted_image)).toBeNull();

await patientlyWaitFor(() => {
expect(queryByText(firstImage.bootc_booted_image)).toBeInTheDocument();
});

assertNockRequest(autocompleteScope);
assertNockRequest(scope, done);
act(done);
});
Loading
Loading