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);
}
}
};