References tab

Signed-off-by: Raul Kele <raulkeleblk@gmail.com>

Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
This commit is contained in:
Raul Kele 2023-01-19 10:34:57 +02:00
parent 9eeb00e801
commit cea2fb605e
9 changed files with 382 additions and 17 deletions

View File

@ -0,0 +1,103 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { api } from 'api';
import ReferredBy from 'components/ReferredBy';
import React from 'react';
const mockReferrersList = {
data: {
Referrers: [
{
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
ArtifactType: 'application/vnd.example.icecream.v1',
Size: 466,
Digest: 'sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c',
Annotations: [
{
Key: 'demo',
Value: 'true'
},
{
Key: 'format',
Value: 'oci'
}
]
},
{
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
ArtifactType: 'application/vnd.example.icecream.v1',
Size: 466,
Digest: 'sha256:d9ad22f41d9cb9797c134401416eee2a70446cee1a8eb76fc6b191f4320dade2',
Annotations: [
{
Key: 'demo',
Value: 'true'
},
{
Key: 'format',
Value: 'oci'
}
]
}
]
}
};
// useNavigate mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedUsedNavigate
}));
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
});
describe('Referred by tab', () => {
it('should render referrers if there are any', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
render(<ReferredBy repoName="golang" digest="test" />);
expect(await screen.findAllByText('Media type: application/vnd.oci.artifact.manifest.v1+json')).toHaveLength(2);
});
it("renders no referrers if there aren't any", async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: { Referrers: [] } } });
render(<ReferredBy repoName="golang" digest="test" />);
expect(await screen.findByText(/Nothing found/i)).toBeInTheDocument();
});
it('should log an error if the request fails', async () => {
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<ReferredBy repoName="golang" digest="test" />);
await waitFor(() => expect(error).toBeCalledTimes(1));
});
it('should display the digest when clicking the dropdowns', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
render(<ReferredBy repoName="golang" digest="test" />);
const firstDigest = (await screen.findAllByText(/digest/i))[0];
expect(firstDigest).toBeInTheDocument();
await userEvent.click(firstDigest);
expect(
await screen.findByText(/sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c/i)
).toBeInTheDocument();
await userEvent.click(firstDigest);
expect(
await screen.findByText(/sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c/i)
).not.toBeInTheDocument();
});
it('should display the annotations when clicking the dropdown', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
render(<ReferredBy repoName="golang" digest="test" />);
const firstAnnotations = (await screen.findAllByText(/ANNOTATIONS/i))[0];
expect(firstAnnotations).toBeInTheDocument();
await userEvent.click(firstAnnotations);
expect(await screen.findByText(/demo: true/i)).toBeInTheDocument();
await userEvent.click(firstAnnotations);
expect(await screen.findByText(/demo: true/i)).not.toBeInTheDocument();
});
});

View File

@ -235,7 +235,7 @@ describe('Tags details', () => {
const dependenciesTab = await screen.findByTestId('dependencies-tab');
fireEvent.click(dependenciesTab);
expect(await screen.findByTestId('depends-on-container')).toBeInTheDocument();
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(4));
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(5));
});
it("should log an error when data can't be fetched", async () => {

View File

@ -105,7 +105,9 @@ const endpoints = {
const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`;
const paginationParam = `requestedPage: {limit:${pageSize} offset:${(pageNumber - 1) * pageSize} sortBy:RELEVANCE}`;
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam}) {Images {RepoName Tag Logo}}}`;
}
},
referrers: ({ repo, digest, type = '' }) =>
`/v2/_zot/ext/search?query={Referrers(repo: "${repo}" digest: "${digest}" type: "${type}"){MediaType ArtifactType Size Digest Annotations{Key Value}}}`
};
export { api, endpoints };

View File

@ -89,13 +89,15 @@ function DependsOn(props) {
)
.then((response) => {
if (response.data && response.data.data) {
let imagesData = response.data.data.BaseImageList?.Results?.map((img) => mapToImage(img));
const newImageList = [...images, ...imagesData];
setImages(newImageList);
setIsEndOfList(
response.data.data.BaseImageList.Page?.ItemCount < EXPLORE_PAGE_SIZE ||
newImageList.length >= response.data.data.BaseImageList?.Page?.TotalCount
);
if (!isEmpty(response.data.data.BaseImageList?.Results)) {
let imagesData = response.data.data.BaseImageList?.Results?.map((img) => mapToImage(img));
const newImageList = [...images, ...imagesData];
setImages(newImageList);
setIsEndOfList(
response.data.data.BaseImageList.Page?.ItemCount < EXPLORE_PAGE_SIZE ||
newImageList.length >= response.data.data.BaseImageList?.Page?.TotalCount
);
}
}
setIsLoading(false);
})

View File

@ -119,13 +119,6 @@ function Header() {
<Grid container className={classes.grid}>
<Grid item xs={2} sx={{ display: 'flex', justifyContent: 'start' }}>
<Link to="/home" className={classes.grid}>
{/* <img
alt="zot"
src={logo}
srcSet={`${logoxs} 192w, ${logo} 489w`}
sizes="(max-width: 480px) 192px, 489px"
className={classes.logo}
/> */}
<picture>
<source media="(min-width:600px)" srcSet={logo} />
<img alt="zot" src={logoxs} className={classes.logo} />

View File

@ -0,0 +1,106 @@
import React, { useEffect, useState, useMemo } from 'react';
import { makeStyles } from '@mui/styles';
import { isEmpty } from 'lodash';
import { Divider, Typography, Stack } from '@mui/material';
import ReferrerCard from './ReferrerCard';
import Loading from './Loading';
import { api, endpoints } from 'api';
import { host } from '../host';
import { mapReferrer } from 'utilities/objectModels';
const useStyles = makeStyles(() => ({
none: {
color: '#52637A',
fontSize: '1.4rem',
fontWeight: '600'
}
}));
function ReferredBy(props) {
const { repoName, digest } = props;
const [referrers, setReferrers] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const classes = useStyles();
const abortController = useMemo(() => new AbortController(), []);
useEffect(() => {
api
.get(`${host()}${endpoints.referrers({ repo: repoName, digest })}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
let referrersData = response.data.data.Referrers?.map((referrer) => mapReferrer(referrer));
if (!isEmpty(referrersData)) {
setReferrers(referrersData);
}
}
setIsLoading(false);
})
.catch((e) => {
console.error(e);
setIsLoading(false);
});
return () => {
abortController.abort();
};
}, []);
const renderReferrers = () => {
return !isEmpty(referrers) ? (
referrers.map((referrer, index) => {
return (
<ReferrerCard
key={index}
artifactType={referrer.artifactType}
mediaType={referrer.mediaType}
size={referrer.size}
digest={referrer.digest}
annotations={referrer.annotations}
/>
);
})
) : (
<div>{!isLoading && <Typography className={classes.none}> Nothing found </Typography>}</div>
);
};
const renderListBottom = () => {
if (isLoading) {
return <Loading />;
}
if (!isLoading) {
return <div />;
}
return;
};
return (
<div data-testid="referred-by-container">
<Typography
variant="h4"
gutterBottom
component="div"
align="left"
style={{
color: 'rgba(0, 0, 0, 0.87)',
fontSize: '1.5rem',
fontWeight: '600',
paddingTop: '0.5rem'
}}
>
Referred By
</Typography>
<Divider
variant="fullWidth"
sx={{ margin: '5% 0% 5% 0%', background: 'rgba(0, 0, 0, 0.38)', height: '0.00625rem', width: '100%' }}
/>
<Stack direction="column" spacing={2}>
<Stack direction="column" spacing={2}>
{renderReferrers()}
{renderListBottom()}
</Stack>
</Stack>
</div>
);
}
export default ReferredBy;

View File

@ -0,0 +1,146 @@
import React from 'react';
import { makeStyles } from '@mui/styles';
import { Card, CardContent, Stack, Tooltip, Typography, Collapse, Box, Grid } from '@mui/material';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
import { useState } from 'react';
const useStyles = makeStyles(() => ({
refCard: {
marginBottom: 2,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
background: '#FFFFFF',
boxShadow: 'none!important',
borderRadius: '1.875rem',
flex: 'none',
alignSelf: 'stretch',
flexGrow: 0,
order: 0,
width: '100%'
},
card: {
marginBottom: '2rem',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
background: '#FFFFFF',
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
borderRadius: '1.875rem',
flex: 'none',
alignSelf: 'stretch',
flexGrow: 0,
order: 0,
width: '100%'
},
content: {
textAlign: 'left',
color: '#606060',
padding: '2% 3% 2% 3%',
width: '100%'
},
clickCursor: {
cursor: 'pointer'
},
cardText: {
color: '#000000',
fontSize: '1rem',
paddingBottom: '0.5rem',
paddingTop: '0.5rem',
textOverflow: 'ellipsis'
},
dropdown: {
flexDirection: 'row',
alignItems: 'center'
},
dropdownText: {
color: '#1479FF',
paddingTop: '1rem',
fontSize: '1rem',
fontWeight: '600',
cursor: 'pointer',
textAlign: 'center'
}
}));
export default function ReferrerCard(props) {
const { artifactType, mediaType, size, digest, annotations } = props;
const [digestDropdownOpen, setDigestDropdownOpen] = useState(false);
const [annotationDropdownOpen, setAnnotationDropdownOpen] = useState(false);
const classes = useStyles();
return (
<Card className={classes.card} raised>
<CardContent className={classes.content}>
<Typography variant="body1" align="left" className={classes.cardText}>
Type: {artifactType && `${artifactType}`}
</Typography>
<Typography variant="body1" align="left" className={classes.cardText}>
Media type: {mediaType && `${mediaType}`}
</Typography>
<Typography variant="body1" align="left" className={classes.cardText}>
Size: {size && `${size}`}
</Typography>
<Stack direction="row" onClick={() => setDigestDropdownOpen(!digestDropdownOpen)}>
{!digestDropdownOpen ? (
<KeyboardArrowRight className={classes.dropdownText} />
) : (
<KeyboardArrowDown className={classes.dropdownText} />
)}
<Typography
sx={{
color: '#1479FF',
paddingTop: '1rem',
fontSize: '0.8125rem',
fontWeight: '600',
cursor: 'pointer'
}}
>
DIGEST
</Typography>
</Stack>
<Collapse in={digestDropdownOpen} timeout="auto" unmountOnExit>
<Box>
<Grid container item xs={12} direction={'row'}>
<Tooltip title={digest || ''} placement="top">
<Typography variant="body1">{digest}</Typography>
</Tooltip>
</Grid>
</Box>
</Collapse>
<Stack direction="row" onClick={() => setAnnotationDropdownOpen(!annotationDropdownOpen)}>
{!annotationDropdownOpen ? (
<KeyboardArrowRight className={classes.dropdownText} />
) : (
<KeyboardArrowDown className={classes.dropdownText} />
)}
<Typography
sx={{
color: '#1479FF',
paddingTop: '1rem',
fontSize: '0.8125rem',
fontWeight: '600',
cursor: 'pointer'
}}
>
ANNOTATIONS
</Typography>
</Stack>
<Collapse in={annotationDropdownOpen} timeout="auto" unmountOnExit>
<Box>
<Grid container item xs={12} direction={'row'}>
<ul>
{annotations?.map((annotation) => (
<li key={annotation.key}>
<Typography variant="body1">{`${annotation?.key}: ${annotation?.value}`}</Typography>
</li>
))}
</ul>
</Grid>
</Box>
</Collapse>
</CardContent>
</Card>
);
}

View File

@ -41,6 +41,7 @@ import { isEmpty } from 'lodash';
import Loading from './Loading';
import { dockerPull, podmanPull, skopeoPull } from 'utilities/pullStrings';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import ReferredBy from './ReferredBy';
const useStyles = makeStyles((theme) => ({
pageWrapper: {
@ -511,6 +512,7 @@ function TagDetails() {
/>
<Tab value="IsDependentOn" label="Used by" className={classes.tabContent} />
<Tab value="Vulnerabilities" label="Vulnerabilities" className={classes.tabContent} />
<Tab value="ReferredBy" label="Referred By" className={classes.tabContent} />
</TabList>
<Grid container>
<Grid item xs={12}>
@ -526,6 +528,9 @@ function TagDetails() {
<TabPanel value="Vulnerabilities" className={classes.tabPanel}>
<VulnerabilitiesDetails name={reponame} tag={tag} />
</TabPanel>
<TabPanel value="ReferredBy" className={classes.tabPanel}>
<ReferredBy repoName={reponame} digest={imageDetailData?.digest} />
</TabPanel>
</Grid>
</Grid>
</Box>

View File

@ -73,4 +73,12 @@ const mapCVEInfo = (cveInfo) => {
};
};
export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo };
const mapReferrer = (referrer) => ({
mediaType: referrer.MediaType,
artifactType: referrer.ArtifactType,
size: referrer.Size,
digest: referrer.Digest,
annotations: referrer.Annotations?.map((annotation) => ({ key: annotation.Key, value: annotation.Value }))
});
export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapReferrer };