diff --git a/src/__tests__/RepoPage/Tags.test.js b/src/__tests__/RepoPage/Tags.test.js index 136c86c9..d42abb0b 100644 --- a/src/__tests__/RepoPage/Tags.test.js +++ b/src/__tests__/RepoPage/Tags.test.js @@ -11,37 +11,49 @@ jest.mock('react-router-dom', () => ({ const mockedTagsData = [ { - Digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559', - Tag: 'latest', - LastUpdated: '2022-07-19T18:06:18.818788283Z', - Vendor: 'test1', - Size: '569130088', - Platform: { - Os: 'linux', - Arch: 'amd64' - } + tag: 'latest', + vendor: 'test1', + manifests: [ + { + lastUpdated: '2022-07-19T18:06:18.818788283Z', + digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559', + size: '569130088', + platform: { + Os: 'linux', + Arch: 'amd64' + } + } + ] }, { - Digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559', - Tag: 'bullseye', - LastUpdated: '2022-07-19T18:06:18.818788283Z', - Vendor: 'test1', - Size: '569130088', - Platform: { - Os: 'linux', - Arch: 'amd64' - } + tag: 'bullseye', + vendor: 'test1', + manifests: [ + { + digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559', + lastUpdated: '2022-07-19T18:06:18.818788283Z', + size: '569130088', + platform: { + Os: 'linux', + Arch: 'amd64' + } + } + ] }, { - Digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559', - Tag: '1.5.2', - LastUpdated: '2022-07-19T18:06:18.818788283Z', - Vendor: 'test1', - Size: '569130088', - Platform: { - Os: 'linux', - Arch: 'amd64' - } + tag: '1.5.2', + vendor: 'test1', + manifests: [ + { + lastUpdated: '2022-07-19T18:06:18.818788283Z', + digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559', + size: '569130088', + platform: { + Os: 'linux', + Arch: 'amd64' + } + } + ] } ]; @@ -60,7 +72,20 @@ describe('Tags component', () => { const tagLink = await screen.findByText('latest'); fireEvent.click(tagLink); await waitFor(() => { - expect(mockedUsedNavigate).toHaveBeenCalledWith('tag/latest'); + expect(mockedUsedNavigate).toHaveBeenCalledWith('tag/latest', { state: { digest: null } }); + }); + }); + + it('should navigate to specific manifest when clicking the digest', async () => { + render(); + const openBtn = screen.getAllByText(/digest/i); + await fireEvent.click(openBtn[0]); + const tagLink = await screen.findByText(/sha256:adca4/i); + fireEvent.click(tagLink); + await waitFor(() => { + expect(mockedUsedNavigate).toHaveBeenCalledWith('tag/latest', { + state: { digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559' } + }); }); }); diff --git a/src/__tests__/TagPage/TagDetails.test.js b/src/__tests__/TagPage/TagDetails.test.js index e885b192..46bee25f 100644 --- a/src/__tests__/TagPage/TagDetails.test.js +++ b/src/__tests__/TagPage/TagDetails.test.js @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import { api } from 'api'; import TagDetails from 'components/Tag/TagDetails'; import MockThemeProvier from '__mocks__/MockThemeProvider'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'; const TagDetailsThemeWrapper = () => { return ( @@ -72,6 +72,102 @@ const mockImage = { } } ] + }, + { + Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf45etertdfg973e29', + LastUpdated: '2020-12-08T00:22:52.526672082Z', + Size: '75183423', + ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78', + Platform: { + Os: 'windows', + Arch: 'amd64' + }, + History: [ + { + Layer: { + Size: '75181999', + Digest: 'sha256:7a0437f04f83f084b7ed68ad9c4a4947e12fc4e1b006b38129bac89114ec3621', + Score: null + }, + HistoryDescription: { + Created: '2020-12-08T00:22:52.526672082Z', + CreatedBy: + '/bin/sh -c #(nop) ADD file:bd7a2aed6ede423b719ceb2f723e4ecdfa662b28639c8429731c878e86fb138b in / ', + Author: '', + Comment: '', + EmptyLayer: false + } + }, + { + Layer: null, + HistoryDescription: { + Created: '2020-12-08T00:22:52.895811646Z', + CreatedBy: + '/bin/sh -c #(nop) LABEL org.label-schema.schema-version=1.0 org.label-schema.name=CentOS Base Image org.label-schema.vendor=CentOS org.label-schema.license=GPLv2 org.label-schema.build-date=20201204', + Author: '', + Comment: '', + EmptyLayer: true + } + }, + { + Layer: null, + HistoryDescription: { + Created: '2020-12-08T00:22:53.076477777Z', + CreatedBy: '/bin/sh -c #(nop) CMD ["/bin/bash"]', + Author: '', + Comment: '', + EmptyLayer: true + } + } + ] + }, + { + Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e25', + LastUpdated: '2020-12-08T00:22:52.526672082Z', + Size: '75183423', + ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78', + Platform: { + Os: 'linux', + Arch: 'arm' + }, + History: [ + { + Layer: { + Size: '75181999', + Digest: 'sha256:7a0437f04f83f084b7ed68ad9c4a4947e12fc4e1b006b38129bac89114ec3621', + Score: null + }, + HistoryDescription: { + Created: '2020-12-08T00:22:52.526672082Z', + CreatedBy: + '/bin/sh -c #(nop) ADD file:bd7a2aed6ede423b719ceb2f723e4ecdfa662b28639c8429731c878e86fb138b in / ', + Author: '', + Comment: '', + EmptyLayer: false + } + }, + { + Layer: null, + HistoryDescription: { + Created: '2020-12-08T00:22:52.895811646Z', + CreatedBy: + '/bin/sh -c #(nop) LABEL org.label-schema.schema-version=1.0 org.label-schema.name=CentOS Base Image org.label-schema.vendor=CentOS org.label-schema.license=GPLv2 org.label-schema.build-date=20201204', + Author: '', + Comment: '', + EmptyLayer: true + } + }, + { + Layer: null, + HistoryDescription: { + Created: '2020-12-08T00:22:53.076477777Z', + CreatedBy: '/bin/sh -c #(nop) CMD ["/bin/bash"]', + Author: '', + Comment: '', + EmptyLayer: true + } + } + ] } ], Vulnerabilities: { @@ -285,7 +381,8 @@ jest.mock('react-router-dom', () => ({ useParams: () => { return { name: 'test', tag: '1.0.1' }; }, - useNavigate: () => mockUseNavigate + useNavigate: () => mockUseNavigate, + useLocation: jest.fn() })); jest.mock('../../host', () => ({ @@ -328,6 +425,24 @@ describe('Tags details', () => { await waitFor(() => expect(error).toBeCalledTimes(1)); }); + it('should show the data of the different manifests when switching between them', async () => { + jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImage } }); + render(); + const manifestSelect = await screen.findByText(/linux\/amd64/i); + await userEvent.click(manifestSelect); + await userEvent.click(await screen.findByText(/windows\/amd64/i)); + expect(await screen.findByText(/windows\/amd64/i)).toBeInTheDocument(); + }); + + it('should preselect a manifest if data is received', async () => { + jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImage } }); + useLocation.mockImplementation(() => ({ + state: { digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e25' } + })); + render(); + expect(await screen.findByText(/linux\/arm/i)).toBeInTheDocument(); + }); + it('should redirect to homepage if it receives invalid data', async () => { jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: null, errors: ['testerror'] } }); render(); diff --git a/src/api.js b/src/api.js index fab66304..82228420 100644 --- a/src/api.js +++ b/src/api.js @@ -76,7 +76,7 @@ const endpoints = { (pageNumber - 1) * pageSize }}){Results {Name LastUpdated Size Platforms {Os Arch} NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description Licenses Title Source IsSigned Documentation Vendor Labels} DownloadCount}}}`, detailedRepoInfo: (name) => - `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch}} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor Size } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors NewestImage {RepoName IsSigned Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag 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 } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors NewestImage {RepoName IsSigned Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Title Documentation DownloadCount Source Description Licenses}}}}`, detailedImageInfo: (name, tag) => `/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned Vulnerabilities {MaxSeverity Count} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`, vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }) => diff --git a/src/components/Header/SearchSuggestion.jsx b/src/components/Header/SearchSuggestion.jsx index 80e420ac..4df4129a 100644 --- a/src/components/Header/SearchSuggestion.jsx +++ b/src/components/Header/SearchSuggestion.jsx @@ -6,7 +6,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { api, endpoints } from 'api'; import { host } from 'host'; import { mapToImage, mapToRepo } from 'utilities/objectModels'; -import { createSearchParams, useNavigate, useSearchParams } from 'react-router-dom'; +import { createSearchParams, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { debounce, isEmpty } from 'lodash'; import { useCombobox } from 'downshift'; import { HEADER_SEARCH_PAGE_SIZE } from 'utilities/paginationConstants'; @@ -104,6 +104,7 @@ function SearchSuggestion() { const [isLoading, setIsLoading] = useState(false); const [isFailedSearch, setIsFailedSearch] = useState(false); const navigate = useNavigate(); + const location = useLocation(); const abortController = useMemo(() => new AbortController(), []); const classes = useStyles(); @@ -180,7 +181,9 @@ function SearchSuggestion() { }; const searchCall = (value) => { - setQueryParams((prevState) => createSearchParams({ ...prevState, search: searchQuery })); + if (location.pathname?.includes('explore')) { + setQueryParams((prevState) => createSearchParams({ ...prevState, search: searchQuery })); + } if (value !== '') { // if search term inclused the ':' character, search for images, if not, search repos if (value?.includes(':')) { diff --git a/src/components/Repo/RepoDetails.jsx b/src/components/Repo/RepoDetails.jsx index 69adab07..1326981c 100644 --- a/src/components/Repo/RepoDetails.jsx +++ b/src/components/Repo/RepoDetails.jsx @@ -20,7 +20,7 @@ import { TabContext, TabList, TabPanel } from '@mui/lab'; import RepoDetailsMetadata from './RepoDetailsMetadata'; import Loading from '../Shared/Loading'; -import { isEmpty } from 'lodash'; +import { isEmpty, uniq } from 'lodash'; import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck'; import { mapToRepoFromRepoInfo } from 'utilities/objectModels'; @@ -164,6 +164,7 @@ function RepoDetails() { const classes = useStyles(); useEffect(() => { + setIsLoading(true); api .get(`${host()}${endpoints.detailedRepoInfo(name)}`, abortController.signal) .then((response) => { @@ -197,22 +198,13 @@ function RepoDetails() { const platformChips = () => { const platforms = repoDetailData?.platforms || []; + const filteredPlatforms = platforms?.flatMap((platform) => [platform.Os, platform.Arch]); - return platforms.map((platform, index) => ( - + return uniq(filteredPlatforms).map((platform, index) => ( + - { const selectedSort = Object.values(tagsSortByCriteria).find((sc) => sc.value === sortFilter); - const filteredTags = tags.filter((t) => t.Tag?.includes(tagsFilter)); + const filteredTags = tags.filter((t) => t.tag?.includes(tagsFilter)); if (selectedSort) { filteredTags.sort(selectedSort.func); } @@ -86,13 +84,11 @@ export default function Tags(props) { filteredTags.map((tag) => { return ( ); }) diff --git a/src/components/Shared/RepoCard.jsx b/src/components/Shared/RepoCard.jsx index a00095b1..c32a3f80 100644 --- a/src/components/Shared/RepoCard.jsx +++ b/src/components/Shared/RepoCard.jsx @@ -16,7 +16,7 @@ import repocube4 from '../../assets/repocube-4.png'; import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck'; import { Markdown } from 'utilities/MarkdowntojsxWrapper'; -import { isEmpty } from 'lodash'; +import { isEmpty, uniq } from 'lodash'; // temporary utility to get image const randomIntFromInterval = (min, max) => { @@ -119,22 +119,12 @@ function RepoCard(props) { }; const platformChips = () => { - const platformsOsArch = platforms || []; - return platformsOsArch.map((platform, index) => ( - + const filteredPlatforms = platforms?.flatMap((platform) => [platform.Os, platform.Arch]); + return uniq(filteredPlatforms).map((platform, index) => ( + - ({ })); export default function TagCard(props) { - const { repoName, tag, lastUpdated, vendor, digest, size, platform } = props; + const { repoName, tag, lastUpdated, vendor, manifests } = props; const [open, setOpen] = useState(false); const classes = useStyles(); @@ -70,11 +70,11 @@ export default function TagCard(props) { : `Timestamp N/A`; const navigate = useNavigate(); - const goToTags = () => { + const goToTags = (digest = null) => { if (repoName) { - navigate(`/image/${encodeURIComponent(repoName)}/tag/${tag}`); + navigate(`/image/${encodeURIComponent(repoName)}/tag/${tag}`, { state: { digest } }); } else { - navigate(`tag/${tag}`); + navigate(`tag/${tag}`, { state: { digest } }); } }; @@ -135,23 +135,38 @@ export default function TagCard(props) { Size - - - - {digest?.substr(0, 12)} - - - - - {platform?.Os}/{platform?.Arch} - - - - - {transform.formatBytes(size)} - + + {manifests.map((el) => ( + + + + goToTags(el.digest)} + > + {el.digest?.substr(0, 12)} + + + + + + {el.platform?.Os}/{el.platform?.Arch} + + + + + {transform.formatBytes(el.size)} + + - + ))} diff --git a/src/components/Tag/Tabs/DependsOn.jsx b/src/components/Tag/Tabs/DependsOn.jsx index 184a06f8..544ff9e8 100644 --- a/src/components/Tag/Tabs/DependsOn.jsx +++ b/src/components/Tag/Tabs/DependsOn.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useMemo, useRef } from 'react'; -import { isEmpty, head } from 'lodash'; +import { isEmpty } from 'lodash'; // utility import { api, endpoints } from '../../../api'; @@ -148,10 +148,8 @@ function DependsOn(props) { repoName={dependence.repoName} tag={dependence.tag} vendor={dependence.vendor} - platform={head(dependence.manifests)?.platform} isSigned={dependence.isSigned} - size={head(dependence.manifests)?.size} - digest={head(dependence.manifests)?.digest} + manifests={dependence.manifests} key={index} lastUpdated={dependence.lastUpdated} /> diff --git a/src/components/Tag/Tabs/IsDependentOn.jsx b/src/components/Tag/Tabs/IsDependentOn.jsx index ad1f9807..9c7fb406 100644 --- a/src/components/Tag/Tabs/IsDependentOn.jsx +++ b/src/components/Tag/Tabs/IsDependentOn.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState, useRef } from 'react'; -import { isEmpty, head } from 'lodash'; +import { isEmpty } from 'lodash'; // utility import { api, endpoints } from '../../../api'; @@ -148,10 +148,8 @@ function IsDependentOn(props) { repoName={dependence.repoName} tag={dependence.tag} vendor={dependence.vendor} - platform={head(dependence.manifests)?.platform} isSigned={dependence.isSigned} - size={head(dependence.manifests)?.size} - digest={head(dependence.manifests)?.digest} + manifests={dependence.manifests} key={index} lastUpdated={dependence.lastUpdated} /> diff --git a/src/components/Tag/TagDetails.jsx b/src/components/Tag/TagDetails.jsx index ae2cfcff..6af03053 100644 --- a/src/components/Tag/TagDetails.jsx +++ b/src/components/Tag/TagDetails.jsx @@ -1,4 +1,4 @@ -import { useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import React, { useEffect, useMemo, useState, useRef } from 'react'; // utility @@ -19,7 +19,8 @@ import { MenuItem, Tab, Typography, - InputBase + InputBase, + InputLabel } from '@mui/material'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import makeStyles from '@mui/styles/makeStyles'; @@ -220,6 +221,10 @@ function TagDetails() { const mounted = useRef(false); const navigate = useNavigate(); + // check for optional preselected digest + const { state } = useLocation() || {}; + const { digest } = state || ''; + // get url param from el.digest === digest); + if (preselectedManifest) { + setSelectedManifest(preselectedManifest); + } else { + setSelectedManifest(head(imageData.manifests)); + } + } else { + setSelectedManifest(head(imageData.manifests)); + } setPullString(dockerPull(imageData.name)); setSelectedPullTab(dockerPull(imageData.name)); } else if (!isEmpty(response.data.errors)) { @@ -286,6 +300,11 @@ function TagDetails() { return 'Pull Image'; }; + const handleOSArchChange = (e) => { + const { value } = e.target; + setSelectedManifest(value); + }; + return ( <> {isLoading ? ( @@ -329,6 +348,26 @@ function TagDetails() { {/* */} + + + + OS/Arch + {!isEmpty(selectedManifest) && ( + + )} + + DIGEST: {selectedManifest?.digest} diff --git a/src/utilities/objectModels.js b/src/utilities/objectModels.js index 478c529c..8d027589 100644 --- a/src/utilities/objectModels.js +++ b/src/utilities/objectModels.js @@ -20,7 +20,7 @@ const mapToRepo = (responseRepo) => { const mapToRepoFromRepoInfo = (responseRepoInfo) => { return { name: responseRepoInfo.Summary?.Name, - images: responseRepoInfo.Images, + images: responseRepoInfo.Images?.map((image) => mapToImage(image)) || [], lastUpdated: responseRepoInfo.Summary?.LastUpdated, size: responseRepoInfo.Summary?.Size, platforms: responseRepoInfo.Summary?.Platforms, diff --git a/src/utilities/sortCriteria.js b/src/utilities/sortCriteria.js index 78a0c4d5..25f660da 100644 --- a/src/utilities/sortCriteria.js +++ b/src/utilities/sortCriteria.js @@ -32,28 +32,28 @@ export const tagsSortByCriteria = { value: 'UPDATETIME_DESC', label: 'Newest', func: (a, b) => { - return DateTime.fromISO(b.LastUpdated).diff(DateTime.fromISO(a.LastUpdated)); + return DateTime.fromISO(b.lastUpdated).diff(DateTime.fromISO(a.lastUpdated)); } }, updateTime: { value: 'UPDATETIME', label: 'Oldest', func: (a, b) => { - return DateTime.fromISO(a.LastUpdated).diff(DateTime.fromISO(b.LastUpdated)); + return DateTime.fromISO(a.lastUpdated).diff(DateTime.fromISO(b.lastUpdated)); } }, alphabetic: { value: 'ALPHABETIC', label: 'A - Z', func: (a, b) => { - return a.Tag?.localeCompare(b.Tag); + return a.tag?.localeCompare(b.tag); } }, alphabeticDesc: { value: 'ALPHABETIC_DESC', label: 'Z - A', func: (a, b) => { - return b.Tag?.localeCompare(a.Tag); + return b.tag?.localeCompare(a.tag); } } };