Images Table: Add clones table for AWS composes

This commit is contained in:
lucasgarfield 2022-10-24 16:10:24 +02:00 committed by Sanne Raymaekers
parent ed9325615c
commit 5c37e3b45b
11 changed files with 419 additions and 217 deletions

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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>

View file

@ -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;
}

View 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,
};

View file

@ -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;

View file

@ -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;
}
};

View file

@ -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;

View file

@ -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();
});