Compare commits
	
		
			5 Commits
		
	
	
		
			commit-2e1
			...
			commit-6cd
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 6cda89c710 | ||
|  | 12b474e126 | ||
|  | a9db66bd34 | ||
|  | f4600b8b79 | ||
|  | c375c0697a | 
							
								
								
									
										103
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										103
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -19,6 +19,7 @@ | ||||
|         "@testing-library/user-event": "^13.5.0", | ||||
|         "axios": "^0.24.0", | ||||
|         "downshift": "^6.1.12", | ||||
|         "export-from-json": "^1.7.3", | ||||
|         "lodash": "^4.17.21", | ||||
|         "luxon": "^2.5.2", | ||||
|         "markdown-to-jsx": "^7.1.7", | ||||
| @@ -26,7 +27,8 @@ | ||||
|         "react-dom": "^17.0.2", | ||||
|         "react-router-dom": "^6.2.1", | ||||
|         "react-sticky-el": "^2.0.9", | ||||
|         "web-vitals": "^2.1.3" | ||||
|         "web-vitals": "^2.1.3", | ||||
|         "xlsx": "^0.18.5" | ||||
|       }, | ||||
|       "devDependencies": { | ||||
|         "@babel/plugin-proposal-private-property-in-object": "^7.16.7", | ||||
| @@ -5592,6 +5594,14 @@ | ||||
|         "node": ">=8.9" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/adler-32": { | ||||
|       "version": "1.3.1", | ||||
|       "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", | ||||
|       "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", | ||||
|       "engines": { | ||||
|         "node": ">=0.8" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/agent-base": { | ||||
|       "version": "6.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", | ||||
| @@ -6547,6 +6557,18 @@ | ||||
|         "node": ">=4" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/cfb": { | ||||
|       "version": "1.2.2", | ||||
|       "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", | ||||
|       "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", | ||||
|       "dependencies": { | ||||
|         "adler-32": "~1.3.0", | ||||
|         "crc-32": "~1.2.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=0.8" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/chalk": { | ||||
|       "version": "4.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", | ||||
| @@ -6780,6 +6802,14 @@ | ||||
|         "node": ">=4" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/codepage": { | ||||
|       "version": "1.15.0", | ||||
|       "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", | ||||
|       "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", | ||||
|       "engines": { | ||||
|         "node": ">=0.8" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/collect-v8-coverage": { | ||||
|       "version": "1.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", | ||||
| @@ -7030,6 +7060,17 @@ | ||||
|         "node": ">=10" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/crc-32": { | ||||
|       "version": "1.2.2", | ||||
|       "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", | ||||
|       "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", | ||||
|       "bin": { | ||||
|         "crc32": "bin/crc32.njs" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=0.8" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/cross-spawn": { | ||||
|       "version": "7.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", | ||||
| @@ -8926,6 +8967,11 @@ | ||||
|         "node": "^14.15.0 || ^16.10.0 || >=18.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/export-from-json": { | ||||
|       "version": "1.7.3", | ||||
|       "resolved": "https://registry.npmjs.org/export-from-json/-/export-from-json-1.7.3.tgz", | ||||
|       "integrity": "sha512-Xg0L0saYz+CBz2MnaZvSEAHr17hWtHAfFWXw/frllG9t6aijuQukiU40ElOeM9nDTrtQPhLJMLN0q8lo897FYg==" | ||||
|     }, | ||||
|     "node_modules/express": { | ||||
|       "version": "4.18.2", | ||||
|       "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", | ||||
| @@ -9423,6 +9469,14 @@ | ||||
|         "node": ">= 0.6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/frac": { | ||||
|       "version": "1.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", | ||||
|       "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", | ||||
|       "engines": { | ||||
|         "node": ">=0.8" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/fraction.js": { | ||||
|       "version": "4.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", | ||||
| @@ -16997,6 +17051,17 @@ | ||||
|       "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/ssf": { | ||||
|       "version": "0.11.2", | ||||
|       "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", | ||||
|       "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", | ||||
|       "dependencies": { | ||||
|         "frac": "~1.1.2" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=0.8" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/stable": { | ||||
|       "version": "0.1.8", | ||||
|       "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", | ||||
| @@ -18746,6 +18811,22 @@ | ||||
|         "url": "https://github.com/sponsors/ljharb" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/wmf": { | ||||
|       "version": "1.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", | ||||
|       "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", | ||||
|       "engines": { | ||||
|         "node": ">=0.8" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/word": { | ||||
|       "version": "0.3.0", | ||||
|       "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", | ||||
|       "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", | ||||
|       "engines": { | ||||
|         "node": ">=0.8" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/word-wrap": { | ||||
|       "version": "1.2.3", | ||||
|       "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", | ||||
| @@ -19133,6 +19214,26 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/xlsx": { | ||||
|       "version": "0.18.5", | ||||
|       "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", | ||||
|       "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", | ||||
|       "dependencies": { | ||||
|         "adler-32": "~1.3.0", | ||||
|         "cfb": "~1.2.1", | ||||
|         "codepage": "~1.15.0", | ||||
|         "crc-32": "~1.2.1", | ||||
|         "ssf": "~0.11.2", | ||||
|         "wmf": "~1.0.1", | ||||
|         "word": "~0.3.0" | ||||
|       }, | ||||
|       "bin": { | ||||
|         "xlsx": "bin/xlsx.njs" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=0.8" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/xml-name-validator": { | ||||
|       "version": "3.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", | ||||
|   | ||||
| @@ -14,6 +14,7 @@ | ||||
|     "@testing-library/user-event": "^13.5.0", | ||||
|     "axios": "^0.24.0", | ||||
|     "downshift": "^6.1.12", | ||||
|     "export-from-json": "^1.7.3", | ||||
|     "lodash": "^4.17.21", | ||||
|     "luxon": "^2.5.2", | ||||
|     "markdown-to-jsx": "^7.1.7", | ||||
| @@ -21,17 +22,18 @@ | ||||
|     "react-dom": "^17.0.2", | ||||
|     "react-router-dom": "^6.2.1", | ||||
|     "react-sticky-el": "^2.0.9", | ||||
|     "web-vitals": "^2.1.3" | ||||
|     "web-vitals": "^2.1.3", | ||||
|     "xlsx": "^0.18.5" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/plugin-proposal-private-property-in-object": "^7.16.7", | ||||
|     "@playwright/test": "^1.28.1", | ||||
|     "eslint": "^8.23.1", | ||||
|     "eslint-config-prettier": "^8.5.0", | ||||
|     "eslint-plugin-prettier": "^4.2.1", | ||||
|     "eslint-plugin-react": "^7.31.8", | ||||
|     "prettier": "^2.7.1", | ||||
|     "react-scripts": "^5.0.1", | ||||
|     "@babel/plugin-proposal-private-property-in-object": "^7.16.7" | ||||
|     "react-scripts": "^5.0.1" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "start": "react-scripts start", | ||||
|   | ||||
| @@ -21,7 +21,7 @@ const StateFilterCardWrapper = () => { | ||||
| describe('Filters components', () => { | ||||
|   it('renders the filters cards', async () => { | ||||
|     render(<StateFilterCardWrapper />); | ||||
|     expect(screen.getAllByRole('checkbox')).toHaveLength(2); | ||||
|     expect(screen.getAllByRole('checkbox')).toHaveLength(3); | ||||
|  | ||||
|     const checkbox = screen.getAllByRole('checkbox'); | ||||
|     expect(checkbox[0]).not.toBeChecked(); | ||||
|   | ||||
| @@ -22,6 +22,7 @@ const mockedTagsData = [ | ||||
|   { | ||||
|     tag: 'latest', | ||||
|     vendor: 'test1', | ||||
|     isDeletable: true, | ||||
|     manifests: [ | ||||
|       { | ||||
|         lastUpdated: '2022-07-19T18:06:18.818788283Z', | ||||
| @@ -37,6 +38,7 @@ const mockedTagsData = [ | ||||
|   { | ||||
|     tag: 'bullseye', | ||||
|     vendor: 'test1', | ||||
|     isDeletable: true, | ||||
|     manifests: [ | ||||
|       { | ||||
|         digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559', | ||||
| @@ -52,6 +54,7 @@ const mockedTagsData = [ | ||||
|   { | ||||
|     tag: '1.5.2', | ||||
|     vendor: 'test1', | ||||
|     isDeletable: true, | ||||
|     manifests: [ | ||||
|       { | ||||
|         lastUpdated: '2022-07-19T18:06:18.818788283Z', | ||||
| @@ -76,6 +79,18 @@ describe('Tags component', () => { | ||||
|     await waitFor(() => expect(screen.queryByText(/OS\/ARCH/i)).not.toBeInTheDocument()); | ||||
|   }); | ||||
|  | ||||
|   it('should see delete tag button and its dialog', async () => { | ||||
|     render(<TagsThemeWrapper />); | ||||
|     const deleteBtn = await screen.findAllByTestId('DeleteIcon'); | ||||
|     fireEvent.click(deleteBtn[0]); | ||||
|     expect(await screen.findByTestId('delete-dialog')).toBeInTheDocument(); | ||||
|     const confirmBtn = await screen.findByTestId('confirm-delete'); | ||||
|     expect(confirmBtn).toBeInTheDocument(); | ||||
|     fireEvent.click(confirmBtn); | ||||
|     expect(await screen.findByTestId('confirm-delete')).toBeInTheDocument(); | ||||
|     expect(await screen.findByTestId('cancel-delete')).toBeInTheDocument(); | ||||
|   }); | ||||
|  | ||||
|   it('should navigate to tag page details when tag is clicked', async () => { | ||||
|     render(<TagsThemeWrapper />); | ||||
|     const tagLink = await screen.findByText('latest'); | ||||
|   | ||||
| @@ -6,6 +6,8 @@ import VulnerabilitiesDetails from 'components/Tag/Tabs/VulnerabilitiesDetails'; | ||||
| import React from 'react'; | ||||
| import { MemoryRouter } from 'react-router-dom'; | ||||
|  | ||||
| jest.mock('xlsx'); | ||||
|  | ||||
| const StateVulnerabilitiesWrapper = () => { | ||||
|   return ( | ||||
|     <MockThemeProvider> | ||||
| @@ -558,6 +560,32 @@ describe('Vulnerabilties page', () => { | ||||
|     expect(await screen.findByText('latest')).toBeInTheDocument(); | ||||
|   }); | ||||
|  | ||||
|   it('should allow export of vulnerabilities list', async () => { | ||||
|     const xlsxMock = jest.createMockFromModule('xlsx'); | ||||
|     xlsxMock.writeFile = jest.fn(); | ||||
|  | ||||
|     jest | ||||
|       .spyOn(api, 'get') | ||||
|       .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }) | ||||
|       .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); | ||||
|     const downloadBtn = await screen.findAllByTestId('DownloadIcon'); | ||||
|     fireEvent.click(downloadBtn[0]); | ||||
|     expect(await screen.findByTestId('export-csv-menuItem')).toBeInTheDocument(); | ||||
|     expect(await screen.findByTestId('export-excel-menuItem')).toBeInTheDocument(); | ||||
|     const exportAsCSVBtn = screen.getByText(/CSV/i); | ||||
|     expect(exportAsCSVBtn).toBeInTheDocument(); | ||||
|     global.URL.createObjectURL = jest.fn(); | ||||
|     await fireEvent.click(exportAsCSVBtn); | ||||
|     expect(await screen.findByTestId('export-csv-menuItem')).not.toBeInTheDocument(); | ||||
|     fireEvent.click(downloadBtn[0]); | ||||
|     const exportAsExcelBtn = screen.getByText(/MS Excel/i); | ||||
|     expect(exportAsExcelBtn).toBeInTheDocument(); | ||||
|     await fireEvent.click(exportAsExcelBtn); | ||||
|     expect(await screen.findByTestId('export-excel-menuItem')).not.toBeInTheDocument(); | ||||
|   }); | ||||
|  | ||||
|   it('should handle fixed CVE query errors', async () => { | ||||
|     jest | ||||
|       .spyOn(api, 'get') | ||||
|   | ||||
| @@ -81,12 +81,13 @@ const endpoints = { | ||||
|   authConfig: `/v2/_zot/ext/mgmt`, | ||||
|   openidAuth: `/zot/auth/login`, | ||||
|   logout: `/zot/auth/logout`, | ||||
|   deleteImage: (name, tag) => `/v2/${name}/manifests/${tag}`, | ||||
|   repoList: ({ pageNumber = 1, pageSize = 15 } = {}) => | ||||
|     `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage: {limit:${pageSize} offset:${ | ||||
|       (pageNumber - 1) * pageSize | ||||
|     }}){Results {Name LastUpdated Size Platforms {Os Arch}  NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description  Licenses Title Source IsSigned SignatureInfo { Tool IsTrusted Author } Documentation Vendor Labels} IsStarred IsBookmarked StarCount DownloadCount}}}`, | ||||
|   detailedRepoInfo: (name) => | ||||
|     `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch} Size} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors IsStarred IsBookmarked NewestImage {RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Vendor Title Documentation DownloadCount Source Description Licenses}}}}`, | ||||
|     `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch} Size} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor IsDeletable } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors IsStarred IsBookmarked NewestImage {RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Vendor Title Documentation DownloadCount Source Description Licenses}}}}`, | ||||
|   detailedImageInfo: (name, tag) => | ||||
|     `/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count}  Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`, | ||||
|   vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }, searchTerm = '') => { | ||||
| @@ -98,6 +99,8 @@ const endpoints = { | ||||
|     } | ||||
|     return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`; | ||||
|   }, | ||||
|   allVulnerabilitiesForRepo: (name) => | ||||
|     `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}}}}`, | ||||
|   imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }, filter = {}) => { | ||||
|     let filterParam = ''; | ||||
|     if (filter.Os || filter.Arch) { | ||||
|   | ||||
| @@ -149,14 +149,14 @@ function Header({ setSearchCurrentValue = () => {} }) { | ||||
|               </Link> | ||||
|             </Grid> | ||||
|             <Grid item className={classes.headerLinkContainer}> | ||||
|               <a className={classes.link} href="https://zotregistry.io" target="_blank" rel="noreferrer"> | ||||
|               <a className={classes.link} href="https://zotregistry.dev" target="_blank" rel="noreferrer"> | ||||
|                 Product | ||||
|               </a> | ||||
|             </Grid> | ||||
|             <Grid item className={classes.headerLinkContainer}> | ||||
|               <a | ||||
|                 className={classes.link} | ||||
|                 href="https://zotregistry.io/v1.4.3/general/concepts/" | ||||
|                 href="https://zotregistry.dev/v2.0.0/general/concepts/" | ||||
|                 target="_blank" | ||||
|                 rel="noreferrer" | ||||
|               > | ||||
|   | ||||
| @@ -197,6 +197,10 @@ function RepoDetails() { | ||||
|     }; | ||||
|   }, [name]); | ||||
|  | ||||
|   const handleDeleteTag = (removed) => { | ||||
|     setTags((prevState) => prevState.filter((tag) => tag.tag !== removed)); | ||||
|   }; | ||||
|  | ||||
|   const handlePlatformChipClick = (event) => { | ||||
|     const { textContent } = event.target; | ||||
|     event.stopPropagation(); | ||||
| @@ -223,7 +227,7 @@ function RepoDetails() { | ||||
|  | ||||
|   const handleBookmarkClick = () => { | ||||
|     api.put(`${host()}${endpoints.bookmarkToggle(name)}`, abortController.signal).then((response) => { | ||||
|       if (response.status === 200) { | ||||
|       if (response && response.status === 200) { | ||||
|         setRepoDetailData((prevState) => ({ | ||||
|           ...prevState, | ||||
|           isBookmarked: !prevState.isBookmarked | ||||
| @@ -341,7 +345,7 @@ function RepoDetails() { | ||||
|           <Grid item xs={12} md={8} className={classes.tags}> | ||||
|             <Card className={classes.cardRoot}> | ||||
|               <CardContent className={classes.tagsContent}> | ||||
|                 <Tags tags={tags} /> | ||||
|                 <Tags tags={tags} repoName={name} onTagDelete={handleDeleteTag} /> | ||||
|               </CardContent> | ||||
|             </Card> | ||||
|           </Grid> | ||||
|   | ||||
| @@ -43,7 +43,7 @@ const useStyles = makeStyles(() => ({ | ||||
|  | ||||
| export default function Tags(props) { | ||||
|   const classes = useStyles(); | ||||
|   const { tags } = props; | ||||
|   const { tags, repoName, onTagDelete } = props; | ||||
|   const [tagsFilter, setTagsFilter] = useState(''); | ||||
|   const [sortFilter, setSortFilter] = useState(tagsSortByCriteria.updateTimeDesc.value); | ||||
|  | ||||
| @@ -63,6 +63,9 @@ export default function Tags(props) { | ||||
|             lastUpdated={tag.lastUpdated} | ||||
|             vendor={tag.vendor} | ||||
|             manifests={tag.manifests} | ||||
|             repo={repoName} | ||||
|             onTagDelete={onTagDelete} | ||||
|             isDeletable={tag.isDeletable} | ||||
|           /> | ||||
|         ); | ||||
|       }) | ||||
|   | ||||
							
								
								
									
										54
									
								
								src/components/Shared/DeleteTag.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/components/Shared/DeleteTag.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| import React, { useState } from 'react'; | ||||
| import { IconButton } from '@mui/material'; | ||||
| import DeleteIcon from '@mui/icons-material/Delete'; | ||||
|  | ||||
| // utility | ||||
| import { api, endpoints } from '../../api'; | ||||
|  | ||||
| // components | ||||
| import DeleteTagConfirmDialog from 'components/Shared/DeleteTagConfirmDialog'; | ||||
| import { host } from '../../host'; | ||||
|  | ||||
| export default function DeleteTag(props) { | ||||
|   const { repo, tag, onTagDelete } = props; | ||||
|   const [open, setOpen] = useState(false); | ||||
|  | ||||
|   const handleClickOpen = () => { | ||||
|     setOpen(true); | ||||
|   }; | ||||
|  | ||||
|   const handleClose = () => { | ||||
|     setOpen(false); | ||||
|   }; | ||||
|  | ||||
|   const deleteTag = (repo, tag) => { | ||||
|     api | ||||
|       .delete(`${host()}${endpoints.deleteImage(repo, tag)}`) | ||||
|       .then((response) => { | ||||
|         if (response && response.status == 202) { | ||||
|           onTagDelete(tag); | ||||
|         } | ||||
|       }) | ||||
|       .catch((err) => { | ||||
|         console.error(err); | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   const onConfirm = () => { | ||||
|     deleteTag(repo, tag); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <React.Fragment> | ||||
|       <IconButton onClick={handleClickOpen}> | ||||
|         <DeleteIcon /> | ||||
|       </IconButton> | ||||
|       <DeleteTagConfirmDialog | ||||
|         onClose={handleClose} | ||||
|         open={open} | ||||
|         title={`Permanently delete image ${repo}:${tag}?`} | ||||
|         onConfirm={onConfirm} | ||||
|       /> | ||||
|     </React.Fragment> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										30
									
								
								src/components/Shared/DeleteTagConfirmDialog.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/components/Shared/DeleteTagConfirmDialog.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import React from 'react'; | ||||
|  | ||||
| // components | ||||
| import { Button, Dialog, DialogTitle, DialogActions } from '@mui/material'; | ||||
|  | ||||
| export default function DeleteTagConfirmDialog(props) { | ||||
|   const { onClose, open, title, onConfirm } = props; | ||||
|  | ||||
|   return ( | ||||
|     <Dialog data-testid="delete-dialog" onClose={onClose} open={open} color="primary"> | ||||
|       <DialogTitle> {title} </DialogTitle> | ||||
|       <DialogActions style={{ justifyContent: 'center' }}> | ||||
|         <Button data-testid="cancel-delete" variant="contained" onClick={onClose} color="primary"> | ||||
|           Cancel | ||||
|         </Button> | ||||
|         <Button | ||||
|           data-testid="confirm-delete" | ||||
|           color="error" | ||||
|           variant="contained" | ||||
|           onClick={() => { | ||||
|             onConfirm(); | ||||
|             onClose(); | ||||
|           }} | ||||
|         > | ||||
|           Delete | ||||
|         </Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
| @@ -6,6 +6,7 @@ import { Markdown } from 'utilities/MarkdowntojsxWrapper'; | ||||
| import transform from 'utilities/transform'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material'; | ||||
| import DeleteTag from 'components/Shared/DeleteTag'; | ||||
|  | ||||
| const useStyles = makeStyles((theme) => ({ | ||||
|   card: { | ||||
| @@ -78,9 +79,9 @@ const useStyles = makeStyles((theme) => ({ | ||||
| })); | ||||
|  | ||||
| export default function TagCard(props) { | ||||
|   const { repoName, tag, lastUpdated, vendor, manifests } = props; | ||||
|  | ||||
|   const { repoName, tag, lastUpdated, vendor, manifests, repo, onTagDelete, isDeletable } = props; | ||||
|   const [open, setOpen] = useState(false); | ||||
|  | ||||
|   const classes = useStyles(); | ||||
|  | ||||
|   const lastDate = lastUpdated | ||||
| @@ -99,9 +100,12 @@ export default function TagCard(props) { | ||||
|   return ( | ||||
|     <Card className={classes.card} raised> | ||||
|       <CardContent className={classes.content}> | ||||
|         <Typography variant="body1" align="left" className={classes.tagHeading}> | ||||
|           Tag | ||||
|         </Typography> | ||||
|         <Stack direction="row" spacing={2} justifyContent="space-between"> | ||||
|           <Typography variant="body1" align="left" className={classes.tagHeading}> | ||||
|             Tag | ||||
|           </Typography> | ||||
|           {isDeletable && <DeleteTag repo={repo} tag={tag} onTagDelete={onTagDelete} />} | ||||
|         </Stack> | ||||
|         <Typography variant="body1" align="left" className={classes.tagName} onClick={() => goToTags()}> | ||||
|           {repoName && `${repoName}:`} | ||||
|           {tag} | ||||
|   | ||||
| @@ -4,14 +4,28 @@ import React, { useEffect, useMemo, useState, useRef } from 'react'; | ||||
| import { api, endpoints } from '../../../api'; | ||||
|  | ||||
| // components | ||||
| import { Stack, Typography, InputBase } from '@mui/material'; | ||||
| import { | ||||
|   IconButton, | ||||
|   Stack, | ||||
|   Typography, | ||||
|   InputBase, | ||||
|   Menu, | ||||
|   MenuItem, | ||||
|   Divider, | ||||
|   Snackbar, | ||||
|   CircularProgress | ||||
| } from '@mui/material'; | ||||
| import makeStyles from '@mui/styles/makeStyles'; | ||||
| import { host } from '../../../host'; | ||||
| import { debounce, isEmpty } from 'lodash'; | ||||
| import Loading from '../../Shared/Loading'; | ||||
| import { mapCVEInfo } from 'utilities/objectModels'; | ||||
| import { mapCVEInfo, mapAllCVEInfo } from 'utilities/objectModels'; | ||||
| import { EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants'; | ||||
| import SearchIcon from '@mui/icons-material/Search'; | ||||
| import DownloadIcon from '@mui/icons-material/Download'; | ||||
|  | ||||
| import * as XLSX from 'xlsx'; | ||||
| import exportFromJSON from 'export-from-json'; | ||||
|  | ||||
| import VulnerabilitiyCard from '../../Shared/VulnerabilityCard'; | ||||
|  | ||||
| @@ -40,6 +54,13 @@ const useStyles = makeStyles((theme) => ({ | ||||
|     fontSize: '1.4rem', | ||||
|     fontWeight: '600' | ||||
|   }, | ||||
|   vulnerabilities: { | ||||
|     position: 'relative', | ||||
|     maxWidth: '100%', | ||||
|     flexDirection: 'row', | ||||
|     alignItems: 'center', | ||||
|     justifyContent: 'space-between' | ||||
|   }, | ||||
|   search: { | ||||
|     position: 'relative', | ||||
|     maxWidth: '100%', | ||||
| @@ -65,13 +86,25 @@ const useStyles = makeStyles((theme) => ({ | ||||
|     '&::placeholder': { | ||||
|       opacity: '1' | ||||
|     } | ||||
|   }, | ||||
|   export: { | ||||
|     alignContent: 'right' | ||||
|   }, | ||||
|   popper: { | ||||
|     width: '100%', | ||||
|     overflow: 'hidden', | ||||
|     padding: '0.3rem', | ||||
|     display: 'flex', | ||||
|     justifyContent: 'center' | ||||
|   } | ||||
| })); | ||||
|  | ||||
| function VulnerabilitiesDetails(props) { | ||||
|   const classes = useStyles(); | ||||
|   const [cveData, setCveData] = useState([]); | ||||
|   const [allCveData, setAllCveData] = useState([]); | ||||
|   const [isLoading, setIsLoading] = useState(true); | ||||
|   const [isLoadingAllCve, setIsLoadingAllCve] = useState(true); | ||||
|   const abortController = useMemo(() => new AbortController(), []); | ||||
|   const { name, tag, digest, platform } = props; | ||||
|  | ||||
| @@ -81,6 +114,9 @@ function VulnerabilitiesDetails(props) { | ||||
|   const [isEndOfList, setIsEndOfList] = useState(false); | ||||
|   const listBottom = useRef(null); | ||||
|  | ||||
|   const [anchorExport, setAnchorExport] = useState(null); | ||||
|   const openExport = Boolean(anchorExport); | ||||
|  | ||||
|   const getCVERequestName = () => { | ||||
|     return digest !== '' ? `${name}@${digest}` : `${name}:${tag}`; | ||||
|   }; | ||||
| @@ -114,6 +150,24 @@ function VulnerabilitiesDetails(props) { | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   const getAllCVEs = () => { | ||||
|     api | ||||
|       .get(`${host()}${endpoints.allVulnerabilitiesForRepo(getCVERequestName())}`, abortController.signal) | ||||
|       .then((response) => { | ||||
|         if (response.data && response.data.data) { | ||||
|           const cveInfo = response.data.data.CVEListForImage?.CVEList; | ||||
|           const cveListData = mapAllCVEInfo(cveInfo); | ||||
|           setAllCveData(cveListData); | ||||
|         } | ||||
|         setIsLoadingAllCve(false); | ||||
|       }) | ||||
|       .catch((e) => { | ||||
|         console.error(e); | ||||
|         setAllCveData([]); | ||||
|         setIsLoadingAllCve(false); | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   const resetPagination = () => { | ||||
|     setIsLoading(true); | ||||
|     setIsEndOfList(false); | ||||
| @@ -124,11 +178,39 @@ function VulnerabilitiesDetails(props) { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleOnExportExcel = () => { | ||||
|     const wb = XLSX.utils.book_new(), | ||||
|       ws = XLSX.utils.json_to_sheet(allCveData); | ||||
|  | ||||
|     XLSX.utils.book_append_sheet(wb, ws, name + '_' + tag); | ||||
|  | ||||
|     XLSX.writeFile(wb, `${name}:${tag}-vulnerabilities.xlsx`); | ||||
|  | ||||
|     handleCloseExport(); | ||||
|   }; | ||||
|  | ||||
|   const handleOnExportCSV = () => { | ||||
|     const fileName = `${name}:${tag}-vulnerabilities`; | ||||
|     const exportType = exportFromJSON.types.csv; | ||||
|  | ||||
|     exportFromJSON({ data: allCveData, fileName, exportType }); | ||||
|  | ||||
|     handleCloseExport(); | ||||
|   }; | ||||
|  | ||||
|   const handleCveFilterChange = (e) => { | ||||
|     const { value } = e.target; | ||||
|     setCveFilter(value); | ||||
|   }; | ||||
|  | ||||
|   const handleClickExport = (event) => { | ||||
|     setAnchorExport(event.currentTarget); | ||||
|   }; | ||||
|  | ||||
|   const handleCloseExport = () => { | ||||
|     setAnchorExport(null); | ||||
|   }; | ||||
|  | ||||
|   const debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300)); | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -172,6 +254,12 @@ function VulnerabilitiesDetails(props) { | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (openExport && isEmpty(allCveData)) { | ||||
|       getAllCVEs(); | ||||
|     } | ||||
|   }, [openExport]); | ||||
|  | ||||
|   const renderCVEs = () => { | ||||
|     return !isEmpty(cveData) ? ( | ||||
|       cveData.map((cve, index) => { | ||||
| @@ -194,9 +282,53 @@ function VulnerabilitiesDetails(props) { | ||||
|  | ||||
|   return ( | ||||
|     <Stack direction="column" spacing="1rem" data-testid="vulnerability-container"> | ||||
|       <Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}> | ||||
|         Vulnerabilities | ||||
|       </Typography> | ||||
|       <Stack className={classes.vulnerabilities}> | ||||
|         <Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}> | ||||
|           Vulnerabilities | ||||
|         </Typography> | ||||
|         <IconButton disableRipple onClick={handleClickExport} className={classes.export}> | ||||
|           <DownloadIcon /> | ||||
|         </IconButton> | ||||
|         <Snackbar | ||||
|           open={openExport && isLoadingAllCve} | ||||
|           message="Getting your data ready for export" | ||||
|           action={<CircularProgress size="2rem" sx={{ color: '#FFFFFF' }} />} | ||||
|         /> | ||||
|         <Menu | ||||
|           anchorEl={anchorExport} | ||||
|           open={openExport} | ||||
|           onClose={handleCloseExport} | ||||
|           data-testid="export-dropdown" | ||||
|           anchorOrigin={{ | ||||
|             vertical: 'bottom', | ||||
|             horizontal: 'center' | ||||
|           }} | ||||
|           transformOrigin={{ | ||||
|             vertical: 'top', | ||||
|             horizontal: 'center' | ||||
|           }} | ||||
|         > | ||||
|           <MenuItem | ||||
|             onClick={handleOnExportCSV} | ||||
|             disableRipple | ||||
|             disabled={isLoadingAllCve} | ||||
|             className={classes.popper} | ||||
|             data-testid="export-csv-menuItem" | ||||
|           > | ||||
|             CSV | ||||
|           </MenuItem> | ||||
|           <Divider sx={{ my: 0.5 }} /> | ||||
|           <MenuItem | ||||
|             onClick={handleOnExportExcel} | ||||
|             disableRipple | ||||
|             disabled={isLoadingAllCve} | ||||
|             className={classes.popper} | ||||
|             data-testid="export-excel-menuItem" | ||||
|           > | ||||
|             MS Excel | ||||
|           </MenuItem> | ||||
|         </Menu> | ||||
|       </Stack> | ||||
|       <Stack className={classes.search}> | ||||
|         <InputBase | ||||
|           placeholder={'Search'} | ||||
|   | ||||
| @@ -6,6 +6,10 @@ const osFilters = [ | ||||
|   { | ||||
|     label: 'linux', | ||||
|     value: 'linux' | ||||
|   }, | ||||
|   { | ||||
|     label: 'freebsd', | ||||
|     value: 'freebsd' | ||||
|   } | ||||
| ]; | ||||
|  | ||||
|   | ||||
| @@ -69,6 +69,7 @@ const mapToImage = (responseImage) => { | ||||
|     authors: responseImage.Authors, | ||||
|     vulnerabiltySeverity: responseImage.Vulnerabilities?.MaxSeverity, | ||||
|     vulnerabilityCount: responseImage.Vulnerabilities?.Count, | ||||
|     isDeletable: responseImage.IsDeletable, | ||||
|     // frontend only prop to increase interop with Repo objects and code reusability | ||||
|     name: `${responseImage.RepoName}:${responseImage.Tag}` | ||||
|   }; | ||||
| @@ -101,6 +102,24 @@ const mapCVEInfo = (cveInfo) => { | ||||
|   return cveList; | ||||
| }; | ||||
|  | ||||
| const mapAllCVEInfo = (cveInfo) => { | ||||
|   const cveList = cveInfo.flatMap((cve) => { | ||||
|     return cve.PackageList.map((packageInfo) => { | ||||
|       return { | ||||
|         id: cve.Id, | ||||
|         severity: cve.Severity, | ||||
|         title: cve.Title, | ||||
|         description: cve.Description, | ||||
|         reference: cve.Reference, | ||||
|         packageName: packageInfo.Name, | ||||
|         packageInstalledVersion: packageInfo.InstalledVersion, | ||||
|         packageFixedVersion: packageInfo.FixedVersion | ||||
|       }; | ||||
|     }); | ||||
|   }); | ||||
|   return cveList; | ||||
| }; | ||||
|  | ||||
| const mapSignatureInfo = (signatureInfo) => { | ||||
|   return signatureInfo | ||||
|     ? { | ||||
| @@ -123,4 +142,4 @@ const mapReferrer = (referrer) => ({ | ||||
|   annotations: referrer.Annotations?.map((annotation) => ({ key: annotation.Key, value: annotation.Value })) | ||||
| }); | ||||
|  | ||||
| export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapReferrer, mapToManifest }; | ||||
| export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapAllCVEInfo, mapReferrer, mapToManifest }; | ||||
|   | ||||
| @@ -76,8 +76,14 @@ test.describe('explore page test', () => { | ||||
|  | ||||
|     await expect(exploreFirst).toBeVisible({ timeout: 250000 }); | ||||
|  | ||||
|     const windowsFilter = page.getByRole('checkbox', { name: 'windows' }); | ||||
|     await linuxFilter.uncheck(); | ||||
|     await page.getByRole('checkbox', { name: 'windows' }).check(); | ||||
|     await windowsFilter.check(); | ||||
|     await expect(exploreFirst).not.toBeVisible({ timeout: 250000 }); | ||||
|  | ||||
|     const freebsdFilter = page.getByRole('checkbox', { name: 'freebsd' }); | ||||
|     await windowsFilter.uncheck(); | ||||
|     await freebsdFilter.check(); | ||||
|     await expect(exploreFirst).not.toBeVisible({ timeout: 250000 }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -19,7 +19,7 @@ const pageSizes = { | ||||
| const endpoints = { | ||||
|   repoList: `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage:%20{limit:15%20offset:0}){Results%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20%20Licenses%20Logo%20Title%20Source%20IsSigned%20Documentation%20Vendor%20Labels}%20StarCount%20DownloadCount}}}`, | ||||
|   detailedRepoInfo: (name) => | ||||
|     `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:%22${name}%22){Images%20{Manifests%20{Digest%20Platform%20{Os%20Arch}%20Size}%20Vulnerabilities%20{MaxSeverity%20Count}%20Tag%20LastUpdated%20Vendor%20}%20Summary%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20Vendors%20NewestImage%20{RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20Manifests%20{Digest}%20Tag%20Title%20Documentation%20DownloadCount%20Source%20Description%20Licenses}}}}`, | ||||
|     `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:%22${name}%22){Images%20{Manifests%20{Digest%20Platform%20{Os%20Arch}%20Size}%20Vulnerabilities%20{MaxSeverity%20Count}%20Tag%20LastUpdated%20Vendor%20IsDeletable%20}%20Summary%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20Vendors%20NewestImage%20{RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20Manifests%20{Digest}%20Tag%20Title%20Documentation%20DownloadCount%20Source%20Description%20Licenses}}}}`, | ||||
|   globalSearch: (searchTerm, sortCriteria, pageNumber = 1, pageSize = 10) => | ||||
|     `/v2/_zot/ext/search?query={GlobalSearch(query:%22${searchTerm}%22,%20requestedPage:%20{limit:${pageSize}%20offset:${ | ||||
|       10 * (pageNumber - 1) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user