Compare commits
7 Commits
commit-4db
...
commit-2f9
Author | SHA1 | Date | |
---|---|---|---|
2f94cc30ae | |||
ddf1d9224b | |||
7471fb58a8 | |||
2b3058fb14 | |||
ecff33fe01 | |||
60ca6d21d5 | |||
9029b97b47 |
110
.github/workflows/end-to-end-test.yml
vendored
Normal file
110
.github/workflows/end-to-end-test.yml
vendored
Normal file
@ -0,0 +1,110 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
name: end-to-end-test
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Test zui/zot integration
|
||||
env:
|
||||
CI: ""
|
||||
REGISTRY_HOST: "localhost"
|
||||
REGISTRY_PORT: "8080"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout zui repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.x
|
||||
cache: 'npm'
|
||||
|
||||
- name: Build zui
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
make install
|
||||
make build
|
||||
|
||||
- name: Install container image tooling
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
sudo apt-get update
|
||||
sudo apt-get install libgpgme-dev libassuan-dev libbtrfs-dev libdevmapper-dev pkg-config rpm snapd jq
|
||||
git clone https://github.com/containers/skopeo -b v1.9.0 $GITHUB_WORKSPACE/src/github.com/containers/skopeo
|
||||
cd $GITHUB_WORKSPACE/src/github.com/containers/skopeo && make bin/skopeo
|
||||
chmod +x bin/skopeo
|
||||
sudo mv bin/skopeo /usr/local/bin/skopeo
|
||||
which skopeo
|
||||
skopeo -v
|
||||
curl -L https://github.com/regclient/regclient/releases/download/v0.4.7/regctl-linux-amd64 -o regctl
|
||||
chmod +x regctl
|
||||
sudo mv regctl /usr/local/bin/regctl
|
||||
which regctl
|
||||
regctl version
|
||||
curl -L https://github.com/sigstore/cosign/releases/download/v1.13.0/cosign-linux-amd64 -o cosign
|
||||
chmod +x cosign
|
||||
sudo mv cosign /usr/local/bin/cosign
|
||||
which cosign
|
||||
cosign version
|
||||
cd $GITHUB_WORKSPACE
|
||||
|
||||
- name: Install go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19.x
|
||||
|
||||
- name: Checkout zot repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
repository: project-zot/zot
|
||||
ref: main
|
||||
path: zot
|
||||
|
||||
- name: Build zot
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE/zot
|
||||
make binary
|
||||
ls -l bin/
|
||||
|
||||
- name: Bringup zot server
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE/zot
|
||||
mkdir /tmp/zot
|
||||
./bin/zot-linux-amd64 serve examples/config-ui.json &
|
||||
while true; do x=0; curl -f http://$REGISTRY_HOST:$REGISTRY_PORT/v2/ || x=1; if [ $x -eq 0 ]; then break; fi; sleep 1; done
|
||||
|
||||
- name: Load image test data from cache into a local folder
|
||||
id: restore-cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: tests/data/images
|
||||
key: image-config-${{ hashFiles('**/tests/data/config.yaml') }}
|
||||
restore-keys: |
|
||||
image-config-
|
||||
|
||||
- name: Load image test data into zot server
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
regctl registry set --tls disabled $REGISTRY_HOST:$REGISTRY_PORT
|
||||
make test-data REGISTRY_HOST=$REGISTRY_HOST REGISTRY_PORT=$REGISTRY_PORT
|
||||
|
||||
- name: Run integration tests
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
make integration-tests REGISTRY_HOST=$REGISTRY_HOST REGISTRY_PORT=$REGISTRY_PORT
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,6 +8,7 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/tests/data/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
15
Makefile
15
Makefile
@ -1,3 +1,6 @@
|
||||
REGISTRY_HOST ?= localhost
|
||||
REGISTRY_PORT ?= 8080
|
||||
|
||||
.PHONY: all
|
||||
all: install audit build
|
||||
|
||||
@ -20,3 +23,15 @@ audit:
|
||||
.PHONY: run
|
||||
run:
|
||||
npm start
|
||||
|
||||
.PHONY: test-data
|
||||
test-data:
|
||||
./tests/scripts/load_test_data.py \
|
||||
--registry $(REGISTRY_HOST):$(REGISTRY_PORT) \
|
||||
--data-dir tests/data \
|
||||
--config-file tests/data/config.yaml \
|
||||
--metadata-file tests/data/image_metadata.json
|
||||
|
||||
.PHONY: integration-tests
|
||||
integration-tests: # Triggering the tests TBD
|
||||
cat tests/data/image_metadata.json | jq
|
||||
|
@ -13,8 +13,9 @@ jest.mock('react-router-dom', () => ({
|
||||
}));
|
||||
|
||||
const mockImageList = {
|
||||
RepoListWithNewestImage: {
|
||||
Results: [
|
||||
GlobalSearch: {
|
||||
Page: { TotalCount: 6, ItemCount: 3 },
|
||||
Repos: [
|
||||
{
|
||||
Name: 'alpine',
|
||||
Size: '2806985',
|
||||
@ -65,28 +66,36 @@ const mockImageList = {
|
||||
Count: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const mockImageListRecent = {
|
||||
GlobalSearch: {
|
||||
Page: { TotalCount: 6, ItemCount: 2 },
|
||||
Repos: [
|
||||
{
|
||||
Name: 'centos',
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
Name: 'alpine',
|
||||
Size: '2806985',
|
||||
LastUpdated: '2022-08-09T17:19:53.274069586Z',
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
Description: 'w',
|
||||
IsSigned: false,
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
Vulnerabilities: {
|
||||
MaxSeverity: 'NONE',
|
||||
Count: 10
|
||||
MaxSeverity: 'LOW',
|
||||
Count: 7
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Name: 'debian',
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
Name: 'mongo',
|
||||
Size: '231383863',
|
||||
LastUpdated: '2022-08-02T01:30:49.193203152Z',
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
@ -95,25 +104,8 @@ const mockImageList = {
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
Vulnerabilities: {
|
||||
MaxSeverity: 'MEDIUM',
|
||||
Count: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Name: 'mysql',
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
Vulnerabilities: {
|
||||
MaxSeverity: 'UNKNOWN',
|
||||
Count: 10
|
||||
MaxSeverity: 'HIGH',
|
||||
Count: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -132,7 +124,8 @@ afterEach(() => {
|
||||
|
||||
describe('Home component', () => {
|
||||
it('fetches image data and renders popular, bookmarks and recently updated', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
|
||||
render(<Home />);
|
||||
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(2));
|
||||
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(2));
|
||||
@ -140,14 +133,16 @@ describe('Home component', () => {
|
||||
});
|
||||
|
||||
it('renders signature icons', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
|
||||
render(<Home />);
|
||||
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders vulnerability icons', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
|
||||
render(<Home />);
|
||||
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(2);
|
||||
@ -158,11 +153,12 @@ describe('Home component', () => {
|
||||
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
|
||||
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<Home />);
|
||||
await waitFor(() => expect(error).toBeCalledTimes(1));
|
||||
await waitFor(() => expect(error).toBeCalledTimes(2));
|
||||
});
|
||||
|
||||
it('should redirect to explore page when clicking view all popular', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
|
||||
render(<Home />);
|
||||
const viewAllButtons = await screen.findAllByText(/view all/i);
|
||||
expect(viewAllButtons).toHaveLength(2);
|
||||
|
@ -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(<Tags tags={mockedTagsData} />);
|
||||
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' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,47 +1,42 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { api } from 'api';
|
||||
import ReferredBy from 'components/Tag/Tabs/ReferredBy';
|
||||
import React from 'react';
|
||||
|
||||
const mockReferrersList = {
|
||||
data: {
|
||||
Referrers: [
|
||||
const mockReferrersList = [
|
||||
{
|
||||
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
|
||||
ArtifactType: 'application/vnd.example.icecream.v1',
|
||||
Size: 466,
|
||||
Digest: 'sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c',
|
||||
Annotations: [
|
||||
{
|
||||
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
|
||||
ArtifactType: 'application/vnd.example.icecream.v1',
|
||||
Size: 466,
|
||||
Digest: 'sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c',
|
||||
Annotations: [
|
||||
{
|
||||
Key: 'demo',
|
||||
Value: 'true'
|
||||
},
|
||||
{
|
||||
Key: 'format',
|
||||
Value: 'oci'
|
||||
}
|
||||
]
|
||||
Key: 'demo',
|
||||
Value: 'true'
|
||||
},
|
||||
{
|
||||
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
|
||||
ArtifactType: 'application/vnd.example.icecream.v1',
|
||||
Size: 466,
|
||||
Digest: 'sha256:d9ad22f41d9cb9797c134401416eee2a70446cee1a8eb76fc6b191f4320dade2',
|
||||
Annotations: [
|
||||
{
|
||||
Key: 'demo',
|
||||
Value: 'true'
|
||||
},
|
||||
{
|
||||
Key: 'format',
|
||||
Value: 'oci'
|
||||
}
|
||||
]
|
||||
Key: 'format',
|
||||
Value: 'oci'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
|
||||
ArtifactType: 'application/vnd.example.icecream.v1',
|
||||
Size: 466,
|
||||
Digest: 'sha256:d9ad22f41d9cb9797c134401416eee2a70446cee1a8eb76fc6b191f4320dade2',
|
||||
Annotations: [
|
||||
{
|
||||
Key: 'demo',
|
||||
Value: 'true'
|
||||
},
|
||||
{
|
||||
Key: 'format',
|
||||
Value: 'oci'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
];
|
||||
|
||||
// useNavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
@ -57,27 +52,17 @@ afterEach(() => {
|
||||
|
||||
describe('Referred by tab', () => {
|
||||
it('should render referrers if there are any', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
|
||||
render(<ReferredBy repoName="golang" digest="test" />);
|
||||
render(<ReferredBy referrers={mockReferrersList} />);
|
||||
expect(await screen.findAllByText('Media type: application/vnd.oci.artifact.manifest.v1+json')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders no referrers if there aren't any", async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: { Referrers: [] } } });
|
||||
render(<ReferredBy repoName="golang" digest="test" />);
|
||||
render(<ReferredBy referrers={[]} />);
|
||||
expect(await screen.findByText(/Nothing found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should log an error if the request fails', async () => {
|
||||
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
|
||||
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<ReferredBy repoName="golang" digest="test" />);
|
||||
await waitFor(() => expect(error).toBeCalledTimes(1));
|
||||
});
|
||||
|
||||
it('should display the digest when clicking the dropdowns', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
|
||||
render(<ReferredBy repoName="golang" digest="test" />);
|
||||
render(<ReferredBy referrers={mockReferrersList} />);
|
||||
const firstDigest = (await screen.findAllByText(/digest/i))[0];
|
||||
expect(firstDigest).toBeInTheDocument();
|
||||
await userEvent.click(firstDigest);
|
||||
@ -91,8 +76,7 @@ describe('Referred by tab', () => {
|
||||
});
|
||||
|
||||
it('should display the annotations when clicking the dropdown', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
|
||||
render(<ReferredBy repoName="golang" digest="test" />);
|
||||
render(<ReferredBy referrers={mockReferrersList} />);
|
||||
const firstAnnotations = (await screen.findAllByText(/ANNOTATIONS/i))[0];
|
||||
expect(firstAnnotations).toBeInTheDocument();
|
||||
await userEvent.click(firstAnnotations);
|
||||
|
@ -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(<TagDetailsThemeWrapper />);
|
||||
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(<TagDetailsThemeWrapper />);
|
||||
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(<TagDetailsThemeWrapper />);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { api } from 'api';
|
||||
import VulnerabilitiesDetails from 'components/Tag/Tabs/VulnerabilitiesDetails';
|
||||
import React from 'react';
|
||||
@ -432,6 +433,14 @@ const mockCVEList = {
|
||||
}
|
||||
};
|
||||
|
||||
const mockCVEListFiltered = {
|
||||
CVEListForImage: {
|
||||
Tag: '',
|
||||
Page: { ItemCount: 20, TotalCount: 20 },
|
||||
CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => e.Id.includes('2022'))
|
||||
}
|
||||
};
|
||||
|
||||
const mockCVEFixed = {
|
||||
pageOne: {
|
||||
ImageListWithCVEFixed: {
|
||||
@ -488,6 +497,16 @@ describe('Vulnerabilties page', () => {
|
||||
await waitFor(() => expect(screen.getAllByText(/fixed in/i)).toHaveLength(20));
|
||||
});
|
||||
|
||||
it('sends filtered query if user types in the search bar', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
const cveSearchInput = screen.getByPlaceholderText(/search for/i);
|
||||
jest.spyOn(api, 'get').mockRejectedValueOnce({ status: 200, data: { data: mockCVEListFiltered } });
|
||||
await userEvent.type(cveSearchInput, '2022');
|
||||
expect((await screen.queryAllByText(/2023/i).length) === 0);
|
||||
expect((await screen.findAllByText(/2022/i)).length === 6);
|
||||
});
|
||||
|
||||
it('renders no vulnerabilities if there are not any', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({
|
||||
status: 200,
|
||||
|
15
src/api.js
15
src/api.js
@ -76,13 +76,18 @@ 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 }) =>
|
||||
`/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${
|
||||
`/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned 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 = '') => {
|
||||
let query = `/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}}}}`,
|
||||
}}`;
|
||||
if (!isEmpty(searchTerm)) {
|
||||
query += `, searchedCVE: "${searchTerm}"`;
|
||||
}
|
||||
return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`;
|
||||
},
|
||||
imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }) =>
|
||||
`/v2/_zot/ext/search?query={ImageListWithCVEFixed(id:"${cveId}", image:"${repoName}", requestedPage: {limit:${pageSize} offset:${
|
||||
(pageNumber - 1) * pageSize
|
||||
|
@ -17,7 +17,7 @@ import { host } from '../../host';
|
||||
import { mapToRepo } from 'utilities/objectModels.js';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import FilterCard from '../Shared/FilterCard.jsx';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { isEmpty, isNil } from 'lodash';
|
||||
import filterConstants from 'utilities/filterConstants.js';
|
||||
import { sortByCriteria } from 'utilities/sortCriteria.js';
|
||||
import { EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants.js';
|
||||
@ -63,7 +63,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
}
|
||||
}));
|
||||
|
||||
function Explore() {
|
||||
function Explore({ searchInputValue }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [exploreData, setExploreData] = useState([]);
|
||||
const [sortFilter, setSortFilter] = useState(sortByCriteria.relevance.value);
|
||||
@ -119,7 +119,7 @@ function Explore() {
|
||||
api
|
||||
.get(
|
||||
`${host()}${endpoints.globalSearch({
|
||||
searchQuery: search,
|
||||
searchQuery: !isNil(searchInputValue) ? searchInputValue : search,
|
||||
pageNumber,
|
||||
pageSize: EXPLORE_PAGE_SIZE,
|
||||
sortBy: sortFilter,
|
||||
@ -269,7 +269,7 @@ function Explore() {
|
||||
if (!isLoading && !isEndOfList) {
|
||||
return <div ref={listBottom} />;
|
||||
}
|
||||
return '';
|
||||
return;
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -103,7 +103,7 @@ function setNavShow() {
|
||||
return show;
|
||||
}
|
||||
|
||||
function Header() {
|
||||
function Header({ setSearchCurrentValue = () => {} }) {
|
||||
const show = setNavShow();
|
||||
const classes = useStyles();
|
||||
const path = useLocation().pathname;
|
||||
@ -122,7 +122,7 @@ function Header() {
|
||||
</Link>
|
||||
</Grid>
|
||||
<Grid item xs={8}>
|
||||
{path !== '/' && <SearchSuggestion />}
|
||||
{path !== '/' && <SearchSuggestion setSearchCurrentValue={setSearchCurrentValue} />}
|
||||
</Grid>
|
||||
<Grid item md={2} xs={0}>
|
||||
<div>{''}</div>
|
||||
|
@ -95,12 +95,11 @@ const useStyles = makeStyles(() => ({
|
||||
}
|
||||
}));
|
||||
|
||||
function SearchSuggestion() {
|
||||
const [queryParams, setQueryParams] = useSearchParams();
|
||||
const search = queryParams.get('search');
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(search || '');
|
||||
function SearchSuggestion({ setSearchCurrentValue = () => {} }) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestionData, setSuggestionData] = useState([]);
|
||||
const [queryParams] = useSearchParams();
|
||||
const search = queryParams.get('search') || '';
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFailedSearch, setIsFailedSearch] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
@ -174,13 +173,15 @@ function SearchSuggestion() {
|
||||
const handleSeachChange = (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([]);
|
||||
};
|
||||
|
||||
const searchCall = (value) => {
|
||||
setQueryParams((prevState) => createSearchParams({ ...prevState, search: searchQuery }));
|
||||
if (value !== '') {
|
||||
// if search term inclused the ':' character, search for images, if not, search repos
|
||||
if (value?.includes(':')) {
|
||||
@ -221,7 +222,7 @@ function SearchSuggestion() {
|
||||
items: suggestionData,
|
||||
onInputValueChange: handleSeachChange,
|
||||
onSelectedItemChange: handleSuggestionSelected,
|
||||
initialInputValue: search ?? '',
|
||||
initialInputValue: !isEmpty(searchQuery) ? searchQuery : search,
|
||||
itemToString: (item) => item.name ?? item
|
||||
});
|
||||
|
||||
@ -278,7 +279,7 @@ function SearchSuggestion() {
|
||||
className={isOpen && !isLoading && !isFailedSearch ? classes.resultsWrapper : classes.resultsWrapperHidden}
|
||||
>
|
||||
{isOpen && suggestionData?.length > 0 && renderSuggestions()}
|
||||
{isOpen && isEmpty(searchQuery) && (
|
||||
{isOpen && isEmpty(searchQuery) && isEmpty(suggestionData) && (
|
||||
<>
|
||||
<ListItem
|
||||
className={classes.searchItem}
|
||||
|
@ -8,6 +8,7 @@ import { mapToRepo } from 'utilities/objectModels';
|
||||
import Loading from '../Shared/Loading';
|
||||
import { useNavigate, createSearchParams } from 'react-router-dom';
|
||||
import { sortByCriteria } from 'utilities/sortCriteria';
|
||||
import { HOME_POPULAR_PAGE_SIZE, HOME_RECENT_PAGE_SIZE } from 'utilities/paginationConstants';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
gridWrapper: {
|
||||
@ -60,29 +61,70 @@ const useStyles = makeStyles(() => ({
|
||||
|
||||
function Home() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [homeData, setHomeData] = useState([]);
|
||||
const [popularData, setPopularData] = useState([]);
|
||||
const [recentData, setRecentData] = useState([]);
|
||||
const navigate = useNavigate();
|
||||
const abortController = useMemo(() => new AbortController(), []);
|
||||
const classes = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
const getPopularData = () => {
|
||||
setIsLoading(true);
|
||||
api
|
||||
.get(`${host()}${endpoints.repoList()}`, abortController.signal)
|
||||
.get(
|
||||
`${host()}${endpoints.globalSearch({
|
||||
searchQuery: '',
|
||||
pageNumber: 1,
|
||||
pageSize: HOME_POPULAR_PAGE_SIZE,
|
||||
sortBy: sortByCriteria.downloads?.value
|
||||
})}`,
|
||||
abortController.signal
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.data && response.data.data) {
|
||||
let repoList = response.data.data.RepoListWithNewestImage.Results;
|
||||
let repoList = response.data.data.GlobalSearch.Repos;
|
||||
let repoData = repoList.map((responseRepo) => {
|
||||
return mapToRepo(responseRepo);
|
||||
});
|
||||
setHomeData(repoData);
|
||||
setPopularData(repoData);
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const getRecentData = () => {
|
||||
setIsLoading(true);
|
||||
api
|
||||
.get(
|
||||
`${host()}${endpoints.globalSearch({
|
||||
searchQuery: '',
|
||||
pageNumber: 1,
|
||||
pageSize: HOME_RECENT_PAGE_SIZE,
|
||||
sortBy: sortByCriteria.updateTime?.value
|
||||
})}`,
|
||||
abortController.signal
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.data && response.data.data) {
|
||||
let repoList = response.data.data.GlobalSearch.Repos;
|
||||
let repoData = repoList.map((responseRepo) => {
|
||||
return mapToRepo(responseRepo);
|
||||
});
|
||||
setRecentData(repoData);
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
getPopularData();
|
||||
getRecentData();
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
@ -94,8 +136,8 @@ function Home() {
|
||||
|
||||
const renderMostPopular = () => {
|
||||
return (
|
||||
homeData &&
|
||||
homeData.slice(0, 3).map((item, index) => {
|
||||
popularData &&
|
||||
popularData.map((item, index) => {
|
||||
return (
|
||||
<RepoCard
|
||||
name={item.name}
|
||||
@ -120,8 +162,8 @@ function Home() {
|
||||
|
||||
const renderRecentlyUpdated = () => {
|
||||
return (
|
||||
homeData &&
|
||||
homeData.slice(0, 2).map((item, index) => {
|
||||
recentData &&
|
||||
recentData.map((item, index) => {
|
||||
return (
|
||||
<RepoCard
|
||||
name={item.name}
|
||||
|
@ -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';
|
||||
|
||||
@ -134,7 +134,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
},
|
||||
platformChipsContainer: {
|
||||
alignItems: 'center',
|
||||
padding: '0.5rem 0 0 4rem',
|
||||
padding: '0.5rem 0 0 1rem',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
padding: '0.5rem 0 0 0'
|
||||
}
|
||||
@ -164,6 +164,7 @@ function RepoDetails() {
|
||||
const classes = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
api
|
||||
.get(`${host()}${endpoints.detailedRepoInfo(name)}`, abortController.signal)
|
||||
.then((response) => {
|
||||
@ -197,30 +198,19 @@ function RepoDetails() {
|
||||
|
||||
const platformChips = () => {
|
||||
const platforms = repoDetailData?.platforms || [];
|
||||
const filteredPlatforms = platforms?.flatMap((platform) => [platform.Os, platform.Arch]);
|
||||
|
||||
return platforms.map((platform, index) => (
|
||||
<Stack key={`stack${platform?.Os}${platform?.Arch}`} alignItems="center" direction="row" spacing={2}>
|
||||
<Chip
|
||||
key={`${name}${platform?.Os}${index}`}
|
||||
label={platform?.Os}
|
||||
onClick={handlePlatformChipClick}
|
||||
sx={{
|
||||
backgroundColor: '#E0E5EB',
|
||||
color: '#52637A',
|
||||
fontSize: '0.8125rem'
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
key={`${name}${platform?.Arch}${index}`}
|
||||
label={platform?.Arch}
|
||||
onClick={handlePlatformChipClick}
|
||||
sx={{
|
||||
backgroundColor: '#E0E5EB',
|
||||
color: '#52637A',
|
||||
fontSize: '0.8125rem'
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
return uniq(filteredPlatforms).map((platform, index) => (
|
||||
<Chip
|
||||
key={`${name}${platform}${index}`}
|
||||
label={platform}
|
||||
onClick={handlePlatformChipClick}
|
||||
sx={{
|
||||
backgroundColor: '#E0E5EB',
|
||||
color: '#52637A',
|
||||
fontSize: '0.625rem'
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
@ -291,7 +281,7 @@ function RepoDetails() {
|
||||
<Typography gutterBottom className={classes.repoTitle}>
|
||||
{repoDetailData?.title || 'Title not available'}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} className={classes.platformChipsContainer}>
|
||||
<Stack direction="row" spacing={1} className={classes.platformChipsContainer}>
|
||||
{platformChips()}
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
@ -1,8 +1,6 @@
|
||||
// react global
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { head } from 'lodash';
|
||||
|
||||
// components
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Card, CardContent, Divider, Stack, InputBase, FormControl, Select, InputLabel, MenuItem } from '@mui/material';
|
||||
@ -77,7 +75,7 @@ export default function Tags(props) {
|
||||
const [sortFilter, setSortFilter] = useState(tagsSortByCriteria.updateTimeDesc.value);
|
||||
const renderTags = (tags) => {
|
||||
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 (
|
||||
<TagCard
|
||||
key={tag.Tag}
|
||||
tag={tag.Tag}
|
||||
lastUpdated={tag.LastUpdated}
|
||||
digest={head(tag.Manifests)?.Digest}
|
||||
vendor={tag.Vendor}
|
||||
size={tag.Size}
|
||||
platform={head(tag.Manifests)?.Platform}
|
||||
key={tag.tag}
|
||||
tag={tag.tag}
|
||||
lastUpdated={tag.lastUpdated}
|
||||
vendor={tag.vendor}
|
||||
manifests={tag.manifests}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
@ -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,30 +119,18 @@ function RepoCard(props) {
|
||||
};
|
||||
|
||||
const platformChips = () => {
|
||||
const platformsOsArch = platforms || [];
|
||||
return platformsOsArch.map((platform, index) => (
|
||||
<Stack key={`stack${platform?.Os}${platform?.Arch}`} alignItems="center" direction="row" spacing={2}>
|
||||
<Chip
|
||||
key={`${name}${platform?.Os}${index}`}
|
||||
label={platform?.Os}
|
||||
onClick={handlePlatformChipClick}
|
||||
sx={{
|
||||
backgroundColor: '#E0E5EB',
|
||||
color: '#52637A',
|
||||
fontSize: '0.8125rem'
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
key={`${name}${platform?.Arch}${index}`}
|
||||
label={platform?.Arch}
|
||||
onClick={handlePlatformChipClick}
|
||||
sx={{
|
||||
backgroundColor: '#E0E5EB',
|
||||
color: '#52637A',
|
||||
fontSize: '0.8125rem'
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
const filteredPlatforms = platforms?.flatMap((platform) => [platform.Os, platform.Arch]);
|
||||
return uniq(filteredPlatforms).map((platform, index) => (
|
||||
<Chip
|
||||
key={`${name}${platform}${index}`}
|
||||
label={platform}
|
||||
onClick={handlePlatformChipClick}
|
||||
sx={{
|
||||
backgroundColor: '#E0E5EB',
|
||||
color: '#52637A',
|
||||
fontSize: '0.625rem'
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
@ -198,7 +186,7 @@ function RepoCard(props) {
|
||||
{description || 'Description not available'}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Stack alignItems="center" direction="row" spacing={2} pt={1}>
|
||||
<Stack alignItems="center" direction="row" spacing={1} pt={1}>
|
||||
{platformChips()}
|
||||
</Stack>
|
||||
<Stack alignItems="center" direction="row" spacing={1} pt={2}>
|
||||
|
@ -60,7 +60,7 @@ const useStyles = makeStyles(() => ({
|
||||
}));
|
||||
|
||||
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) {
|
||||
<Typography variant="body1"> Size </Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid container item xs={12} direction={'row'}>
|
||||
<Grid item xs={6} md={4}>
|
||||
<Tooltip title={digest || ''} placement="top">
|
||||
<Typography variant="body1">{digest?.substr(0, 12)}</Typography>
|
||||
</Tooltip>
|
||||
|
||||
{manifests.map((el) => (
|
||||
<Grid container item xs={12} key={el.digest} direction={'row'}>
|
||||
<Grid item xs={6} md={4}>
|
||||
<Tooltip title={el.digest || ''} placement="top">
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#1479FF', textDecorationLine: 'underline', cursor: 'pointer' }}
|
||||
onClick={() => goToTags(el.digest)}
|
||||
>
|
||||
{el.digest?.substr(0, 12)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={4} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Typography variant="body1">
|
||||
{el.platform?.Os}/{el.platform?.Arch}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={0}
|
||||
md={4}
|
||||
className="hide-on-mobile"
|
||||
sx={{ display: 'flex', justifyContent: 'flex-end' }}
|
||||
>
|
||||
<Typography sx={{ textAlign: 'right' }} variant="body1">
|
||||
{transform.formatBytes(el.size)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={4} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Typography variant="body1">
|
||||
{platform?.Os}/{platform?.Arch}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={0} md={4} className="hide-on-mobile" sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Typography sx={{ textAlign: 'right' }} variant="body1">
|
||||
{transform.formatBytes(size)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -1,11 +1,9 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { Divider, Typography, Stack } from '@mui/material';
|
||||
import ReferrerCard from '../../Shared/ReferrerCard';
|
||||
import Loading from '../../Shared/Loading';
|
||||
import { api, endpoints } from 'api';
|
||||
import { host } from '../../../host';
|
||||
import { mapReferrer } from 'utilities/objectModels';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
@ -17,36 +15,24 @@ const useStyles = makeStyles(() => ({
|
||||
}));
|
||||
|
||||
function ReferredBy(props) {
|
||||
const { repoName, digest } = props;
|
||||
const [referrers, setReferrers] = useState([]);
|
||||
const { referrers } = props;
|
||||
const [referrersData, setReferrersData] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const classes = useStyles();
|
||||
const abortController = useMemo(() => new AbortController(), []);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get(`${host()}${endpoints.referrers({ repo: repoName, digest })}`, abortController.signal)
|
||||
.then((response) => {
|
||||
if (response.data && response.data.data) {
|
||||
let referrersData = response.data.data.Referrers?.map((referrer) => mapReferrer(referrer));
|
||||
if (!isEmpty(referrersData)) {
|
||||
setReferrers(referrersData);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
if (!isEmpty(referrers)) {
|
||||
const mappedReferrersData = referrers.map((referrer) => mapReferrer(referrer));
|
||||
setReferrersData(mappedReferrersData);
|
||||
} else {
|
||||
setReferrersData([]);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const renderReferrers = () => {
|
||||
return !isEmpty(referrers) ? (
|
||||
referrers.map((referrer, index) => {
|
||||
return !isEmpty(referrersData) ? (
|
||||
referrersData.map((referrer, index) => {
|
||||
return (
|
||||
<ReferrerCard
|
||||
key={index}
|
||||
@ -63,16 +49,6 @@ function ReferredBy(props) {
|
||||
);
|
||||
};
|
||||
|
||||
const renderListBottom = () => {
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
if (!isLoading) {
|
||||
return <div />;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="referred-by-container">
|
||||
<Typography
|
||||
@ -95,8 +71,7 @@ function ReferredBy(props) {
|
||||
/>
|
||||
<Stack direction="column" spacing={2}>
|
||||
<Stack direction="column" spacing={2}>
|
||||
{renderReferrers()}
|
||||
{renderListBottom()}
|
||||
{isLoading ? <Loading /> : renderReferrers()}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
|
@ -5,16 +5,17 @@ import { api, endpoints } from '../../../api';
|
||||
|
||||
// components
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import { Box, Card, CardContent, Divider, Stack, Typography } from '@mui/material';
|
||||
import { Box, Card, CardContent, Divider, Stack, Typography, InputBase } from '@mui/material';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import { host } from '../../../host';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { debounce, isEmpty } from 'lodash';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Loading from '../../Shared/Loading';
|
||||
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
|
||||
import { VulnerabilityChipCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
import { mapCVEInfo } from 'utilities/objectModels';
|
||||
import { CVE_FIXEDIN_PAGE_SIZE, EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
card: {
|
||||
@ -81,6 +82,25 @@ const useStyles = makeStyles(() => ({
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center'
|
||||
},
|
||||
search: {
|
||||
position: 'relative',
|
||||
minWidth: '100%',
|
||||
flexDirection: 'row',
|
||||
marginBottom: '1.7rem',
|
||||
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
|
||||
border: '0.125rem solid #E7E7E7',
|
||||
borderRadius: '1rem',
|
||||
zIndex: 1155
|
||||
},
|
||||
searchIcon: {
|
||||
color: '#52637A',
|
||||
paddingRight: '3%'
|
||||
},
|
||||
input: {
|
||||
color: '#464141',
|
||||
marginLeft: 1,
|
||||
width: '90%'
|
||||
}
|
||||
}));
|
||||
|
||||
@ -236,27 +256,27 @@ function VulnerabilitiesDetails(props) {
|
||||
const { name, tag } = props;
|
||||
|
||||
// pagination props
|
||||
const [cveFilter, setCveFilter] = useState('');
|
||||
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}`, { pageNumber, pageSize: EXPLORE_PAGE_SIZE })}`,
|
||||
`${host()}${endpoints.vulnerabilitiesForRepo(
|
||||
`${name}:${tag}`,
|
||||
{ pageNumber, pageSize: EXPLORE_PAGE_SIZE },
|
||||
cveFilter
|
||||
)}`,
|
||||
abortController.signal
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.data && response.data.data) {
|
||||
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
|
||||
);
|
||||
setCveData((previousState) => (pageNumber === 1 ? cveListData : [...previousState, ...cveListData]));
|
||||
setIsEndOfList(response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE);
|
||||
} else if (response.data.errors) {
|
||||
setIsEndOfList(true);
|
||||
}
|
||||
@ -270,11 +290,25 @@ function VulnerabilitiesDetails(props) {
|
||||
});
|
||||
};
|
||||
|
||||
const resetPagination = () => {
|
||||
setIsLoading(true);
|
||||
setIsEndOfList(false);
|
||||
if (pageNumber !== 1) {
|
||||
setPageNumber(1);
|
||||
} else {
|
||||
getPaginatedCVEs();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCveFilterChange = (e) => {
|
||||
const { value } = e.target;
|
||||
setCveFilter(value);
|
||||
};
|
||||
|
||||
const debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300));
|
||||
|
||||
useEffect(() => {
|
||||
getPaginatedCVEs();
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [pageNumber]);
|
||||
|
||||
// setup intersection obeserver for infinite scroll
|
||||
@ -302,6 +336,18 @@ function VulnerabilitiesDetails(props) {
|
||||
};
|
||||
}, [isLoading, isEndOfList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
resetPagination();
|
||||
}, [cveFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortController.abort();
|
||||
debouncedChangeHandler.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderCVEs = () => {
|
||||
return !isEmpty(cveData) ? (
|
||||
cveData.map((cve, index) => {
|
||||
@ -347,6 +393,17 @@ function VulnerabilitiesDetails(props) {
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
<Stack className={classes.search} direction="row" alignItems="center" justifyContent="space-between" spacing={2}>
|
||||
<InputBase
|
||||
style={{ paddingLeft: 10, height: 46, color: 'rgba(0, 0, 0, 0.6)' }}
|
||||
placeholder={'Search for Vulnerabilities...'}
|
||||
className={classes.input}
|
||||
onChange={debouncedChangeHandler}
|
||||
/>
|
||||
<div className={classes.searchIcon}>
|
||||
<SearchIcon />
|
||||
</div>
|
||||
</Stack>
|
||||
<Stack direction="column" spacing={2}>
|
||||
<Stack direction="column" spacing={2}>
|
||||
{renderCVEs()}
|
||||
|
@ -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 <Route here (i.e. image name)
|
||||
const { reponame, tag } = useParams();
|
||||
|
||||
@ -240,7 +245,16 @@ function TagDetails() {
|
||||
let imageInfo = response.data.data.Image;
|
||||
let imageData = mapToImage(imageInfo);
|
||||
setImageDetailData(imageData);
|
||||
setSelectedManifest(head(imageData.manifests));
|
||||
if (!isEmpty(digest)) {
|
||||
const preselectedManifest = imageData.manifests?.find((el) => 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() {
|
||||
<SignatureIconCheck isSigned={imageDetailData.isSigned} />
|
||||
{/* <BookmarkIcon sx={{color:"#52637A"}}/> */}
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ width: { xs: '100%', md: 'auto' } }}>
|
||||
<FormControl sx={{ m: '1', minWidth: '4.6875rem' }} className={classes.sortForm} size="small">
|
||||
<InputLabel>OS/Arch</InputLabel>
|
||||
{!isEmpty(selectedManifest) && (
|
||||
<Select
|
||||
label="OS/Arch"
|
||||
value={selectedManifest}
|
||||
onChange={handleOSArchChange}
|
||||
MenuProps={{ disableScrollLock: true }}
|
||||
>
|
||||
{imageDetailData.manifests.map((el) => (
|
||||
<MenuItem key={el.digest} value={el}>
|
||||
{`${el.platform?.Os}/${el.platform?.Arch}`}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Typography gutterBottom className={classes.digest}>
|
||||
DIGEST: {selectedManifest?.digest}
|
||||
@ -541,7 +580,7 @@ function TagDetails() {
|
||||
<VulnerabilitiesDetails name={reponame} tag={tag} />
|
||||
</TabPanel>
|
||||
<TabPanel value="ReferredBy" className={classes.tabPanel}>
|
||||
<ReferredBy repoName={reponame} digest={selectedManifest?.digest} />
|
||||
<ReferredBy referrers={imageDetailData.referrers} />
|
||||
</TabPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
@ -5,6 +5,7 @@ import Header from '../components/Header/Header.jsx';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import { Container, Grid, Stack } from '@mui/material';
|
||||
import Explore from 'components/Explore/Explore.jsx';
|
||||
import { useState } from 'react';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
container: {
|
||||
@ -27,14 +28,15 @@ const useStyles = makeStyles(() => ({
|
||||
|
||||
function ExplorePage() {
|
||||
const classes = useStyles();
|
||||
const [searchCurrentValue, setSearchCurrentValue] = useState();
|
||||
|
||||
return (
|
||||
<Stack className={classes.pageWrapper} direction="column" data-testid="explore-container">
|
||||
<Header />
|
||||
<Header setSearchCurrentValue={setSearchCurrentValue} />
|
||||
<Container className={classes.container}>
|
||||
<Grid container className={classes.gridWrapper}>
|
||||
<Grid item className={classes.tile}>
|
||||
<Explore />
|
||||
<Explore searchInputValue={searchCurrentValue} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
|
@ -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,
|
||||
@ -44,6 +44,7 @@ const mapToImage = (responseImage) => {
|
||||
repoName: responseImage.RepoName,
|
||||
tag: responseImage.Tag,
|
||||
manifests: responseImage.Manifests?.map((manifest) => mapToManifest(manifest)) || [],
|
||||
referrers: responseImage.Referrers,
|
||||
size: responseImage.Size,
|
||||
downloadCount: responseImage.DownloadCount,
|
||||
lastUpdated: responseImage.LastUpdated,
|
||||
|
@ -1,6 +1,15 @@
|
||||
const HEADER_SEARCH_PAGE_SIZE = 9;
|
||||
const EXPLORE_PAGE_SIZE = 10;
|
||||
const HOME_PAGE_SIZE = 10;
|
||||
const HOME_POPULAR_PAGE_SIZE = 3;
|
||||
const HOME_RECENT_PAGE_SIZE = 2;
|
||||
const CVE_FIXEDIN_PAGE_SIZE = 5;
|
||||
|
||||
export { HEADER_SEARCH_PAGE_SIZE, EXPLORE_PAGE_SIZE, HOME_PAGE_SIZE, CVE_FIXEDIN_PAGE_SIZE };
|
||||
export {
|
||||
HEADER_SEARCH_PAGE_SIZE,
|
||||
EXPLORE_PAGE_SIZE,
|
||||
HOME_PAGE_SIZE,
|
||||
CVE_FIXEDIN_PAGE_SIZE,
|
||||
HOME_POPULAR_PAGE_SIZE,
|
||||
HOME_RECENT_PAGE_SIZE
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
116
tests/data/config.yaml
Normal file
116
tests/data/config.yaml
Normal file
@ -0,0 +1,116 @@
|
||||
images:
|
||||
- name: alpine
|
||||
tags:
|
||||
- "3.17"
|
||||
- "3.17.2"
|
||||
- "3.17.1"
|
||||
- "3.16"
|
||||
- "3.16.4"
|
||||
- "3.16.3"
|
||||
- "3.16.2"
|
||||
- "3.16.1"
|
||||
- "3.15"
|
||||
- "3.15.7"
|
||||
- "3.15.6"
|
||||
- "3.15.5"
|
||||
- "3.15.4"
|
||||
- "3.15.3"
|
||||
- "3.15.2"
|
||||
- "3.15.1"
|
||||
- "3.14"
|
||||
- "3.14.9"
|
||||
multiarch: "all"
|
||||
- name: ubuntu
|
||||
tags:
|
||||
- "18.04"
|
||||
- "bionic-20230301"
|
||||
- "bionic"
|
||||
- "22.04"
|
||||
- "jammy-20230301"
|
||||
- "jammy"
|
||||
- "latest"
|
||||
multiarch: ""
|
||||
- name: debian
|
||||
tags:
|
||||
- "bullseye-slim"
|
||||
- "bullseye-20230227-slim"
|
||||
- "11.6-slim"
|
||||
- "11-slim"
|
||||
multiarch: ""
|
||||
- name: centos # Not supported since 2020
|
||||
tags:
|
||||
- "centos7"
|
||||
- "7"
|
||||
- "centos7.9.2009"
|
||||
- "7.9.2009"
|
||||
multiarch: ""
|
||||
- name: node
|
||||
tags:
|
||||
- "18-alpine3.16" # Alpine here an below
|
||||
- "18.15-alpine3.16"
|
||||
- "18.15.0-alpine3.16"
|
||||
- "lts-alpine3.16"
|
||||
- "18-alpine"
|
||||
- "18-alpine3.17"
|
||||
- "18.15-alpine"
|
||||
- "18.15-alpine3.17"
|
||||
- "18.15.0-alpine3.17"
|
||||
- "lts-alpine3.17"
|
||||
- "18-bullseye-slim" # Debian here and below
|
||||
- "18-slim"
|
||||
- "18.15-bullseye-slim"
|
||||
- "18.15.0-bullseye-slim"
|
||||
- "18.15.0-slim"
|
||||
multiarch: "all"
|
||||
- name: nginx
|
||||
tags:
|
||||
- "1.23.3" # debian:bullseye-slim based here and below
|
||||
- "mainline"
|
||||
- "1.23"
|
||||
- "1.23.3-alpine" # Depends on alpine slim tags
|
||||
- "mainline-alpine"
|
||||
- "1.23-alpine"
|
||||
- "1.23.3-alpine-slim" # Based on alpine
|
||||
- "mainline-alpine-slim"
|
||||
- "1.23-alpine-slim"
|
||||
multiarch: ""
|
||||
- name: python
|
||||
tags:
|
||||
- "3.8.16-alpine3.17"
|
||||
- "3.8.16-alpine3.16"
|
||||
- "3.8.16-bullseye"
|
||||
multiarch: ""
|
||||
- name: golang
|
||||
tags:
|
||||
- "1.20.2-bullseye"
|
||||
- "1.20.2-alpine3.17"
|
||||
multiarch: ""
|
||||
- name: perl
|
||||
tags:
|
||||
- "5.36.0-slim" # debian:bullseye-slim based
|
||||
multiarch: ""
|
||||
- name: ruby
|
||||
tags:
|
||||
- "3.2.1-slim-bullseye" # debian:bullseye-slim based
|
||||
multiarch: ""
|
||||
- name: busybox
|
||||
tags:
|
||||
- "1.36.0" # From scratch
|
||||
- "1.35.0"
|
||||
multiarch: ""
|
||||
- name: httpd
|
||||
tags:
|
||||
- "2.4.56-alpine3.17"
|
||||
multiarch: ""
|
||||
- name: hello-world # From scratch
|
||||
tags:
|
||||
- "linux"
|
||||
multiarch: ""
|
||||
- name: bash
|
||||
tags:
|
||||
- "5.2.15-alpine3.16"
|
||||
multiarch: ""
|
||||
- name: rust
|
||||
tags:
|
||||
- "1.68-slim-bullseye"
|
||||
multiarch: ""
|
148
tests/scripts/load_test_data.py
Executable file
148
tests/scripts/load_test_data.py
Executable file
@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
def init_logger(debug=False):
|
||||
logger = logging.getLogger(__name__)
|
||||
if debug:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.INFO)
|
||||
# log format
|
||||
log_fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
date_fmt = "%Y-%m-%d %H:%M:%S"
|
||||
formatter = logging.Formatter(log_fmt, date_fmt)
|
||||
# create streamHandler and set log fmt
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(formatter)
|
||||
# add the streamHandler to logger
|
||||
logger.addHandler(stream_handler)
|
||||
return logger
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('-r', '--registry', default='localhost:8080', help='Registry address')
|
||||
p.add_argument('-u', '--username', default="", help='registry username')
|
||||
p.add_argument('-p', '--password', default="", help='registry password')
|
||||
p.add_argument('-c', '--cosign-password', default="", help='cosign key password')
|
||||
p.add_argument('-d', '--debug', action='store_true', help='enable debug logs')
|
||||
p.add_argument('-f', '--config-file', default="config.yaml", help='config file containing information about images to upload')
|
||||
p.add_argument('-m', '--metadata-file', default="image_metadata.json", help='file containing metadata on uploaded images')
|
||||
p.add_argument('--data-dir', default="", help='location where to store image related data')
|
||||
|
||||
return p.parse_args()
|
||||
|
||||
def fetch_tags(logger, image_name):
|
||||
cmd = "skopeo list-tags docker://docker.io/{}".format(image_name)
|
||||
logger.info("running command: '{}'".format(cmd))
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, shell=True)
|
||||
if result.returncode != 0:
|
||||
logger.error("running command `{}` exited with code: {}".format(cmd, str(result.returncode)))
|
||||
logger.error(result.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return json.loads(result.stdout)["Tags"]
|
||||
|
||||
def pull_modify_push_image(logger, registry, image_name, tag, cosign_password,
|
||||
multiarch, username, password, debug, data_dir):
|
||||
logger.info("image '{}:{}' will be processed and pushed".format(image_name, tag))
|
||||
|
||||
image_update_script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pull_update_push_image.sh")
|
||||
|
||||
with tempfile.TemporaryDirectory() as meta_dir_name:
|
||||
metafile = '{}_{}_metadata.json'.format(image_name, tag)
|
||||
metafile = os.path.join(meta_dir_name, metafile)
|
||||
|
||||
cmd = [image_update_script_path, "-r", registry, "-i", image_name, "-t", tag, "-f", metafile]
|
||||
|
||||
if data_dir:
|
||||
cmd.extend(["--data-dir", data_dir])
|
||||
|
||||
if username:
|
||||
cmd.extend(["-u", username, "-p", password])
|
||||
|
||||
if cosign_password:
|
||||
cmd.extend(["-c", cosign_password])
|
||||
|
||||
if multiarch:
|
||||
cmd.extend(["-m", multiarch])
|
||||
|
||||
if debug:
|
||||
cmd.append("-d")
|
||||
|
||||
logger.info("running command: '{}'".format(" ".join(cmd)))
|
||||
result = subprocess.run(cmd, stderr=sys.stderr, stdout=sys.stdout)
|
||||
if result.returncode != 0:
|
||||
logger.error("pushing image `{}:{}` exited with code: ".format(image_name, tag) + str(result.returncode))
|
||||
sys.exit(1)
|
||||
|
||||
with open(metafile) as f:
|
||||
image_metadata = json.load(f)
|
||||
image_metadata[image_name][tag]["multiarch"] = multiarch
|
||||
|
||||
return image_metadata
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
registry = args.registry
|
||||
username = args.username
|
||||
password = args.password
|
||||
cosign_password = args.cosign_password
|
||||
config_file = args.config_file
|
||||
debug = args.debug
|
||||
metadata_file = args.metadata_file
|
||||
data_dir= args.data_dir
|
||||
|
||||
logger = init_logger(debug)
|
||||
|
||||
with open(config_file, "r") as f:
|
||||
config = yaml.load(f, Loader=yaml.SafeLoader)
|
||||
|
||||
metadata = {}
|
||||
|
||||
for image in config["images"]:
|
||||
image_name = image["name"]
|
||||
|
||||
multiarch = image["multiarch"]
|
||||
if not multiarch:
|
||||
multiarch = ""
|
||||
|
||||
actual_tags = fetch_tags(logger, image_name)
|
||||
expected_tags = image["tags"]
|
||||
|
||||
logger.debug("image '{}' has the following tags specified in the config file: '{}'".format(image_name, ",".join(expected_tags)))
|
||||
logger.debug("image '{}' has the following tags on source registry: '{}'".format(image_name, ",".join(actual_tags)))
|
||||
|
||||
for tag in expected_tags:
|
||||
found = False
|
||||
|
||||
for actual_tag in actual_tags:
|
||||
if actual_tag == tag:
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
logger.error("image '{}:{}' not found".format(image_name, tag))
|
||||
sys.exit(1)
|
||||
|
||||
for tag in expected_tags:
|
||||
image_metadata = pull_modify_push_image(logger, registry, image_name, tag, cosign_password, multiarch, username, password, debug, data_dir)
|
||||
|
||||
metadata.setdefault(image_name, {})
|
||||
metadata[image_name][tag] = image_metadata[image_name][tag]
|
||||
|
||||
with open(metadata_file, "w") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
logger.info("Done loading images, see more details in '{}'".format(metadata_file))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
229
tests/scripts/pull_update_push_image.sh
Executable file
229
tests/scripts/pull_update_push_image.sh
Executable file
@ -0,0 +1,229 @@
|
||||
#!/bin/bash
|
||||
|
||||
registry=""
|
||||
image=""
|
||||
tag=""
|
||||
cosign_password=""
|
||||
metafile=""
|
||||
multiarch=""
|
||||
username=""
|
||||
username=""
|
||||
debug=0
|
||||
data_dir=$(pwd)
|
||||
|
||||
while (( "$#" )); do
|
||||
case $1 in
|
||||
-r|--registry)
|
||||
if [ -z "$2" ]; then
|
||||
echo "Option registry requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
registry=$2;
|
||||
shift 2
|
||||
;;
|
||||
-i|--image)
|
||||
if [ -z "$2" ]; then
|
||||
echo "Option image requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
image=$2
|
||||
shift 2
|
||||
;;
|
||||
-t|--tag)
|
||||
if [ -z "$2" ]; then
|
||||
echo "Option tag requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
tag=$2
|
||||
shift 2
|
||||
;;
|
||||
-u|--username)
|
||||
if [ -z "$2" ]; then
|
||||
echo "Option username requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
username=$2
|
||||
shift 2
|
||||
;;
|
||||
-p|--password)
|
||||
if [ -z "$2" ]; then
|
||||
echo "Option password requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
password=$2
|
||||
shift 2
|
||||
;;
|
||||
-c|--cosign-password)
|
||||
if [ -z "$2" ]; then
|
||||
echo "Option cosign-password requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
cosign_password=$2
|
||||
shift 2
|
||||
;;
|
||||
-m|--multiarch)
|
||||
if [ -z "$2" ]; then
|
||||
echo "Option multiarch requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
multiarch=$2
|
||||
shift 2
|
||||
;;
|
||||
-f|--file)
|
||||
if [ -z "$2" ]; then
|
||||
echo "Option metafile requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
metafile=$2
|
||||
shift 2
|
||||
;;
|
||||
--data-dir)
|
||||
if [ -z "$2" ]; then
|
||||
echo "Option data-dir requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
data_dir=$2
|
||||
shift 2
|
||||
;;
|
||||
-d|--debug)
|
||||
debug=1
|
||||
shift 1
|
||||
;;
|
||||
--)
|
||||
shift 1
|
||||
break
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ${debug} -eq 1 ]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
images_dir=${data_dir}/images
|
||||
docker_docs_dir=${data_dir}/docs
|
||||
cosign_key_path=${data_dir}/cosign.key
|
||||
|
||||
function verify_prerequisites {
|
||||
mkdir -p ${data_dir}
|
||||
|
||||
if [ ! command -v regctl ] &>/dev/null; then
|
||||
echo "you need to install regctl as a prerequisite" >&3
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! command -v skopeo ] &>/dev/null; then
|
||||
echo "you need to install skopeo as a prerequisite" >&3
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! command -v cosign ] &>/dev/null; then
|
||||
echo "you need to install cosign as a prerequisite" >&3
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! command -v jq ] &>/dev/null; then
|
||||
echo "you need to install jq as a prerequisite" >&3
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${cosign_key_path}" ]; then
|
||||
COSIGN_PASSWORD=${cosign_password} cosign generate-key-pair
|
||||
key_dir=$(dirname ${cosign_key_path})
|
||||
mv cosign.key ${cosign_key_path}
|
||||
mv cosign.pub ${key_dir}
|
||||
fi
|
||||
|
||||
# pull docker docs repo
|
||||
if [ ! -d ${docker_docs_dir} ]
|
||||
then
|
||||
git clone https://github.com/docker-library/docs.git ${docker_docs_dir}
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
verify_prerequisites
|
||||
|
||||
repo=$(cat ${docker_docs_dir}/${image}/github-repo)
|
||||
description="$(cat ${docker_docs_dir}/${image}/README-short.txt)"
|
||||
license="$(cat ${docker_docs_dir}/${image}/license.md)"
|
||||
vendor="$(cat ${docker_docs_dir}/${image}/maintainer.md)"
|
||||
logo=$(base64 -w 0 ${docker_docs_dir}/${image}/logo.png)
|
||||
echo ${repo}
|
||||
sed -i.bak "s|%%GITHUB-REPO%%|${repo}|g" ${docker_docs_dir}/${image}/maintainer.md; rm ${docker_docs_dir}/${image}/maintainer.md.bak
|
||||
sed -i.bak "s|%%IMAGE%%|${image}|g" ${docker_docs_dir}/${image}/content.md; rm ${docker_docs_dir}/${image}/content.md.bak
|
||||
doc=$(cat ${docker_docs_dir}/${image}/content.md)
|
||||
|
||||
local_image_ref_skopeo=oci:${images_dir}:${image}-${tag}
|
||||
local_image_ref_regtl=ocidir://${images_dir}:${image}-${tag}
|
||||
remote_src_image_ref=docker://${image}:${tag}
|
||||
remote_dest_image_ref=${registry}/${image}:${tag}
|
||||
|
||||
multiarch_arg=""
|
||||
if [ ! -z "${multiarch}" ]; then
|
||||
multiarch_arg="--multi-arch=${multiarch}"
|
||||
fi
|
||||
|
||||
# Verify if image is already present in local oci layout
|
||||
skopeo inspect ${local_image_ref_skopeo}
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Image ${local_image_ref_skopeo} found locally"
|
||||
else
|
||||
echo "Image ${local_image_ref_skopeo} will be copied"
|
||||
skopeo --insecure-policy copy --format=oci ${multiarch_arg} ${remote_src_image_ref} ${local_image_ref_skopeo}
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Mofify image in local oci layout and update the old reference to point to the new index
|
||||
regctl image mod --replace --annotation org.opencontainers.image.title=${image} ${local_image_ref_regtl}
|
||||
regctl image mod --replace --annotation org.opencontainers.image.description="${description}" ${local_image_ref_regtl}
|
||||
regctl image mod --replace --annotation org.opencontainers.image.url=${repo} ${local_image_ref_regtl}
|
||||
regctl image mod --replace --annotation org.opencontainers.image.source=${repo} ${local_image_ref_regtl}
|
||||
regctl image mod --replace --annotation org.opencontainers.image.licenses="${license}" ${local_image_ref_regtl}
|
||||
regctl image mod --replace --annotation org.opencontainers.image.vendor="${vendor}" ${local_image_ref_regtl}
|
||||
regctl image mod --replace --annotation org.opencontainers.image.documentation="${description}" ${local_image_ref_regtl}
|
||||
|
||||
credentials_args=""
|
||||
if [ ! -z "${username}" ]; then
|
||||
credentials_args="--dest-creds ${username}:${username}"
|
||||
fi
|
||||
|
||||
# Upload image to target registry
|
||||
skopeo copy --dest-tls-verify=false ${multiarch_arg} ${credentials_args} ${local_image_ref_skopeo} docker://${remote_dest_image_ref}
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upload image logo as image media type
|
||||
regctl artifact put --annotation artifact.type=com.zot.logo.image --annotation format=oci \
|
||||
--artifact-type "application/vnd.zot.logo.v1" --subject ${remote_dest_image_ref} ${remote_dest_image_ref}-logo-image << EOF
|
||||
${logo}
|
||||
EOF
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sign new updated image
|
||||
COSIGN_PASSWORD=${cosign_password} cosign sign ${remote_dest_image_ref} --key ${cosign_key_path} --allow-insecure-registry
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
details=$(jq -n \
|
||||
--arg org.opencontainers.image.title "${image}" \
|
||||
--arg org.opencontainers.image.description " $description" \
|
||||
--arg org.opencontainers.image.url "${repo}" \
|
||||
--arg org.opencontainers.image.source "${repo}" \
|
||||
--arg org.opencontainers.image.licenses "${license}" \
|
||||
--arg org.opencontainers.image.vendor "${vendor}" \
|
||||
--arg org.opencontainers.image.documentation "${description}" \
|
||||
'$ARGS.named'
|
||||
)
|
||||
|
||||
jq -n --arg image "${image}" --arg tag "${tag}" --argjson details "${details}" '.[$image][$tag]=$details' > ${metafile}
|
Reference in New Issue
Block a user