diff --git a/playwright.config.js b/playwright.config.js
index c88309c9..b03b9c48 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -7,7 +7,6 @@ const { devices } = require('@playwright/test');
*/
// require('dotenv').config();
-
/**
* @see https://playwright.dev/docs/test-configuration
* @type {import('@playwright/test').PlaywrightTestConfig}
@@ -53,7 +52,7 @@ const config = {
use: {
...devices['Desktop Chrome'],
ignoreHTTPSErrors: true
- },
+ }
},
{
@@ -61,7 +60,7 @@ const config = {
use: {
...devices['Desktop Firefox'],
ignoreHTTPSErrors: true
- },
+ }
},
{
@@ -69,8 +68,8 @@ const config = {
use: {
...devices['Desktop Safari'],
ignoreHTTPSErrors: true
- },
- },
+ }
+ }
/* Test against mobile viewports. */
// {
@@ -102,7 +101,7 @@ const config = {
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
- outputDir: 'test-results/',
+ outputDir: 'test-results/'
/* Run your local dev server before starting the tests */
// webServer: {
diff --git a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js
index 5c980ca5..3f9805c7 100644
--- a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js
+++ b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js
@@ -28,7 +28,7 @@ const mockCVEList = {
LowCount: 1,
MediumCount: 1,
HighCount: 1,
- CriticalCount: 1,
+ CriticalCount: 1
},
CVEList: [
{
diff --git a/src/api.js b/src/api.js
index 0589e992..9589fd93 100644
--- a/src/api.js
+++ b/src/api.js
@@ -104,6 +104,19 @@ const endpoints = {
},
allVulnerabilitiesForRepo: (name) =>
`/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}}}}`,
+ cveDiffForImages: (minuend = {}, subtrahend = {}, { pageNumber = 1, pageSize = 3 }) => {
+ let imageInput = (img) => {
+ let digest = img.digest ? `, digest: ${img.digest}` : '';
+ let platform = img.platform ? `, platform: {os: ${img.platform.os}, arch: ${img.platform.arch}}` : '';
+ return `{repo: "${img.repo}", tag: "${img.tag}"${digest}${platform}}`;
+ };
+ let query = `/v2/_zot/ext/search?query={CVEDiffListForImages(minuend: ${imageInput(
+ minuend
+ )}, subtrahend: ${imageInput(subtrahend)}, requestedPage: {limit:${pageSize} offset:${
+ (pageNumber - 1) * pageSize
+ }}) {Minuend Subtrahend CVEList{Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}} Summary {Count UnknownCount LowCount MediumCount HighCount CriticalCount} Page {TotalCount ItemCount}}}`;
+ return query;
+ },
imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }, filter = {}) => {
let filterParam = '';
if (filter.Os || filter.Arch) {
@@ -150,6 +163,11 @@ const endpoints = {
const paginationParam = `requestedPage: {limit:${pageSize} offset:${(pageNumber - 1) * pageSize} sortBy:RELEVANCE}`;
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam}) {Images {RepoName Tag}}}`;
},
+ imageSuggestionsWithPlatforms: ({ searchQuery = '""', pageNumber = 1, pageSize = 15 }) => {
+ 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 Manifests {Platform {Os Arch}}}}}`;
+ },
referrers: ({ repo, digest, type = '' }) =>
`/v2/_zot/ext/search?query={Referrers(repo: "${repo}" digest: "${digest}" type: "${type}"){MediaType ArtifactType Size Digest Annotations{Key Value}}}`,
bookmarkToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleBookmark`,
diff --git a/src/components/Header/SearchSuggestion.jsx b/src/components/Header/SearchSuggestion.jsx
index 0bf2f97f..90e5bd8d 100644
--- a/src/components/Header/SearchSuggestion.jsx
+++ b/src/components/Header/SearchSuggestion.jsx
@@ -21,6 +21,14 @@ const useStyles = makeStyles(() => ({
position: 'relative',
zIndex: 1150
},
+ searchContainerUnstretched: {
+ display: 'inline-block',
+ backgroundColor: '#2B3A4E',
+ boxShadow: '0 0.313rem 0.625rem rgba(131, 131, 131, 0.08)',
+ borderRadius: '0.625rem',
+ position: 'relative',
+ zIndex: 1150
+ },
searchContainerFocused: {
backgroundColor: '#FFFFFF'
},
@@ -107,7 +115,7 @@ const useStyles = makeStyles(() => ({
}
}));
-function SearchSuggestion({ setSearchCurrentValue = () => {} }) {
+function SearchSuggestion({ setSearchCurrentValue = () => {}, stretch = true }) {
const [searchQuery, setSearchQuery] = useState('');
const [suggestionData, setSuggestionData] = useState([]);
const [queryParams] = useSearchParams();
@@ -269,7 +277,10 @@ function SearchSuggestion({ setSearchCurrentValue = () => {} }) {
};
return (
-
+
({
+ imagesInputBox: {
+ padding: '0.5rem',
+ marginBottom: '0.5rem'
+ },
+ input: {
+ color: '#464141',
+ '&::placeholder': {
+ opacity: '1'
+ }
+ },
+ searchInputBase: {
+ width: '90%',
+ paddingLeft: '1rem',
+ border: '1px solid black',
+ height: 40,
+ color: 'rgba(0, 0, 0, 0.6)'
+ },
+ compareImagesPopup: {
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ backgroundColor: 'white',
+ border: '2px solid #000',
+ padding: '1rem'
+ },
+ compareButton: {
+ paddingLeft: '0.5rem'
+ }
+}));
+
+function CompareImages({ name, tag, platform }) {
+ const classes = useStyles();
+ const [open, setOpen] = useState(false);
+ const [minuend, setMinuend] = useState('');
+ const [subtrahend, setSubtrahend] = useState('');
+ const [cveData, setCVEData] = useState([]);
+ const handleOpen = () => setOpen(true);
+ const handleClose = () => setOpen(false);
+ const handleMinuendInput = (value) => {
+ setMinuend(value);
+ };
+ const handleSubtrahendInput = (value) => {
+ setSubtrahend(value);
+ };
+ const abortController = useMemo(() => new AbortController(), []);
+
+ const imageCVECompare = (minuend, subtrahend) => {
+ api
+ .get(
+ `${host()}${endpoints.cveDiffForImages(minuend, subtrahend, { pageNumber: 1, pageSize: 9 })}`,
+ abortController.signal
+ )
+ .then((diffResponse) => {
+ const cveListData = mapCVEInfo(diffResponse.data.data.CVEDiffListForImages.CVEList);
+ setCVEData(cveListData);
+ })
+ .catch((e) => {
+ console.error(e);
+ });
+ };
+
+ const getImageRefComponents = (image) => {
+ if (image.includes('@')) {
+ let components = image.split('@');
+ return [components[0], '', components[1]];
+ } else if (image.includes(':')) {
+ let components = image.split(':');
+ return [components[0], components[1], ''];
+ }
+
+ return [image, '', ''];
+ };
+
+ const handleCompare = () => {
+ let [minuendRepo, minuendTag] = getImageRefComponents(minuend);
+ let [subtrahendRepo, subtrahendTag] = getImageRefComponents(subtrahend);
+ imageCVECompare({ repo: minuendRepo, tag: minuendTag }, { repo: subtrahendRepo, tag: subtrahendTag });
+ };
+
+ const renderCVEs = () => {
+ return !isEmpty(cveData) ? (
+ cveData.map((cve, index) => {
+ return ;
+ })
+ ) : (
+ <>>
+ );
+ };
+
+ return (
+
+
+
+
+
+ Compare the vulnerabilities of 2 images
+
+
+
+
+
+
+
+
+
+ {renderCVEs()}
+
+
+
+
+ );
+}
+
+export default CompareImages;
diff --git a/src/components/Tag/Tabs/ImageSelector.jsx b/src/components/Tag/Tabs/ImageSelector.jsx
new file mode 100644
index 00000000..7ef42422
--- /dev/null
+++ b/src/components/Tag/Tabs/ImageSelector.jsx
@@ -0,0 +1,395 @@
+import {
+ Avatar,
+ FormControl,
+ FormHelperText,
+ InputBase,
+ InputLabel,
+ List,
+ ListItem,
+ MenuItem,
+ Select,
+ Stack,
+ Typography
+} from '@mui/material';
+import { makeStyles } from '@mui/styles';
+import PhotoIcon from '@mui/icons-material/Photo';
+import React, { useEffect, useMemo, useState } from 'react';
+import { api, endpoints } from 'api';
+import { host } from 'host';
+import { mapToImage, mapToRepo } from 'utilities/objectModels';
+import { useSearchParams } from 'react-router-dom';
+import { debounce, isEmpty } from 'lodash';
+import { useCombobox } from 'downshift';
+import { HEADER_SEARCH_PAGE_SIZE } from 'utilities/paginationConstants';
+
+const useStyles = makeStyles(() => ({
+ searchContainer: {
+ display: 'inline-block',
+ backgroundColor: '#FFFFFF',
+ boxShadow: '0 0.313rem 0.625rem rgba(131, 131, 131, 0.08)',
+ borderRadius: '0.625rem',
+ position: 'relative',
+ zIndex: 1150
+ },
+ searchContainerFocused: {
+ backgroundColor: '#FFFFFF'
+ },
+ search: {
+ position: 'relative',
+ flexDirection: 'row',
+ boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
+ border: '0.063rem solid #8A96A8',
+ borderRadius: '0.625rem',
+ zIndex: 1155
+ },
+ searchFocused: {
+ border: '0.125rem solid #E0E5EB',
+ backgroundColor: '#FFFFF'
+ },
+ searchFailed: {
+ border: '0.125rem solid #ff0303'
+ },
+ resultsWrapper: {
+ margin: '0',
+ marginTop: '-5%',
+ paddingTop: '5%',
+ position: 'absolute',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '#FFFFFF',
+ boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
+ borderBottomLeftRadius: '0.625rem',
+ borderBottomRightRadius: '0.625rem',
+ // border: '0.125rem solid #E7E7E7',
+ borderTop: 0,
+ width: '100%',
+ overflowY: 'auto',
+ zIndex: 1
+ },
+ resultsWrapperFocused: {
+ backgroundColor: '#FFFFFF'
+ },
+ resultsWrapperHidden: {
+ display: 'none'
+ },
+ input: {
+ marginLeft: 1,
+ width: '90%',
+ paddingLeft: 10,
+ height: '40px',
+ fontSize: '1rem',
+ backgroundColor: '#FFFFFF',
+ borderRadius: '0.625rem',
+ color: '#8A96A8'
+ },
+ inputFocused: {
+ backgroundColor: '#FFFFFF',
+ borderRadius: '0.625rem',
+ color: 'rgba(0, 0, 0, 0.6);'
+ },
+ searchItem: {
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ color: '#000000',
+ height: '2.75rem',
+ padding: '0 5%',
+ cursor: 'pointer'
+ },
+ searchItemIconBg: {
+ backgroundColor: '#FFFFFF',
+ height: '1.5rem',
+ width: '1.5rem',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ overflow: 'hidden'
+ },
+ searchItemIcon: {
+ color: '#0000008A',
+ minHeight: '100%',
+ minWidth: '100%',
+ objectFit: 'fill'
+ }
+}));
+
+function ImageSelector({ setSearchCurrentValue = () => {}, name, tag }) {
+ // digest, selectedPlatform
+ const [inputHelpText, setInputHelpText] = useState('Specify a repo:tag');
+ const [activePlatformSelection, setActivePlatformSelection] = useState(false);
+ const [platformOptions, setPlatformOptions] = useState([]);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [platform, setPlatform] = useState('');
+ const [suggestionData, setSuggestionData] = useState([]);
+ const [queryParams] = useSearchParams();
+ const search = queryParams.get('search') || '';
+ const [isLoading, setIsLoading] = useState(false);
+ const [isFailedSearch, setIsFailedSearch] = useState(false);
+ const [isComponentFocused, setIsComponentFocused] = useState(false);
+ const abortController = useMemo(() => new AbortController(), []);
+
+ const classes = useStyles();
+
+ const handleSuggestionSelected = (event) => {
+ let name = event.selectedItem?.name;
+ if (!name?.includes(':')) {
+ name += ':';
+ setInputHelpText('Specify a :tag');
+ } else {
+ setInputHelpText('Image Selected');
+ setActivePlatformSelection(true);
+ let platforms = getImagePlatforms(event.selectedItem);
+ setPlatformOptions(platforms);
+ }
+ };
+
+ const handleSearchChange = (event) => {
+ const value = event?.inputValue;
+ setSearchQuery(value);
+ // used to lift up the state for pages that need to know the current value of the search input (currently only Explore) not used in other cases
+ // one way binding, other components shouldn't set the value of the search input, but using this prop can read it
+ setSearchCurrentValue(value);
+ setIsFailedSearch(false);
+ setIsLoading(true);
+ setSuggestionData([]);
+ if (value === '') {
+ setInputHelpText('Specify a repo:tag');
+ }
+ };
+
+ const handleSearch = () => {
+ console.log(inputValue ? '' : inputValue);
+ };
+
+ const repoSearch = (value) => {
+ api
+ .get(
+ `${host()}${endpoints.globalSearch({ searchQuery: value, pageNumber: 1, pageSize: HEADER_SEARCH_PAGE_SIZE })}`,
+ abortController.signal
+ )
+ .then((suggestionResponse) => {
+ if (suggestionResponse.data.data.GlobalSearch.Repos) {
+ const suggestionParsedData = suggestionResponse.data.data.GlobalSearch.Repos.map((el) => mapToRepo(el));
+ setSuggestionData(suggestionParsedData);
+ setInputHelpText('Specify a repo:tag');
+ if (suggestionParsedData.length === 1 && suggestionParsedData[0].repo === value) {
+ setInputHelpText('Specify a :tag');
+ } else if (isEmpty(suggestionParsedData)) {
+ setIsFailedSearch(true);
+ setInputHelpText('Repo not found');
+ }
+ }
+ setIsLoading(false);
+ })
+ .catch((e) => {
+ console.error(e);
+ setIsLoading(false);
+ setIsFailedSearch(true);
+ });
+ };
+
+ const getImagePlatforms = (image) => {
+ return image.manifests.map((it) => ({ os: it.platform.Os, arch: it.platform.Arch }));
+ };
+
+ const imageSearch = (value) => {
+ let tag = value.split(':')[1];
+ api
+ .get(
+ `${host()}${endpoints.imageSuggestionsWithPlatforms({ searchQuery: value, pageNumber: 1, pageSize: 9 })}`,
+ abortController.signal
+ )
+ .then((suggestionResponse) => {
+ if (suggestionResponse.data.data.GlobalSearch.Images) {
+ const suggestionParsedData = suggestionResponse.data.data.GlobalSearch.Images.map((el) => mapToImage(el));
+ setSuggestionData(suggestionParsedData);
+ setActivePlatformSelection(false);
+ setActivePlatformSelection(false);
+ if (suggestionParsedData.length === 1 && suggestionParsedData[0].tag === tag) {
+ setInputHelpText('Image Selected'); // if the current text matches a valid repo-tag
+ } else if (isEmpty(suggestionParsedData)) {
+ setIsFailedSearch(true);
+ setInputHelpText('Tag not found');
+ } else {
+ setInputHelpText('Specify a :tag');
+ }
+ }
+ setIsLoading(false);
+ })
+ .catch((e) => {
+ console.error(e);
+ setIsLoading(false);
+ setIsFailedSearch(true);
+ });
+ };
+
+ const searchCall = (value) => {
+ if (value !== '') {
+ // if search term inclused the ':' character, search for images, if not, search repos
+ if (value?.includes(':')) {
+ imageSearch(value);
+ } else {
+ repoSearch(value);
+ }
+ }
+ };
+
+ const debounceSuggestions = useMemo(() => {
+ return debounce(searchCall, 300);
+ }, []);
+
+ useEffect(() => {
+ debounceSuggestions(searchQuery);
+ }, [searchQuery]);
+
+ useEffect(() => {
+ return () => {
+ debounceSuggestions.cancel();
+ abortController.abort();
+ };
+ }, []);
+
+ const {
+ // selectedItem,
+ inputValue,
+ getInputProps,
+ getMenuProps,
+ getItemProps,
+ highlightedIndex,
+ getComboboxProps,
+ isOpen,
+ openMenu
+ } = useCombobox({
+ items: suggestionData,
+ onInputValueChange: handleSearchChange,
+ onSelectedItemChange: handleSuggestionSelected,
+ initialInputValue: !isEmpty(searchQuery) ? searchQuery : search,
+ itemToString: (item) => item?.name || item
+ });
+
+ useEffect(() => {
+ setIsComponentFocused(isOpen);
+ }, [isOpen]);
+
+ const renderSuggestions = () => {
+ return suggestionData.map((suggestion, index) => (
+
+
+
+
+
+ {suggestion.name}
+
+
+ ));
+ };
+
+ const renderPlatformOptions = () => {
+ return platformOptions.map((it, index) => (
+
+ ));
+ };
+
+ const renderHelpText = () => {
+ return {inputHelpText};
+ };
+
+ return (
+
+ {renderHelpText()}
+
+ openMenu()}
+ {...getInputProps()}
+ />
+
+ Platform
+
+
+
+
+ {isOpen && suggestionData?.length > 0 && renderSuggestions()}
+ {isOpen && isLoading && !isEmpty(searchQuery) && isEmpty(suggestionData) && (
+ <>
+
+
+ Loading...
+
+
+ >
+ )}
+ {isOpen && isEmpty(searchQuery) && isEmpty(suggestionData) && (
+ <>
+ {}}
+ >
+
+ Use the ':' character to search for tags
+
+
+ >
+ )}
+
+
+ );
+}
+
+export default ImageSelector;
diff --git a/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx b/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx
index 38c1a614..bf3ea1de 100644
--- a/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx
+++ b/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx
@@ -35,6 +35,7 @@ import Collapse from '@mui/material/Collapse';
import VulnerabilitiyCard from '../../Shared/VulnerabilityCard';
import VulnerabilityCountCard from '../../Shared/VulnerabilityCountCard';
+import CompareImages from './CompareImages';
const useStyles = makeStyles((theme) => ({
searchAndDisplayBar: {
@@ -98,7 +99,7 @@ const useStyles = makeStyles((theme) => ({
},
viewModes: {
position: 'relative',
- alignItems: 'baseline',
+ alignItems: 'center',
maxWidth: '100%',
flexDirection: 'row',
justifyContent: 'right'
@@ -352,8 +353,6 @@ function VulnerabilitiesDetails(props) {
return;
}
- console.log('Test');
-
return !isEmpty(cveSummary) ? (
+
diff --git a/src/host.js b/src/host.js
index 582b6ac4..18d5bf52 100644
--- a/src/host.js
+++ b/src/host.js
@@ -1,5 +1,5 @@
const hostConfig = {
- auto: true,
+ auto: false,
default: 'http://localhost:5000'
};