diff --git a/src/Components/ImagesTable/ClonesTable.js b/src/Components/ImagesTable/ClonesTable.js new file mode 100644 index 00000000..a99a3531 --- /dev/null +++ b/src/Components/ImagesTable/ClonesTable.js @@ -0,0 +1,81 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + TableComposable, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@patternfly/react-table'; +import { useSelector } from 'react-redux'; +import { ImageBuildStatus } from './ImageBuildStatus'; +import ImageLink from './ImageLink'; +import { + selectClonesById, + selectComposeById, + selectImageById, +} from '../../store/composesSlice'; +import { timestampToDisplayString } from '../../Utilities/time'; + +const Row = ({ imageId }) => { + const image = useSelector((state) => selectImageById(state, imageId)); + return ( + + + {image.id} + + {timestampToDisplayString(image.created_at)} + + {image.share_with_accounts?.[0]} + {image.region} + + + + + + + + + ); +}; + +const ClonesTable = ({ composeId }) => { + const parentCompose = useSelector((state) => + selectComposeById(state, composeId) + ); + const clones = useSelector((state) => selectClonesById(state, composeId)); + + return ( + + + + UUID + Created + Account + Region + Status + Instance + + + + {clones.map((clone) => ( + + ))} + + ); +}; + +Row.propTypes = { + imageId: PropTypes.string, +}; + +ClonesTable.propTypes = { + composeId: PropTypes.string, +}; + +export default ClonesTable; diff --git a/src/Components/ImagesTable/ImageBuildStatus.js b/src/Components/ImagesTable/ImageBuildStatus.js index ffa9aaa2..1982ee19 100644 --- a/src/Components/ImagesTable/ImageBuildStatus.js +++ b/src/Components/ImagesTable/ImageBuildStatus.js @@ -12,25 +12,34 @@ import { } from '@patternfly/react-icons'; import './ImageBuildStatus.scss'; +import { useSelector } from 'react-redux'; +import { + selectImageById, + selectImageStatusesById, +} from '../../store/composesSlice'; +import { hoursToExpiration } from '../../Utilities/time'; +import { AWS_S3_EXPIRATION_TIME_IN_HOURS } from '../../constants'; -const ImageBuildStatus = (props) => { +export const ImageBuildStatus = ({ imageId }) => { + const image = useSelector((state) => selectImageById(state, imageId)); + + const remainingHours = + AWS_S3_EXPIRATION_TIME_IN_HOURS - hoursToExpiration(image.created_at); + + // Messages appear in order of priority const messages = { - success: [ - { - icon: , - text: 'Ready', - }, - ], failure: [ { icon: , text: 'Image build failed', + priority: 6, }, ], pending: [ { icon: , text: 'Image build is pending', + priority: 2, }, ], // Keep "running" for backward compatibility @@ -38,31 +47,42 @@ const ImageBuildStatus = (props) => { { icon: , text: 'Image build in progress', + priority: 1, }, ], building: [ { icon: , text: 'Image build in progress', + priority: 3, }, ], uploading: [ { icon: , text: 'Image upload in progress', + priority: 4, }, ], registering: [ { icon: , text: 'Cloud registration in progress', + priority: 5, + }, + ], + success: [ + { + icon: , + text: 'Ready', + priority: 0, }, ], expiring: [ { icon: , - text: `Expires in ${props.remainingHours} ${ - props.remainingHours > 1 ? 'hours' : 'hour' + text: `Expires in ${remainingHours} ${ + remainingHours > 1 ? 'hours' : 'hour' }`, }, ], @@ -73,10 +93,38 @@ const ImageBuildStatus = (props) => { }, ], }; + + let status; + if (image.imageType === 'aws') { + const imageStatuses = useSelector((state) => + selectImageStatusesById(state, image.id) + ); + const filteredImageStatuses = imageStatuses.filter( + (imageStatus) => imageStatus !== undefined + ); + if (filteredImageStatuses.length === 0) { + status = image.status; + } else { + status = filteredImageStatuses.reduce((prev, current) => { + return messages[prev][0].priority > messages[current][0].priority + ? prev + : current; + }); + } + } else if (image.uploadType === 'aws.s3' && image.status === 'success') { + // Cloud API currently reports expired images status as 'success' + status = + hoursToExpiration(image.created_at) >= AWS_S3_EXPIRATION_TIME_IN_HOURS + ? 'expired' + : 'expiring'; + } else { + status = image.status; + } + return ( - {messages[props.status] && - messages[props.status].map((message, key) => ( + {messages[status] && + messages[status].map((message, key) => (
{message.icon}
{message.text} @@ -87,8 +135,5 @@ const ImageBuildStatus = (props) => { }; ImageBuildStatus.propTypes = { - status: PropTypes.string, - remainingHours: PropTypes.number, + imageId: PropTypes.string, }; - -export default ImageBuildStatus; diff --git a/src/Components/ImagesTable/ImageLink.js b/src/Components/ImagesTable/ImageLink.js index 517304f0..b0d918bb 100644 --- a/src/Components/ImagesTable/ImageLink.js +++ b/src/Components/ImagesTable/ImageLink.js @@ -1,70 +1,98 @@ -import React, { Suspense } from 'react'; +import React, { Suspense, useState } from 'react'; import PropTypes from 'prop-types'; import { Button } from '@patternfly/react-core'; import { useLoadModule, useScalprum } from '@scalprum/react-core'; +import { useSelector } from 'react-redux'; import ImageLinkDirect from './ImageLinkDirect'; +import { selectImageById } from '../../store/composesSlice'; +import { selectComposeById } from '../../store/composesSlice'; -const ImageLink = ({ - imageId, - imageName, - imageType, - imageStatus, - ...props -}) => { - const scalprum = useScalprum(); - const hasProvisionig = scalprum.initialized && scalprum.config?.provisioning; - const uploadStatus = imageStatus?.upload_status; +const ProvisioningLink = ({ imageId, isExpired, isInClonesTable }) => { + let image = useSelector((state) => selectImageById(state, imageId)); + const parent = image.isClone + ? useSelector((state) => selectComposeById(state, image.parent)) + : null; - if (!uploadStatus) return null; + const [wizardOpen, openWizard] = useState(false); + const [{ default: ProvisioningWizard }, error] = useLoadModule( + { + appName: 'provisioning', // optional + scope: 'provisioning', + module: './ProvisioningWizard', + // processor: (val) => val, // optional + }, + {}, + {} + ); - if (hasProvisionig && imageType === 'ami') { - const [wizardOpen, openWizard] = React.useState(false); - const [{ default: ProvisioningWizard }, error] = useLoadModule( - { - appName: 'provisioning', // optional - scope: 'provisioning', - module: './ProvisioningWizard', - // processor: (val) => val, // optional - }, - {}, - {} + if (!error) { + image = image.isClone ? parent : image; + return ( + + + {wizardOpen && ( + openWizard(false)} + image={{ + name: image.imageName, + id: image.id, + }} + /> + )} + ); - - if (!error) { - return ( - - - {wizardOpen && ( - openWizard(false)} - image={{ name: imageName, id: imageId }} - /> - )} - - ); - } } return ( ); }; +const ImageLink = ({ imageId, isExpired, isInClonesTable }) => { + const image = useSelector((state) => selectImageById(state, imageId)); + const uploadStatus = image.uploadStatus; + + const scalprum = useScalprum(); + const hasProvisioning = scalprum.initialized && scalprum.config?.provisioning; + + if (!uploadStatus) return null; + + if (hasProvisioning && image.imageType === 'ami') { + return ( + + ); + } + + return ( + + ); +}; + +ProvisioningLink.propTypes = { + imageId: PropTypes.string, + isExpired: PropTypes.bool, + isInClonesTable: PropTypes.bool, +}; + ImageLink.propTypes = { imageId: PropTypes.string.isRequired, - imageName: PropTypes.string.isRequired, - imageStatus: PropTypes.object, - imageType: PropTypes.string, - uploadOptions: PropTypes.object, isExpired: PropTypes.bool, - recreateImage: PropTypes.object, + isInClonesTable: PropTypes.bool, }; export default ImageLink; diff --git a/src/Components/ImagesTable/ImageLinkDirect.js b/src/Components/ImagesTable/ImageLinkDirect.js index 7dd73812..50f3cf31 100644 --- a/src/Components/ImagesTable/ImageLinkDirect.js +++ b/src/Components/ImagesTable/ImageLinkDirect.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useNavigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; import { Button, @@ -10,10 +11,16 @@ import { TextVariants, } from '@patternfly/react-core'; import { ExternalLinkAltIcon } from '@patternfly/react-icons'; +import { RegionsPopover } from './RegionsPopover'; +import { selectImageById } from '../../store/composesSlice'; import { resolveRelPath } from '../../Utilities/path'; -const ImageLinkDirect = ({ uploadStatus, ...props }) => { +const ImageLinkDirect = ({ imageId, isExpired, isInClonesTable }) => { const navigate = useNavigate(); + + const image = useSelector((state) => selectImageById(state, imageId)); + const uploadStatus = image.uploadStatus; + const fileExtensions = { vsphere: '.vmdk', 'guest-image': '.qcow2', @@ -26,27 +33,29 @@ const ImageLinkDirect = ({ uploadStatus, ...props }) => { uploadStatus.options.region + '#LaunchInstanceWizard:ami=' + uploadStatus.options.ami; - return ( - - ); + if (isInClonesTable) { + return ( + + ); + } else { + return ; + } } else if (uploadStatus.type === 'azure') { const url = 'https://portal.azure.com/#@' + - props.uploadOptions.tenant_id + + image.uploadOptions.tenant_id + '/resource/subscriptions/' + - props.uploadOptions.subscription_id + + image.uploadOptions.subscription_id + '/resourceGroups/' + - props.uploadOptions.resource_group + + image.uploadOptions.resource_group + '/providers/Microsoft.Compute/images/' + uploadStatus.options.image_name; return ( @@ -89,7 +98,7 @@ const ImageLinkDirect = ({ uploadStatus, ...props }) => { Shared with
{/* the account the image is shared with is stored in the form type:account so this extracts the account */} - {props.uploadOptions.share_with_accounts[0].split(':')[1]} + {image.uploadOptions.share_with_accounts[0].split(':')[1]} } @@ -100,7 +109,7 @@ const ImageLinkDirect = ({ uploadStatus, ...props }) => { ); } else if (uploadStatus.type === 'aws.s3') { - if (!props.isExpired) { + if (!isExpired) { return ( ); } else { @@ -121,7 +130,7 @@ const ImageLinkDirect = ({ uploadStatus, ...props }) => { onClick={() => navigate(resolveRelPath('imagewizard'), { state: { - composeRequest: props.recreateImage, + composeRequest: image.request, initialStep: 'review', }, }) @@ -138,11 +147,9 @@ const ImageLinkDirect = ({ uploadStatus, ...props }) => { }; ImageLinkDirect.propTypes = { - uploadStatus: PropTypes.object, - imageType: PropTypes.string, + imageId: PropTypes.string, isExpired: PropTypes.bool, - recreateImage: PropTypes.object, - uploadOptions: PropTypes.object, + isInClonesTable: PropTypes.bool, }; export default ImageLinkDirect; diff --git a/src/Components/ImagesTable/ImagesTable.js b/src/Components/ImagesTable/ImagesTable.js index dfb5eb8c..10293ef8 100644 --- a/src/Components/ImagesTable/ImagesTable.js +++ b/src/Components/ImagesTable/ImagesTable.js @@ -27,14 +27,20 @@ import { } from '@patternfly/react-core'; import { PlusCircleIcon } from '@patternfly/react-icons'; import './ImagesTable.scss'; -import ImageBuildStatus from './ImageBuildStatus'; +import { ImageBuildStatus } from './ImageBuildStatus'; import Release from './Release'; import Target from './Target'; import ImageLink from './ImageLink'; import ErrorDetails from './ImageBuildErrorDetails'; +import ClonesTable from './ClonesTable'; import DocumentationButton from '../sharedComponents/DocumentationButton'; import { fetchComposes, fetchComposeStatus } from '../../store/actions/actions'; import { resolveRelPath } from '../../Utilities/path'; +import { + hoursToExpiration, + timestampToDisplayString, +} from '../../Utilities/time'; +import { AWS_S3_EXPIRATION_TIME_IN_HOURS } from '../../constants'; const ImagesTable = () => { const [page, setPage] = useState(1); @@ -111,65 +117,6 @@ const ImagesTable = () => { setPage(1); }; - const timestampToDisplayString = (ts) => { - // timestamp has format 2021-04-27 12:31:12.794809 +0000 UTC - // must be converted to ms timestamp and then reformatted to Apr 27, 2021 - if (!ts) { - return ''; - } - - // get YYYY-MM-DD format - const date = ts.slice(0, 10); - const ms = Date.parse(date); - const options = { month: 'short', day: 'numeric', year: 'numeric' }; - const tsDisplay = new Intl.DateTimeFormat('en-US', options).format(ms); - return tsDisplay; - }; - - const convertStringToDate = (createdAtAsString) => { - if (isNaN(Date.parse(createdAtAsString))) { - // converts property created_at of the image object from string to UTC - const [dateValues, timeValues] = createdAtAsString.split(' '); - const datetimeString = `${dateValues}T${timeValues}Z`; - return Date.parse(datetimeString); - } else { - return Date.parse(createdAtAsString); - } - }; - - const setComposeStatus = (compose) => { - if (!compose.image_status) { - return ''; - } else if ( - compose.request.image_requests[0].upload_request.type !== 'aws.s3' || - compose.image_status.status !== 'success' - ) { - return compose.image_status.status; - } else if ( - hoursToExpiration(compose.created_at) >= s3ExpirationTimeInHours - ) { - return 'expired'; - } else { - return 'expiring'; - } - }; - - const hoursToExpiration = (imageCreatedAt) => { - if (imageCreatedAt) { - const currentTime = Date.now(); - // miliseconds in hour - needed for calculating the difference - // between current date and the date of the image creation - const msInHour = 1000 * 60 * 60; - const timeUntilExpiration = Math.floor( - (currentTime - convertStringToDate(imageCreatedAt)) / msInHour - ); - return timeUntilExpiration; - } else { - // when creating a new image, the compose.created_at can be undefined when first queued - return 0; - } - }; - const actions = (compose) => [ { title: 'Recreate image', @@ -197,8 +144,6 @@ const ImagesTable = () => { const itemsStartInclusive = (page - 1) * perPage; const itemsEndExclusive = itemsStartInclusive + perPage; - const s3ExpirationTimeInHours = 6; - return ( {(composes.allIds.length === 0 && ( @@ -258,7 +203,7 @@ const ImagesTable = () => { Image name - Created + Created/Updated Release Target Status @@ -272,7 +217,7 @@ const ImagesTable = () => { const compose = composes.byId[id]; return ( - + { - + - + = - s3ExpirationTimeInHours + AWS_S3_EXPIRATION_TIME_IN_HOURS ? true : false } - recreateImage={compose.request} /> @@ -337,11 +258,16 @@ const ImagesTable = () => { - - UUID -
{id}
- -
+ {compose.request.image_requests[0].upload_request + .type === 'aws' ? ( + + ) : ( + + UUID +
{id}
+ +
+ )} diff --git a/src/Components/ImagesTable/ImagesTable.scss b/src/Components/ImagesTable/ImagesTable.scss index e463134c..d56ace76 100644 --- a/src/Components/ImagesTable/ImagesTable.scss +++ b/src/Components/ImagesTable/ImagesTable.scss @@ -1,11 +1,4 @@ -@media only screen and (min-width: 768px) { - .pf-c-table__expandable-row td:first-child { - // Align with expand/collapse button by duplicating its padding - padding-left: calc(var(--pf-c-table--cell--first-last-child--PaddingLeft) + var(--pf-global--spacer--md)); - } -} -.pf-m-expanded tr:first-child { - // Remove border between a compose and its expanded detail +.pf-m-expanded .no-bottom-border { border-bottom-style: none; } diff --git a/src/Components/ImagesTable/RegionsPopover.js b/src/Components/ImagesTable/RegionsPopover.js new file mode 100644 index 00000000..c18895df --- /dev/null +++ b/src/Components/ImagesTable/RegionsPopover.js @@ -0,0 +1,90 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Popover } from '@patternfly/react-core'; +import { useSelector } from 'react-redux'; +import { createSelector } from '@reduxjs/toolkit'; +import { selectComposeById, selectImagesById } from '../../store/composesSlice'; + +export const selectRegions = createSelector( + [selectComposeById, selectImagesById], + (compose, images) => { + const filteredImages = images.filter( + (image) => + compose.share_with_accounts && + compose.share_with_accounts[0] === image.share_with_accounts[0] + ); + + let regions = {}; + filteredImages.forEach((image) => { + if (image.region && image.status === 'success') { + if (regions[image.region]) { + new Date(image.created_at) < + new Date(regions[image.region].created_at) + ? null + : (regions[image.region] = { + ami: image.ami, + created_at: image.created_at, + }); + } else { + regions[image.region] = { + ami: image.ami, + created_at: image.created_at, + }; + } + } + }); + + return regions; + } +); + +const ImageLinkRegion = ({ region, ami }) => { + const url = + 'https://console.aws.amazon.com/ec2/v2/home?region=' + + region + + '#LaunchInstanceWizard:ami=' + + ami; + + return ( + + ); +}; + +export const RegionsPopover = ({ composeId }) => { + const regions = useSelector((state) => selectRegions(state, composeId)); + + const listItems = useMemo(() => { + let listItems = []; + for (const [key, value] of Object.entries(regions).sort()) { + listItems.push( +
  • + +
  • + ); + } + return listItems; + }, [regions]); + + return ( + Launch instance} + bodyContent={
      {listItems}
    } + > + +
    + ); +}; + +ImageLinkRegion.propTypes = { + region: PropTypes.string, + ami: PropTypes.string, +}; + +RegionsPopover.propTypes = { + composeId: PropTypes.string, +}; diff --git a/src/Components/ImagesTable/Target.js b/src/Components/ImagesTable/Target.js index eee8f525..7699ba92 100644 --- a/src/Components/ImagesTable/Target.js +++ b/src/Components/ImagesTable/Target.js @@ -1,7 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { selectComposeById } from '../../store/composesSlice'; + +const Target = ({ composeId }) => { + const compose = useSelector((state) => selectComposeById(state, composeId)); -const Target = (props) => { const targetOptions = { aws: 'Amazon Web Services', azure: 'Microsoft Azure', @@ -12,18 +16,21 @@ const Target = (props) => { }; let target; - if (props.uploadType === 'aws.s3') { - target = targetOptions[props.imageType]; + if (compose.uploadType === 'aws.s3') { + target = targetOptions[compose.imageType]; + } else if (compose.uploadType === 'aws') { + target = + targetOptions[compose.uploadType] + + ` (${compose.clones.length !== 0 ? compose.clones.length + 1 : 1})`; } else { - target = targetOptions[props.uploadType]; + target = targetOptions[compose.uploadType]; } return <>{target}; }; Target.propTypes = { - uploadType: PropTypes.string, - imageType: PropTypes.string, + composeId: PropTypes.string, }; export default Target; diff --git a/src/Utilities/time.js b/src/Utilities/time.js index 6a62a7bf..f44b13d6 100644 --- a/src/Utilities/time.js +++ b/src/Utilities/time.js @@ -1,15 +1,3 @@ -export const timestampToISO8601 = (timestamp) => { - if (!timestamp) { - return ''; - } - - const date = timestamp.slice(0, 10); - const time = timestamp.slice(11, 26); - - return `${date}T${time}+0000`; -}; - - export const timestampToDisplayString = (ts) => { // timestamp has format 2021-04-27 12:31:12.794809 +0000 UTC // must be converted to ms timestamp and then reformatted to Apr 27, 2021 @@ -24,3 +12,30 @@ export const timestampToDisplayString = (ts) => { const tsDisplay = new Intl.DateTimeFormat('en-US', options).format(ms); return tsDisplay; }; + +export const convertStringToDate = (createdAtAsString) => { + if (isNaN(Date.parse(createdAtAsString))) { + // converts property created_at of the image object from string to UTC + const [dateValues, timeValues] = createdAtAsString.split(' '); + const datetimeString = `${dateValues}T${timeValues}Z`; + return Date.parse(datetimeString); + } else { + return Date.parse(createdAtAsString); + } +}; + +export const hoursToExpiration = (imageCreatedAt) => { + if (imageCreatedAt) { + const currentTime = Date.now(); + // miliseconds in hour - needed for calculating the difference + // between current date and the date of the image creation + const msInHour = 1000 * 60 * 60; + const timeUntilExpiration = Math.floor( + (currentTime - convertStringToDate(imageCreatedAt)) / msInHour + ); + return timeUntilExpiration; + } else { + // when creating a new image, the compose.created_at can be undefined when first queued + return 0; + } +}; diff --git a/src/constants.js b/src/constants.js index af20d250..d830600f 100644 --- a/src/constants.js +++ b/src/constants.js @@ -43,3 +43,5 @@ export const AWS_REGIONS = [ { description: 'Middle East (UAE)', value: 'me-central-1' }, { description: 'South America (S\u00e3o Paolo)', value: 'sa-east-1' }, ]; + +export const AWS_S3_EXPIRATION_TIME_IN_HOURS = 6; diff --git a/src/test/Components/ImagesTable/ImagesTable.test.js b/src/test/Components/ImagesTable/ImagesTable.test.js index a8a18c24..c297346f 100644 --- a/src/test/Components/ImagesTable/ImagesTable.test.js +++ b/src/test/Components/ImagesTable/ImagesTable.test.js @@ -46,10 +46,12 @@ const mockComposes = { image_requests: [ { architecture: 'x86_64', - image_type: 'ami', + image_type: 'vhd', upload_request: { - type: 'aws', - options: {}, + type: 'gcp', + options: { + share_with_accounts: ['serviceAccount:test@email.com'], + }, }, }, ], @@ -247,7 +249,13 @@ const mockStatus = { // kept "running" for backward compatibility 'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa': { image_status: { - status: 'running', + status: 'failure', + error: { + reason: 'A dependency error occured', + details: { + reason: 'Error in depsolve job', + }, + }, }, }, 'edbae1c2-62bc-42c1-ae0c-3110ab718f58': { @@ -537,17 +545,17 @@ describe('Images Table', () => { const { getAllByRole } = within(table); const rows = getAllByRole('row'); - const errorToggle = within(rows[7]).getByRole('button', { + const errorToggle = within(rows[2]).getByRole('button', { name: /details/i, }); expect( - screen.getAllByText(/61b0effa-c901-4ee5-86b9-2010b47f1b22/i)[1] + screen.getAllByText(/c1cfa347-4c37-49b5-8e73-6aa1d1746cfa/i)[1] ).not.toBeVisible(); userEvent.click(errorToggle); expect( - screen.getAllByText(/61b0effa-c901-4ee5-86b9-2010b47f1b22/i)[1] + screen.getAllByText(/c1cfa347-4c37-49b5-8e73-6aa1d1746cfa/i)[1] ).toBeVisible(); expect(screen.getAllByText(/Error in depsolve job/i)[0]).toBeVisible(); });