Images Table: Add clones table for AWS composes
This commit is contained in:
parent
ed9325615c
commit
5c37e3b45b
11 changed files with 419 additions and 217 deletions
81
src/Components/ImagesTable/ClonesTable.js
Normal file
81
src/Components/ImagesTable/ClonesTable.js
Normal file
|
|
@ -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 (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<Td dataLabel="UUID">{image.id}</Td>
|
||||
<Td dataLabel="Created">
|
||||
{timestampToDisplayString(image.created_at)}
|
||||
</Td>
|
||||
<Td dataLabel="Account">{image.share_with_accounts?.[0]}</Td>
|
||||
<Td dataLabel="Region">{image.region}</Td>
|
||||
<Td dataLabel="Status">
|
||||
<ImageBuildStatus imageId={image.id} />
|
||||
</Td>
|
||||
<Td dataLabel="Instance">
|
||||
<ImageLink imageId={image.id} isInClonesTable={true} />
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
);
|
||||
};
|
||||
|
||||
const ClonesTable = ({ composeId }) => {
|
||||
const parentCompose = useSelector((state) =>
|
||||
selectComposeById(state, composeId)
|
||||
);
|
||||
const clones = useSelector((state) => selectClonesById(state, composeId));
|
||||
|
||||
return (
|
||||
<TableComposable
|
||||
variant="compact"
|
||||
className="pf-u-mb-md"
|
||||
data-testid="clones-table"
|
||||
>
|
||||
<Thead>
|
||||
<Tr className="no-bottom-border">
|
||||
<Th>UUID</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Account</Th>
|
||||
<Th>Region</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Instance</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Row imageId={parentCompose.id} imageType={'compose'} />
|
||||
{clones.map((clone) => (
|
||||
<Row imageId={clone.id} key={clone.id} />
|
||||
))}
|
||||
</TableComposable>
|
||||
);
|
||||
};
|
||||
|
||||
Row.propTypes = {
|
||||
imageId: PropTypes.string,
|
||||
};
|
||||
|
||||
ClonesTable.propTypes = {
|
||||
composeId: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ClonesTable;
|
||||
|
|
@ -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: <CheckCircleIcon className="success" />,
|
||||
text: 'Ready',
|
||||
},
|
||||
],
|
||||
failure: [
|
||||
{
|
||||
icon: <ExclamationCircleIcon className="error" />,
|
||||
text: 'Image build failed',
|
||||
priority: 6,
|
||||
},
|
||||
],
|
||||
pending: [
|
||||
{
|
||||
icon: <PendingIcon />,
|
||||
text: 'Image build is pending',
|
||||
priority: 2,
|
||||
},
|
||||
],
|
||||
// Keep "running" for backward compatibility
|
||||
|
|
@ -38,31 +47,42 @@ const ImageBuildStatus = (props) => {
|
|||
{
|
||||
icon: <InProgressIcon className="pending" />,
|
||||
text: 'Image build in progress',
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
building: [
|
||||
{
|
||||
icon: <InProgressIcon className="pending" />,
|
||||
text: 'Image build in progress',
|
||||
priority: 3,
|
||||
},
|
||||
],
|
||||
uploading: [
|
||||
{
|
||||
icon: <InProgressIcon className="pending" />,
|
||||
text: 'Image upload in progress',
|
||||
priority: 4,
|
||||
},
|
||||
],
|
||||
registering: [
|
||||
{
|
||||
icon: <InProgressIcon className="pending" />,
|
||||
text: 'Cloud registration in progress',
|
||||
priority: 5,
|
||||
},
|
||||
],
|
||||
success: [
|
||||
{
|
||||
icon: <CheckCircleIcon className="success" />,
|
||||
text: 'Ready',
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
expiring: [
|
||||
{
|
||||
icon: <ExclamationTriangleIcon className="expiring" />,
|
||||
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 (
|
||||
<React.Fragment>
|
||||
{messages[props.status] &&
|
||||
messages[props.status].map((message, key) => (
|
||||
{messages[status] &&
|
||||
messages[status].map((message, key) => (
|
||||
<Flex key={key} className="pf-u-align-items-baseline pf-m-nowrap">
|
||||
<div className="pf-u-mr-sm">{message.icon}</div>
|
||||
{message.text}
|
||||
|
|
@ -87,8 +135,5 @@ const ImageBuildStatus = (props) => {
|
|||
};
|
||||
|
||||
ImageBuildStatus.propTypes = {
|
||||
status: PropTypes.string,
|
||||
remainingHours: PropTypes.number,
|
||||
imageId: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ImageBuildStatus;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Suspense fallback="loading">
|
||||
<Button variant="link" isInline onClick={() => openWizard(true)}>
|
||||
Launch
|
||||
</Button>
|
||||
{wizardOpen && (
|
||||
<ProvisioningWizard
|
||||
isOpen
|
||||
onClose={() => openWizard(false)}
|
||||
image={{
|
||||
name: image.imageName,
|
||||
id: image.id,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
if (!error) {
|
||||
return (
|
||||
<Suspense fallback="loading">
|
||||
<Button variant="link" isInline onClick={() => openWizard(true)}>
|
||||
Launch
|
||||
</Button>
|
||||
{wizardOpen && (
|
||||
<ProvisioningWizard
|
||||
isOpen
|
||||
onClose={() => openWizard(false)}
|
||||
image={{ name: imageName, id: imageId }}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageLinkDirect
|
||||
imageType={imageType}
|
||||
uploadStatus={uploadStatus}
|
||||
{...props}
|
||||
imageId={image.id}
|
||||
isExpired={isExpired}
|
||||
isInClonesTable={isInClonesTable}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<ProvisioningLink
|
||||
imageId={image.id}
|
||||
isExpired={isExpired}
|
||||
isInClonesTable={isInClonesTable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageLinkDirect
|
||||
imageId={image.id}
|
||||
isExpired={isExpired}
|
||||
isInClonesTable={isInClonesTable}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Button
|
||||
component="a"
|
||||
target="_blank"
|
||||
variant="link"
|
||||
icon={<ExternalLinkAltIcon />}
|
||||
iconPosition="right"
|
||||
isInline
|
||||
href={url}
|
||||
>
|
||||
Launch instance
|
||||
</Button>
|
||||
);
|
||||
if (isInClonesTable) {
|
||||
return (
|
||||
<Button
|
||||
component="a"
|
||||
target="_blank"
|
||||
variant="link"
|
||||
isInline
|
||||
href={url}
|
||||
>
|
||||
Launch
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return <RegionsPopover composeId={image.id} />;
|
||||
}
|
||||
} 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 }) => {
|
|||
<strong>Shared with</strong>
|
||||
<br />
|
||||
{/* 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]}
|
||||
</Text>
|
||||
</TextContent>
|
||||
}
|
||||
|
|
@ -100,7 +109,7 @@ const ImageLinkDirect = ({ uploadStatus, ...props }) => {
|
|||
</Popover>
|
||||
);
|
||||
} else if (uploadStatus.type === 'aws.s3') {
|
||||
if (!props.isExpired) {
|
||||
if (!isExpired) {
|
||||
return (
|
||||
<Button
|
||||
component="a"
|
||||
|
|
@ -109,7 +118,7 @@ const ImageLinkDirect = ({ uploadStatus, ...props }) => {
|
|||
isInline
|
||||
href={uploadStatus.options.url}
|
||||
>
|
||||
Download ({fileExtensions[props.imageType]})
|
||||
Download ({fileExtensions[image.imageType]})
|
||||
</Button>
|
||||
);
|
||||
} 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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<React.Fragment>
|
||||
{(composes.allIds.length === 0 && (
|
||||
|
|
@ -258,7 +203,7 @@ const ImagesTable = () => {
|
|||
<Tr>
|
||||
<Th />
|
||||
<Th>Image name</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Created/Updated</Th>
|
||||
<Th>Release</Th>
|
||||
<Th>Target</Th>
|
||||
<Th>Status</Th>
|
||||
|
|
@ -272,7 +217,7 @@ const ImagesTable = () => {
|
|||
const compose = composes.byId[id];
|
||||
return (
|
||||
<Tbody key={id} isExpanded={isExpanded(compose)}>
|
||||
<Tr>
|
||||
<Tr className="no-bottom-border">
|
||||
<Td
|
||||
expand={{
|
||||
rowIndex,
|
||||
|
|
@ -291,44 +236,20 @@ const ImagesTable = () => {
|
|||
<Release release={compose.request.distribution} />
|
||||
</Td>
|
||||
<Td dataLabel="Target">
|
||||
<Target
|
||||
uploadType={
|
||||
compose.request.image_requests[0].upload_request
|
||||
.type
|
||||
}
|
||||
imageType={
|
||||
compose.request.image_requests[0].image_type
|
||||
}
|
||||
/>
|
||||
<Target composeId={id} />
|
||||
</Td>
|
||||
<Td dataLabel="Status">
|
||||
<ImageBuildStatus
|
||||
status={setComposeStatus(compose)}
|
||||
remainingHours={
|
||||
s3ExpirationTimeInHours -
|
||||
hoursToExpiration(compose.created_at)
|
||||
}
|
||||
/>
|
||||
<ImageBuildStatus imageId={id} />
|
||||
</Td>
|
||||
<Td dataLabel="Instance">
|
||||
<ImageLink
|
||||
imageId={id}
|
||||
imageName={compose.request.image_name || id}
|
||||
imageStatus={compose.image_status}
|
||||
imageType={
|
||||
compose.request.image_requests[0].image_type
|
||||
}
|
||||
uploadOptions={
|
||||
compose.request.image_requests[0].upload_request
|
||||
.options
|
||||
}
|
||||
isExpired={
|
||||
hoursToExpiration(compose.created_at) >=
|
||||
s3ExpirationTimeInHours
|
||||
AWS_S3_EXPIRATION_TIME_IN_HOURS
|
||||
? true
|
||||
: false
|
||||
}
|
||||
recreateImage={compose.request}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
|
|
@ -337,11 +258,16 @@ const ImagesTable = () => {
|
|||
</Tr>
|
||||
<Tr isExpanded={isExpanded(compose)}>
|
||||
<Td colSpan={8}>
|
||||
<ExpandableRowContent>
|
||||
<strong>UUID</strong>
|
||||
<div>{id}</div>
|
||||
<ErrorDetails status={compose.image_status} />
|
||||
</ExpandableRowContent>
|
||||
{compose.request.image_requests[0].upload_request
|
||||
.type === 'aws' ? (
|
||||
<ClonesTable composeId={compose.id} />
|
||||
) : (
|
||||
<ExpandableRowContent>
|
||||
<strong>UUID</strong>
|
||||
<div>{id}</div>
|
||||
<ErrorDetails status={compose.image_status} />
|
||||
</ExpandableRowContent>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
90
src/Components/ImagesTable/RegionsPopover.js
Normal file
90
src/Components/ImagesTable/RegionsPopover.js
Normal file
|
|
@ -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 (
|
||||
<Button component="a" target="_blank" variant="link" isInline href={url}>
|
||||
{region}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
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(
|
||||
<li key={key}>
|
||||
<ImageLinkRegion region={key} ami={value.ami} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return listItems;
|
||||
}, [regions]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
aria-label="Launch instance"
|
||||
headerContent={<div>Launch instance</div>}
|
||||
bodyContent={<ul>{listItems}</ul>}
|
||||
>
|
||||
<Button variant="link" isInline>
|
||||
Launch
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
ImageLinkRegion.propTypes = {
|
||||
region: PropTypes.string,
|
||||
ami: PropTypes.string,
|
||||
};
|
||||
|
||||
RegionsPopover.propTypes = {
|
||||
composeId: PropTypes.string,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue