From a4b25adb51d157b364fb1bd3d0962f7c0a6a6bb2 Mon Sep 17 00:00:00 2001 From: Raul Kele Date: Thu, 26 Jan 2023 13:46:51 +0200 Subject: [PATCH] patch: integrated pagination in cve tab Signed-off-by: Raul Kele --- .../TagPage/VulnerabilitiesDetails.test.js | 14 ++- src/api.js | 6 +- src/components/TagDetails.jsx | 9 +- src/components/VulnerabilitiesDetails.jsx | 102 +++++++++++++----- src/utilities/objectModels.js | 6 +- 5 files changed, 104 insertions(+), 33 deletions(-) diff --git a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js index 32d177e4..b5ca5116 100644 --- a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js +++ b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js @@ -15,6 +15,7 @@ const StateVulnerabilitiesWrapper = () => { const mockCVEList = { CVEListForImage: { Tag: '', + Page: { ItemCount: 20, TotalCount: 20 }, CVEList: [ { Id: 'CVE-2020-16156', @@ -445,6 +446,17 @@ const mockCVEFixed = { ] }; +beforeEach(() => { + // IntersectionObserver isn't available in test environment + const mockIntersectionObserver = jest.fn(); + mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null + }); + window.IntersectionObserver = mockIntersectionObserver; +}); + afterEach(() => { // restore the spy created with spyOn jest.restoreAllMocks(); @@ -461,7 +473,7 @@ describe('Vulnerabilties page', () => { it('renders no vulnerabilities if there are not any', async () => { jest.spyOn(api, 'get').mockResolvedValue({ status: 200, - data: { data: { CVEListForImage: { Tag: '', CVEList: [] } } } + data: { data: { CVEListForImage: { Tag: '', Page: {}, CVEList: [] } } } }); render(); await waitFor(() => expect(screen.getAllByText('No Vulnerabilities')).toHaveLength(1)); diff --git a/src/api.js b/src/api.js index 08c0afe7..bfd102eb 100644 --- a/src/api.js +++ b/src/api.js @@ -68,8 +68,10 @@ const endpoints = { `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Digest Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor Size Platform {Os Arch}} Summary {Name LastUpdated Size Platforms {Os Arch} Vendors NewestImage {RepoName IsSigned Vulnerabilities {MaxSeverity Count} Layers {Size Digest} Digest Tag Logo Title Documentation DownloadCount Source Description Licenses History {Layer {Size Digest} HistoryDescription {Created CreatedBy Author Comment EmptyLayer}}}}}}`, detailedImageInfo: (name, tag) => `/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned Vulnerabilities {MaxSeverity Count} Tag Digest LastUpdated Size ConfigDigest Platform {Os Arch} Vendor Licenses Logo}}`, - vulnerabilitiesForRepo: (name) => - `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag, CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`, + vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }) => + `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${ + (pageNumber - 1) * pageSize + }}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`, layersDetailsForImage: (name) => `/v2/_zot/ext/search?query={Image(image: "${name}"){History {Layer {Size Digest Score} HistoryDescription {Created CreatedBy Author Comment EmptyLayer} }}}`, imageListWithCVEFixed: (cveId, repoName) => diff --git a/src/components/TagDetails.jsx b/src/components/TagDetails.jsx index 95422488..4b45315d 100644 --- a/src/components/TagDetails.jsx +++ b/src/components/TagDetails.jsx @@ -1,5 +1,5 @@ import { useParams } from 'react-router-dom'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState, useRef } from 'react'; // utility import { api, endpoints } from '../api'; @@ -214,6 +214,7 @@ function TagDetails() { const [selectedTab, setSelectedTab] = useState('Layers'); const [selectedPullTab, setSelectedPullTab] = useState(''); const abortController = useMemo(() => new AbortController(), []); + const mounted = useRef(false); // get url param from { + mounted.current = true; // if same-page navigation because of tag update, following 2 lines help ux setSelectedTab('Layers'); window?.scrollTo(0, 0); @@ -246,6 +248,7 @@ function TagDetails() { }); return () => { abortController.abort(); + mounted.current = false; }; }, [reponame, tag]); @@ -265,7 +268,9 @@ function TagDetails() { useEffect(() => { if (isCopied) { setTimeout(() => { - setIsCopied(false); + if (mounted.current) { + setIsCopied(false); + } }, 3000); } }, [isCopied]); diff --git a/src/components/VulnerabilitiesDetails.jsx b/src/components/VulnerabilitiesDetails.jsx index d4409d63..c379f4d7 100644 --- a/src/components/VulnerabilitiesDetails.jsx +++ b/src/components/VulnerabilitiesDetails.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState, useRef } from 'react'; // utility import { api, endpoints } from '../api'; @@ -14,6 +14,7 @@ import Loading from './Loading'; import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material'; import { VulnerabilityChipCheck } from 'utilities/vulnerabilityAndSignatureCheck'; import { mapCVEInfo } from 'utilities/objectModels'; +import { EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants'; const useStyles = makeStyles(() => ({ card: { @@ -179,48 +180,96 @@ function VulnerabilitiyCard(props) { function VulnerabilitiesDetails(props) { const classes = useStyles(); - const [cveData, setCveData] = useState({}); + const [cveData, setCveData] = useState([]); const [isLoading, setIsLoading] = useState(true); const abortController = useMemo(() => new AbortController(), []); const { name, tag } = props; - useEffect(() => { + // pagination props + const [pageNumber, setPageNumber] = useState(1); + const [isEndOfList, setIsEndOfList] = useState(false); + const listBottom = useRef(null); + + const getPaginatedCVEs = () => { setIsLoading(true); api - .get(`${host()}${endpoints.vulnerabilitiesForRepo(`${name}:${tag}`)}`, abortController.signal) + .get( + `${host()}${endpoints.vulnerabilitiesForRepo(`${name}:${tag}`, { pageNumber, pageSize: EXPLORE_PAGE_SIZE })}`, + abortController.signal + ) .then((response) => { if (response.data && response.data.data) { - let cveInfo = response.data.data.CVEListForImage; - let cveListData = mapCVEInfo(cveInfo); - setCveData(cveListData); + if (!isEmpty(response.data.data.CVEListForImage?.CVEList)) { + let cveInfo = response.data.data.CVEListForImage.CVEList; + let cveListData = mapCVEInfo(cveInfo); + const newCVEList = [...cveData, ...cveListData]; + setCveData(newCVEList); + setIsEndOfList( + response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE || + newCVEList.length >= response.data.data.CVEListForImage?.Page?.TotalCount + ); + } } setIsLoading(false); }) .catch((e) => { console.error(e); setIsLoading(false); - setCveData({}); + setCveData([]); + setIsEndOfList(true); }); + }; + + useEffect(() => { + getPaginatedCVEs(); return () => { abortController.abort(); }; - }, []); + }, [pageNumber]); - const renderCVEs = (cves) => { - if (cves?.length !== 0) { - return ( - cves && - cves.map((cve, index) => { - return ; - }) - ); - } else { - return ( -
- No Vulnerabilities {' '} -
- ); + // setup intersection obeserver for infinite scroll + useEffect(() => { + if (isLoading || isEndOfList) return; + const handleIntersection = (entries) => { + if (isLoading || isEndOfList) return; + const [target] = entries; + if (target?.isIntersecting) { + setPageNumber((pageNumber) => pageNumber + 1); + } + }; + const intersectionObserver = new IntersectionObserver(handleIntersection, { + root: null, + rootMargin: '0px', + threshold: 0 + }); + + if (listBottom.current) { + intersectionObserver.observe(listBottom.current); } + + return () => { + intersectionObserver.disconnect(); + }; + }, [isLoading, isEndOfList]); + + const renderCVEs = () => { + return !isEmpty(cveData) ? ( + cveData.map((cve, index) => { + return ; + }) + ) : ( +
{!isLoading && No Vulnerabilities }
+ ); + }; + + const renderListBottom = () => { + if (isLoading) { + return ; + } + if (!isLoading && !isEndOfList) { + return
; + } + return ''; }; return ( @@ -248,7 +297,12 @@ function VulnerabilitiesDetails(props) { width: '100%' }} /> - {isLoading ? : renderCVEs(cveData?.cveList)} + + + {renderCVEs()} + {renderListBottom()} + + ); } diff --git a/src/utilities/objectModels.js b/src/utilities/objectModels.js index 89c423d8..771448e4 100644 --- a/src/utilities/objectModels.js +++ b/src/utilities/objectModels.js @@ -60,7 +60,7 @@ const mapToImage = (responseImage) => { }; const mapCVEInfo = (cveInfo) => { - const cveList = cveInfo.CVEList?.map((cve) => { + const cveList = cveInfo.map((cve) => { return { id: cve.Id, severity: cve.Severity, @@ -68,9 +68,7 @@ const mapCVEInfo = (cveInfo) => { description: cve.Description }; }); - return { - cveList - }; + return cveList; }; const mapReferrer = (referrer) => ({