10 Commits

Author SHA1 Message Date
6cda89c710 fix: change 'csv' to 'CSV' in the vulnerabilities download options list (#413)
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2024-01-17 14:16:46 +02:00
12b474e126 fix: update zot documentation urls (#411)
resolves #410

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2024-01-12 13:47:16 +02:00
a9db66bd34 feat: add freebsd as an OS filter (#407) (#408)
Signed-off-by: Doug Rabson <dfr@rabson.org>
2023-12-28 14:52:27 +02:00
f4600b8b79 feat: export vulnerabilities list
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2023-12-20 09:23:47 -08:00
c375c0697a feat: added button to delete tag
Signed-off-by: Petu Eusebiu <peusebiu@cisco.com>
2023-12-15 15:32:28 -08:00
2e1e2e92b7 fix: show a loading message while waiting for a response
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2023-12-11 13:14:30 -08:00
d9370fb9c1 feat: starred repos implementation (#399)
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2023-12-07 16:36:54 +02:00
e97e04eee5 ci: dco job should run only on PRs (#396)
See also message in b919279eef

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2023-11-28 11:23:15 +02:00
a288523a3f feat: vulnerability chips - show icon before string (#392)
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2023-11-28 10:51:23 +02:00
fad5572db4 feat: add prefix zot to /auth urls (#389)
See: https://github.com/project-zot/zot/issues/1883

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2023-10-20 13:17:24 +03:00
28 changed files with 706 additions and 84 deletions

View File

@ -2,16 +2,18 @@
name: DCO
on:
pull_request:
push:
branches:
- main
permissions: read-all
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.x
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Check DCO

View File

@ -94,7 +94,7 @@ jobs:
- name: Build zot
run: |
cd $GITHUB_WORKSPACE/zot
make binary
make binary ZUI_BUILD_PATH=$GITHUB_WORKSPACE/build
ls -l bin/
- name: Bringup zot server

103
package-lock.json generated
View File

@ -19,6 +19,7 @@
"@testing-library/user-event": "^13.5.0",
"axios": "^0.24.0",
"downshift": "^6.1.12",
"export-from-json": "^1.7.3",
"lodash": "^4.17.21",
"luxon": "^2.5.2",
"markdown-to-jsx": "^7.1.7",
@ -26,7 +27,8 @@
"react-dom": "^17.0.2",
"react-router-dom": "^6.2.1",
"react-sticky-el": "^2.0.9",
"web-vitals": "^2.1.3"
"web-vitals": "^2.1.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.16.7",
@ -5592,6 +5594,14 @@
"node": ">=8.9"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -6547,6 +6557,18 @@
"node": ">=4"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -6780,6 +6802,14 @@
"node": ">=4"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect-v8-coverage": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
@ -7030,6 +7060,17 @@
"node": ">=10"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -8926,6 +8967,11 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/export-from-json": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/export-from-json/-/export-from-json-1.7.3.tgz",
"integrity": "sha512-Xg0L0saYz+CBz2MnaZvSEAHr17hWtHAfFWXw/frllG9t6aijuQukiU40ElOeM9nDTrtQPhLJMLN0q8lo897FYg=="
},
"node_modules/express": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
@ -9423,6 +9469,14 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
@ -16997,6 +17051,17 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
@ -18746,6 +18811,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
@ -19133,6 +19214,26 @@
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",

View File

@ -14,6 +14,7 @@
"@testing-library/user-event": "^13.5.0",
"axios": "^0.24.0",
"downshift": "^6.1.12",
"export-from-json": "^1.7.3",
"lodash": "^4.17.21",
"luxon": "^2.5.2",
"markdown-to-jsx": "^7.1.7",
@ -21,17 +22,18 @@
"react-dom": "^17.0.2",
"react-router-dom": "^6.2.1",
"react-sticky-el": "^2.0.9",
"web-vitals": "^2.1.3"
"web-vitals": "^2.1.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.16.7",
"@playwright/test": "^1.28.1",
"eslint": "^8.23.1",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.8",
"prettier": "^2.7.1",
"react-scripts": "^5.0.1",
"@babel/plugin-proposal-private-property-in-object": "^7.16.7"
"react-scripts": "^5.0.1"
},
"scripts": {
"start": "react-scripts start",

View File

@ -34,6 +34,7 @@ const mockImageList = {
Size: '2806985',
LastUpdated: '2022-08-09T17:19:53.274069586Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: 'w',
@ -58,6 +59,7 @@ const mockImageList = {
Size: '231383863',
LastUpdated: '2022-08-02T01:30:49.193203152Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
@ -82,6 +84,7 @@ const mockImageList = {
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
@ -106,6 +109,7 @@ const mockImageList = {
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
@ -130,6 +134,7 @@ const mockImageList = {
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
@ -158,6 +163,7 @@ const mockImageList = {
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
@ -182,6 +188,7 @@ const mockImageList = {
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
@ -338,4 +345,13 @@ describe('Explore component', () => {
await userEvent.click(bookmarkButton);
expect(await screen.findAllByTestId('bookmarked')).toHaveLength(1);
});
it('should star a repo if star button is clicked', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
render(<StateExploreWrapper />);
const starButton = (await screen.findAllByTestId('star-button'))[0];
jest.spyOn(api, 'put').mockResolvedValueOnce({ status: 200, data: {} });
await userEvent.click(starButton);
expect(await screen.findAllByTestId('starred')).toHaveLength(1);
});
});

View File

@ -21,7 +21,7 @@ const StateFilterCardWrapper = () => {
describe('Filters components', () => {
it('renders the filters cards', async () => {
render(<StateFilterCardWrapper />);
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
expect(screen.getAllByRole('checkbox')).toHaveLength(3);
const checkbox = screen.getAllByRole('checkbox');
expect(checkbox[0]).not.toBeChecked();

View File

@ -164,6 +164,48 @@ const mockImageListBookmarks = {
}
};
const mockImageListStars = {
GlobalSearch: {
Page: { TotalCount: 3, ItemCount: 2 },
Repos: [
{
Name: 'alpine',
Size: '2806985',
LastUpdated: '2022-08-09T17:19:53.274069586Z',
NewestImage: {
Tag: 'latest',
Description: 'w',
IsSigned: false,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'LOW',
Count: 7
}
}
},
{
Name: 'mongo',
Size: '231383863',
LastUpdated: '2022-08-02T01:30:49.193203152Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'HIGH',
Count: 2
}
}
}
]
}
};
beforeEach(() => {
window.scrollTo = jest.fn();
});
@ -178,8 +220,8 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />);
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(3));
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(3));
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(4));
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(4));
await waitFor(() => expect(screen.getAllByText(/node/i)).toHaveLength(1));
});
@ -187,16 +229,16 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />);
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(3);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(5);
});
it('renders vulnerability icons', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />);
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(3);
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(3);
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('critical-vulnerability-icon')).toHaveLength(1);
});
@ -204,16 +246,17 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<HomeWrapper />);
await waitFor(() => expect(error).toBeCalledTimes(3));
await waitFor(() => expect(error).toBeCalledTimes(4));
});
it('should redirect to explore page when clicking view all popular', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListBookmarks } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListStars } });
render(<HomeWrapper />);
const viewAllButtons = await screen.findAllByText(/view all/i);
expect(viewAllButtons).toHaveLength(3);
expect(viewAllButtons).toHaveLength(4);
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: [] } });
fireEvent.click(viewAllButtons[0]);
expect(mockedUsedNavigate).toHaveBeenCalledWith({
@ -230,5 +273,10 @@ describe('Home component', () => {
pathname: `/explore`,
search: createSearchParams({ filter: 'IsBookmarked' }).toString()
});
fireEvent.click(viewAllButtons[3]);
expect(mockedUsedNavigate).toHaveBeenCalledWith({
pathname: `/explore`,
search: createSearchParams({ filter: 'IsStarred' }).toString()
});
});
});

View File

@ -47,6 +47,7 @@ const mockRepoDetailsData = {
Size: '451554070',
Vendors: ['[The Node.js Docker Team](https://github.com/nodejs/docker-node)\n'],
IsBookmarked: false,
IsStarred: false,
NewestImage: {
RepoName: 'mongo',
IsSigned: true,
@ -316,4 +317,13 @@ describe('Repo details component', () => {
await userEvent.click(bookmarkButton);
expect(await screen.findByTestId('bookmarked')).toBeInTheDocument();
});
it('should star a repo if star button is clicked', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockRepoDetailsData } });
render(<RepoDetailsThemeWrapper />);
const starButton = await screen.findByTestId('star-button');
jest.spyOn(api, 'put').mockResolvedValue({ status: 200, data: {} });
await userEvent.click(starButton);
expect(await screen.findByTestId('starred')).toBeInTheDocument();
});
});

View File

@ -22,6 +22,7 @@ const mockedTagsData = [
{
tag: 'latest',
vendor: 'test1',
isDeletable: true,
manifests: [
{
lastUpdated: '2022-07-19T18:06:18.818788283Z',
@ -37,6 +38,7 @@ const mockedTagsData = [
{
tag: 'bullseye',
vendor: 'test1',
isDeletable: true,
manifests: [
{
digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559',
@ -52,6 +54,7 @@ const mockedTagsData = [
{
tag: '1.5.2',
vendor: 'test1',
isDeletable: true,
manifests: [
{
lastUpdated: '2022-07-19T18:06:18.818788283Z',
@ -76,6 +79,18 @@ describe('Tags component', () => {
await waitFor(() => expect(screen.queryByText(/OS\/ARCH/i)).not.toBeInTheDocument());
});
it('should see delete tag button and its dialog', async () => {
render(<TagsThemeWrapper />);
const deleteBtn = await screen.findAllByTestId('DeleteIcon');
fireEvent.click(deleteBtn[0]);
expect(await screen.findByTestId('delete-dialog')).toBeInTheDocument();
const confirmBtn = await screen.findByTestId('confirm-delete');
expect(confirmBtn).toBeInTheDocument();
fireEvent.click(confirmBtn);
expect(await screen.findByTestId('confirm-delete')).toBeInTheDocument();
expect(await screen.findByTestId('cancel-delete')).toBeInTheDocument();
});
it('should navigate to tag page details when tag is clicked', async () => {
render(<TagsThemeWrapper />);
const tagLink = await screen.findByText('latest');

View File

@ -6,6 +6,8 @@ import VulnerabilitiesDetails from 'components/Tag/Tabs/VulnerabilitiesDetails';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
jest.mock('xlsx');
const StateVulnerabilitiesWrapper = () => {
return (
<MockThemeProvider>
@ -558,6 +560,32 @@ describe('Vulnerabilties page', () => {
expect(await screen.findByText('latest')).toBeInTheDocument();
});
it('should allow export of vulnerabilities list', async () => {
const xlsxMock = jest.createMockFromModule('xlsx');
xlsxMock.writeFile = jest.fn();
jest
.spyOn(api, 'get')
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
const downloadBtn = await screen.findAllByTestId('DownloadIcon');
fireEvent.click(downloadBtn[0]);
expect(await screen.findByTestId('export-csv-menuItem')).toBeInTheDocument();
expect(await screen.findByTestId('export-excel-menuItem')).toBeInTheDocument();
const exportAsCSVBtn = screen.getByText(/CSV/i);
expect(exportAsCSVBtn).toBeInTheDocument();
global.URL.createObjectURL = jest.fn();
await fireEvent.click(exportAsCSVBtn);
expect(await screen.findByTestId('export-csv-menuItem')).not.toBeInTheDocument();
fireEvent.click(downloadBtn[0]);
const exportAsExcelBtn = screen.getByText(/MS Excel/i);
expect(exportAsExcelBtn).toBeInTheDocument();
await fireEvent.click(exportAsExcelBtn);
expect(await screen.findByTestId('export-excel-menuItem')).not.toBeInTheDocument();
});
it('should handle fixed CVE query errors', async () => {
jest
.spyOn(api, 'get')

View File

@ -79,14 +79,15 @@ const api = {
const endpoints = {
status: `/v2/`,
authConfig: `/v2/_zot/ext/mgmt`,
openidAuth: `/auth/login`,
logout: `/auth/logout`,
openidAuth: `/zot/auth/login`,
logout: `/zot/auth/logout`,
deleteImage: (name, tag) => `/v2/${name}/manifests/${tag}`,
repoList: ({ pageNumber = 1, pageSize = 15 } = {}) =>
`/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
}}){Results {Name LastUpdated Size Platforms {Os Arch} NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description Licenses Title Source IsSigned SignatureInfo { Tool IsTrusted Author } Documentation Vendor Labels} IsStarred IsBookmarked DownloadCount}}}`,
}}){Results {Name LastUpdated Size Platforms {Os Arch} NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description Licenses Title Source IsSigned SignatureInfo { Tool IsTrusted Author } Documentation Vendor Labels} IsStarred IsBookmarked StarCount DownloadCount}}}`,
detailedRepoInfo: (name) =>
`/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 IsStarred IsBookmarked NewestImage {RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Vendor 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 IsDeletable } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors IsStarred IsBookmarked NewestImage {RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Vendor Title Documentation DownloadCount Source Description Licenses}}}}`,
detailedImageInfo: (name, tag) =>
`/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned SignatureInfo { Tool IsTrusted Author } 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 = '') => {
@ -98,6 +99,8 @@ const endpoints = {
}
return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`;
},
allVulnerabilitiesForRepo: (name) =>
`/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}}}}`,
imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }, filter = {}) => {
let filterParam = '';
if (filter.Os || filter.Arch) {
@ -134,9 +137,10 @@ const endpoints = {
if (filter.Arch) filterParam += ` Arch:${!isEmpty(filter.Arch) ? `${JSON.stringify(filter.Arch)}` : '""'}`;
if (filter.HasToBeSigned) filterParam += ` HasToBeSigned: ${filter.HasToBeSigned}`;
if (filter.IsBookmarked) filterParam += ` IsBookmarked: ${filter.IsBookmarked}`;
if (filter.IsStarred) filterParam += ` IsStarred: ${filter.IsStarred}`;
filterParam += '}';
if (Object.keys(filter).length === 0) filterParam = '';
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam} ${filterParam}) {Page {TotalCount ItemCount} Repos {Name LastUpdated Size Platforms { Os Arch } IsStarred IsBookmarked NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description IsSigned SignatureInfo { Tool IsTrusted Author } Licenses Vendor Labels } DownloadCount}}}`;
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam} ${filterParam}) {Page {TotalCount ItemCount} Repos {Name LastUpdated Size Platforms { Os Arch } IsStarred IsBookmarked NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description IsSigned SignatureInfo { Tool IsTrusted Author } Licenses Vendor Labels } StarCount DownloadCount}}}`;
},
imageSuggestions: ({ searchQuery = '""', pageNumber = 1, pageSize = 15 }) => {
const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`;
@ -145,7 +149,8 @@ const endpoints = {
},
referrers: ({ repo, digest, type = '' }) =>
`/v2/_zot/ext/search?query={Referrers(repo: "${repo}" digest: "${digest}" type: "${type}"){MediaType ArtifactType Size Digest Annotations{Key Value}}}`,
bookmarkToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleBookmark`
bookmarkToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleBookmark`,
starToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleStar`
};
export { api, endpoints };

View File

@ -220,9 +220,11 @@ function Explore({ searchInputValue }) {
version={item.latestVersion}
description={item.description}
downloads={item.downloads}
stars={item.stars}
isSigned={item.isSigned}
signatureInfo={item.signatureInfo}
isBookmarked={item.isBookmarked}
isStarred={item.isStarred}
vendor={item.vendor}
platforms={item.platforms}
key={index}

View File

@ -149,14 +149,14 @@ function Header({ setSearchCurrentValue = () => {} }) {
</Link>
</Grid>
<Grid item className={classes.headerLinkContainer}>
<a className={classes.link} href="https://zotregistry.io" target="_blank" rel="noreferrer">
<a className={classes.link} href="https://zotregistry.dev" target="_blank" rel="noreferrer">
Product
</a>
</Grid>
<Grid item className={classes.headerLinkContainer}>
<a
className={classes.link}
href="https://zotregistry.io/v1.4.3/general/concepts/"
href="https://zotregistry.dev/v2.0.0/general/concepts/"
target="_blank"
rel="noreferrer"
>

View File

@ -295,12 +295,26 @@ function SearchSuggestion({ setSearchCurrentValue = () => {} }) {
<List
{...getMenuProps()}
className={
isOpen && !isLoading && !isFailedSearch
isOpen && !isFailedSearch
? `${classes.resultsWrapper} ${isComponentFocused && classes.resultsWrapperFocused}`
: classes.resultsWrapperHidden
}
>
{isOpen && suggestionData?.length > 0 && renderSuggestions()}
{isOpen && isLoading && !isEmpty(searchQuery) && isEmpty(suggestionData) && (
<>
<ListItem
className={classes.searchItem}
style={{ color: '#52637A', fontSize: '1rem', textOverflow: 'ellipsis' }}
{...getItemProps({ item: '', index: 0 })}
spacing={2}
>
<Stack direction="row" spacing={2}>
<Typography>Loading...</Typography>
</Stack>
</ListItem>
</>
)}
{isOpen && isEmpty(searchQuery) && isEmpty(suggestionData) && (
<>
<ListItem

View File

@ -8,7 +8,12 @@ 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, HOME_BOOKMARKS_PAGE_SIZE } from 'utilities/paginationConstants';
import {
HOME_POPULAR_PAGE_SIZE,
HOME_RECENT_PAGE_SIZE,
HOME_BOOKMARKS_PAGE_SIZE,
HOME_STARS_PAGE_SIZE
} from 'utilities/paginationConstants';
import { isEmpty } from 'lodash';
import NoDataComponent from 'components/Shared/NoDataComponent';
@ -89,6 +94,8 @@ function Home() {
const [isLoadingRecent, setIsLoadingRecent] = useState(true);
const [bookmarkData, setBookmarkData] = useState([]);
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(true);
const [starData, setStarData] = useState([]);
const [isLoadingStars, setIsLoadingStars] = useState(true);
const navigate = useNavigate();
const abortController = useMemo(() => new AbortController(), []);
@ -185,12 +192,44 @@ function Home() {
});
};
const getStars = () => {
setIsLoadingStars(true);
api
.get(
`${host()}${endpoints.globalSearch({
searchQuery: '',
pageNumber: 1,
pageSize: HOME_STARS_PAGE_SIZE,
sortBy: sortByCriteria.relevance?.value,
filter: { IsStarred: true }
})}`,
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);
});
setStarData(repoData);
setIsLoading(false);
setIsLoadingStars(false);
}
})
.catch((e) => {
setIsLoading(false);
setIsLoadingStars(false);
console.error(e);
});
};
useEffect(() => {
window.scrollTo(0, 0);
setIsLoading(true);
getPopularData();
getRecentData();
getBookmarks();
getStars();
return () => {
abortController.abort();
};
@ -203,9 +242,11 @@ function Home() {
const isNoData = () =>
!isLoading &&
!isLoadingBookmarks &&
!isLoadingStars &&
!isLoadingPopular &&
!isLoadingRecent &&
bookmarkData.length === 0 &&
starData.length === 0 &&
popularData.length === 0 &&
recentData.length === 0;
@ -219,9 +260,11 @@ function Home() {
version={item.latestVersion}
description={item.description}
downloads={item.downloads}
stars={item.stars}
isSigned={item.isSigned}
signatureInfo={item.signatureInfo}
isBookmarked={item.isBookmarked}
isStarred={item.isStarred}
vendor={item.vendor}
platforms={item.platforms}
key={index}
@ -294,6 +337,27 @@ function Home() {
{isLoadingBookmarks ? <Loading /> : renderCards(bookmarkData, isLoadingBookmarks)}
</>
)}
{!isEmpty(starData) && (
<>
<Stack className={classes.sectionHeaderContainer}>
<div>
<Typography variant="h4" align="left" className={classes.sectionTitle}>
Stars
</Typography>
</div>
<div>
<Typography
variant="body2"
className={classes.viewAll}
onClick={() => handleClickViewAll('filter', 'IsStarred')}
>
View all
</Typography>
</div>
</Stack>
{isLoadingStars ? <Loading /> : renderCards(starData, isLoadingStars)}
</>
)}
</Stack>
);
};

View File

@ -14,6 +14,8 @@ import { useParams, useNavigate, createSearchParams } from 'react-router-dom';
import { Card, CardContent, CardMedia, Chip, Grid, Stack, Tooltip, Typography, IconButton } from '@mui/material';
import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import StarIcon from '@mui/icons-material/Star';
import StarBorderIcon from '@mui/icons-material/StarBorder';
import makeStyles from '@mui/styles/makeStyles';
// placeholder images
@ -195,6 +197,10 @@ function RepoDetails() {
};
}, [name]);
const handleDeleteTag = (removed) => {
setTags((prevState) => prevState.filter((tag) => tag.tag !== removed));
};
const handlePlatformChipClick = (event) => {
const { textContent } = event.target;
event.stopPropagation();
@ -221,7 +227,7 @@ function RepoDetails() {
const handleBookmarkClick = () => {
api.put(`${host()}${endpoints.bookmarkToggle(name)}`, abortController.signal).then((response) => {
if (response.status === 200) {
if (response && response.status === 200) {
setRepoDetailData((prevState) => ({
...prevState,
isBookmarked: !prevState.isBookmarked
@ -230,6 +236,17 @@ function RepoDetails() {
});
};
const handleStarClick = () => {
api.put(`${host()}${endpoints.starToggle(name)}`, abortController.signal).then((response) => {
if (response.status === 200) {
setRepoDetailData((prevState) => ({
...prevState,
isStarred: !prevState.isStarred
}));
}
});
};
const getVendor = () => {
return `${repoDetailData.newestTag?.Vendor || 'Vendor not available'}`;
};
@ -276,15 +293,26 @@ function RepoDetails() {
signatureInfo={repoDetailData.signatureInfo}
/>
</Stack>
{isAuthenticated() && (
<IconButton component="span" onClick={handleBookmarkClick} data-testid="bookmark-button">
{repoDetailData?.isBookmarked ? (
<BookmarkIcon data-testid="bookmarked" />
) : (
<BookmarkBorderIcon data-testid="not-bookmarked" />
)}
</IconButton>
)}
<Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={1}>
{isAuthenticated() && (
<IconButton component="span" onClick={handleStarClick} data-testid="star-button">
{repoDetailData?.isStarred ? (
<StarIcon data-testid="starred" />
) : (
<StarBorderIcon data-testid="not-starred" />
)}
</IconButton>
)}
{isAuthenticated() && (
<IconButton component="span" onClick={handleBookmarkClick} data-testid="bookmark-button">
{repoDetailData?.isBookmarked ? (
<BookmarkIcon data-testid="bookmarked" />
) : (
<BookmarkBorderIcon data-testid="not-bookmarked" />
)}
</IconButton>
)}
</Stack>
</Stack>
<Typography gutterBottom className={classes.repoTitle}>
{repoDetailData?.title || 'Title not available'}
@ -317,7 +345,7 @@ function RepoDetails() {
<Grid item xs={12} md={8} className={classes.tags}>
<Card className={classes.cardRoot}>
<CardContent className={classes.tagsContent}>
<Tags tags={tags} />
<Tags tags={tags} repoName={name} onTagDelete={handleDeleteTag} />
</CardContent>
</Card>
</Grid>

View File

@ -43,7 +43,7 @@ const useStyles = makeStyles(() => ({
export default function Tags(props) {
const classes = useStyles();
const { tags } = props;
const { tags, repoName, onTagDelete } = props;
const [tagsFilter, setTagsFilter] = useState('');
const [sortFilter, setSortFilter] = useState(tagsSortByCriteria.updateTimeDesc.value);
@ -63,6 +63,9 @@ export default function Tags(props) {
lastUpdated={tag.lastUpdated}
vendor={tag.vendor}
manifests={tag.manifests}
repo={repoName}
onTagDelete={onTagDelete}
isDeletable={tag.isDeletable}
/>
);
})

View File

@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { IconButton } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
// utility
import { api, endpoints } from '../../api';
// components
import DeleteTagConfirmDialog from 'components/Shared/DeleteTagConfirmDialog';
import { host } from '../../host';
export default function DeleteTag(props) {
const { repo, tag, onTagDelete } = props;
const [open, setOpen] = useState(false);
const handleClickOpen = () => {
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
const deleteTag = (repo, tag) => {
api
.delete(`${host()}${endpoints.deleteImage(repo, tag)}`)
.then((response) => {
if (response && response.status == 202) {
onTagDelete(tag);
}
})
.catch((err) => {
console.error(err);
});
};
const onConfirm = () => {
deleteTag(repo, tag);
};
return (
<React.Fragment>
<IconButton onClick={handleClickOpen}>
<DeleteIcon />
</IconButton>
<DeleteTagConfirmDialog
onClose={handleClose}
open={open}
title={`Permanently delete image ${repo}:${tag}?`}
onConfirm={onConfirm}
/>
</React.Fragment>
);
}

View File

@ -0,0 +1,30 @@
import React from 'react';
// components
import { Button, Dialog, DialogTitle, DialogActions } from '@mui/material';
export default function DeleteTagConfirmDialog(props) {
const { onClose, open, title, onConfirm } = props;
return (
<Dialog data-testid="delete-dialog" onClose={onClose} open={open} color="primary">
<DialogTitle> {title} </DialogTitle>
<DialogActions style={{ justifyContent: 'center' }}>
<Button data-testid="cancel-delete" variant="contained" onClick={onClose} color="primary">
Cancel
</Button>
<Button
data-testid="confirm-delete"
color="error"
variant="contained"
onClick={() => {
onConfirm();
onClose();
}}
>
Delete
</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -28,6 +28,8 @@ import {
import makeStyles from '@mui/styles/makeStyles';
import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import StarIcon from '@mui/icons-material/Star';
import StarBorderIcon from '@mui/icons-material/StarBorder';
import { useTheme } from '@emotion/react';
// placeholder images
@ -183,17 +185,24 @@ function RepoCard(props) {
platforms,
description,
downloads,
stars,
isSigned,
signatureInfo,
lastUpdated,
version,
vulnerabilityData,
isBookmarked
isBookmarked,
isStarred
} = props;
// keep a local bookmark state to display in the ui dynamically on updates
const [currentBookmarkValue, setCurrentBookmarkValue] = useState(isBookmarked);
// keep a local star state to display in the ui dynamically on updates
const [currentStarValue, setCurrentStarValue] = useState(isStarred);
const [currentStarCount, setCurrentStarCount] = useState(stars);
const goToDetails = () => {
navigate(`/image/${encodeURIComponent(name)}`);
};
@ -215,6 +224,23 @@ function RepoCard(props) {
});
};
const handleStarClick = (event) => {
event.stopPropagation();
event.preventDefault();
api.put(`${host()}${endpoints.starToggle(name)}`, abortController.signal).then((response) => {
if (response.status === 200) {
setCurrentStarValue((prevState) => !prevState);
currentStarValue
? setCurrentStarCount((prevState) => {
return !isNaN(prevState) ? prevState - 1 : prevState;
})
: setCurrentStarCount((prevState) => {
return !isNaN(prevState) ? prevState + 1 : prevState;
});
}
});
};
const platformChips = () => {
const filteredPlatforms = uniq(platforms?.flatMap((platform) => [platform.Os, platform.Arch]));
const hiddenChips = filteredPlatforms.length - MAX_PLATFORM_CHIPS;
@ -260,6 +286,16 @@ function RepoCard(props) {
);
};
const renderStar = () => {
return (
isAuthenticated() && (
<IconButton component="span" onClick={handleStarClick} data-testid="star-button">
{currentStarValue ? <StarIcon data-testid="starred" /> : <StarBorderIcon data-testid="not-starred" />}
</IconButton>
)
);
};
return (
<Card variant="outlined" className={classes.card} data-testid="repo-card">
<CardActionArea
@ -337,6 +373,15 @@ function RepoCard(props) {
#1
</Typography>
</Grid> */}
<Grid item xs={12}>
{renderStar()}
<Typography variant="body2" component="span" className={classes.contentRightLabel}>
Stars
</Typography>
<Typography variant="body2" component="span" className={classes.contentRightValue}>
{!isNaN(currentStarCount) ? currentStarCount : `not available`}
</Typography>
</Grid>
<Grid container item xs={12} className={classes.contentRightActions}>
<Grid item>{renderBookmark()}</Grid>
</Grid>

View File

@ -6,6 +6,7 @@ import { Markdown } from 'utilities/MarkdowntojsxWrapper';
import transform from 'utilities/transform';
import { DateTime } from 'luxon';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
import DeleteTag from 'components/Shared/DeleteTag';
const useStyles = makeStyles((theme) => ({
card: {
@ -78,9 +79,9 @@ const useStyles = makeStyles((theme) => ({
}));
export default function TagCard(props) {
const { repoName, tag, lastUpdated, vendor, manifests } = props;
const { repoName, tag, lastUpdated, vendor, manifests, repo, onTagDelete, isDeletable } = props;
const [open, setOpen] = useState(false);
const classes = useStyles();
const lastDate = lastUpdated
@ -99,9 +100,12 @@ export default function TagCard(props) {
return (
<Card className={classes.card} raised>
<CardContent className={classes.content}>
<Typography variant="body1" align="left" className={classes.tagHeading}>
Tag
</Typography>
<Stack direction="row" spacing={2} justifyContent="space-between">
<Typography variant="body1" align="left" className={classes.tagHeading}>
Tag
</Typography>
{isDeletable && <DeleteTag repo={repo} tag={tag} onTagDelete={onTagDelete} />}
</Stack>
<Typography variant="body1" align="left" className={classes.tagName} onClick={() => goToTags()}>
{repoName && `${repoName}:`}
{tag}

View File

@ -4,14 +4,28 @@ import React, { useEffect, useMemo, useState, useRef } from 'react';
import { api, endpoints } from '../../../api';
// components
import { Stack, Typography, InputBase } from '@mui/material';
import {
IconButton,
Stack,
Typography,
InputBase,
Menu,
MenuItem,
Divider,
Snackbar,
CircularProgress
} from '@mui/material';
import makeStyles from '@mui/styles/makeStyles';
import { host } from '../../../host';
import { debounce, isEmpty } from 'lodash';
import Loading from '../../Shared/Loading';
import { mapCVEInfo } from 'utilities/objectModels';
import { mapCVEInfo, mapAllCVEInfo } from 'utilities/objectModels';
import { EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants';
import SearchIcon from '@mui/icons-material/Search';
import DownloadIcon from '@mui/icons-material/Download';
import * as XLSX from 'xlsx';
import exportFromJSON from 'export-from-json';
import VulnerabilitiyCard from '../../Shared/VulnerabilityCard';
@ -40,6 +54,13 @@ const useStyles = makeStyles((theme) => ({
fontSize: '1.4rem',
fontWeight: '600'
},
vulnerabilities: {
position: 'relative',
maxWidth: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
},
search: {
position: 'relative',
maxWidth: '100%',
@ -65,13 +86,25 @@ const useStyles = makeStyles((theme) => ({
'&::placeholder': {
opacity: '1'
}
},
export: {
alignContent: 'right'
},
popper: {
width: '100%',
overflow: 'hidden',
padding: '0.3rem',
display: 'flex',
justifyContent: 'center'
}
}));
function VulnerabilitiesDetails(props) {
const classes = useStyles();
const [cveData, setCveData] = useState([]);
const [allCveData, setAllCveData] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingAllCve, setIsLoadingAllCve] = useState(true);
const abortController = useMemo(() => new AbortController(), []);
const { name, tag, digest, platform } = props;
@ -81,6 +114,9 @@ function VulnerabilitiesDetails(props) {
const [isEndOfList, setIsEndOfList] = useState(false);
const listBottom = useRef(null);
const [anchorExport, setAnchorExport] = useState(null);
const openExport = Boolean(anchorExport);
const getCVERequestName = () => {
return digest !== '' ? `${name}@${digest}` : `${name}:${tag}`;
};
@ -114,6 +150,24 @@ function VulnerabilitiesDetails(props) {
});
};
const getAllCVEs = () => {
api
.get(`${host()}${endpoints.allVulnerabilitiesForRepo(getCVERequestName())}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
const cveInfo = response.data.data.CVEListForImage?.CVEList;
const cveListData = mapAllCVEInfo(cveInfo);
setAllCveData(cveListData);
}
setIsLoadingAllCve(false);
})
.catch((e) => {
console.error(e);
setAllCveData([]);
setIsLoadingAllCve(false);
});
};
const resetPagination = () => {
setIsLoading(true);
setIsEndOfList(false);
@ -124,11 +178,39 @@ function VulnerabilitiesDetails(props) {
}
};
const handleOnExportExcel = () => {
const wb = XLSX.utils.book_new(),
ws = XLSX.utils.json_to_sheet(allCveData);
XLSX.utils.book_append_sheet(wb, ws, name + '_' + tag);
XLSX.writeFile(wb, `${name}:${tag}-vulnerabilities.xlsx`);
handleCloseExport();
};
const handleOnExportCSV = () => {
const fileName = `${name}:${tag}-vulnerabilities`;
const exportType = exportFromJSON.types.csv;
exportFromJSON({ data: allCveData, fileName, exportType });
handleCloseExport();
};
const handleCveFilterChange = (e) => {
const { value } = e.target;
setCveFilter(value);
};
const handleClickExport = (event) => {
setAnchorExport(event.currentTarget);
};
const handleCloseExport = () => {
setAnchorExport(null);
};
const debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300));
useEffect(() => {
@ -172,6 +254,12 @@ function VulnerabilitiesDetails(props) {
};
}, []);
useEffect(() => {
if (openExport && isEmpty(allCveData)) {
getAllCVEs();
}
}, [openExport]);
const renderCVEs = () => {
return !isEmpty(cveData) ? (
cveData.map((cve, index) => {
@ -194,9 +282,53 @@ function VulnerabilitiesDetails(props) {
return (
<Stack direction="column" spacing="1rem" data-testid="vulnerability-container">
<Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}>
Vulnerabilities
</Typography>
<Stack className={classes.vulnerabilities}>
<Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}>
Vulnerabilities
</Typography>
<IconButton disableRipple onClick={handleClickExport} className={classes.export}>
<DownloadIcon />
</IconButton>
<Snackbar
open={openExport && isLoadingAllCve}
message="Getting your data ready for export"
action={<CircularProgress size="2rem" sx={{ color: '#FFFFFF' }} />}
/>
<Menu
anchorEl={anchorExport}
open={openExport}
onClose={handleCloseExport}
data-testid="export-dropdown"
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
>
<MenuItem
onClick={handleOnExportCSV}
disableRipple
disabled={isLoadingAllCve}
className={classes.popper}
data-testid="export-csv-menuItem"
>
CSV
</MenuItem>
<Divider sx={{ my: 0.5 }} />
<MenuItem
onClick={handleOnExportExcel}
disableRipple
disabled={isLoadingAllCve}
className={classes.popper}
data-testid="export-excel-menuItem"
>
MS Excel
</MenuItem>
</Menu>
</Stack>
<Stack className={classes.search}>
<InputBase
placeholder={'Search'}

View File

@ -6,6 +6,10 @@ const osFilters = [
{
label: 'linux',
value: 'linux'
},
{
label: 'freebsd',
value: 'freebsd'
}
];
@ -19,6 +23,11 @@ const imageFilters = [
label: 'Bookmarks',
value: 'IsBookmarked',
type: 'boolean'
},
{
label: 'Starred Repositories',
value: 'IsStarred',
type: 'boolean'
}
];

View File

@ -15,6 +15,7 @@ const mapToRepo = (responseRepo) => {
logo: responseRepo.NewestImage?.Logo,
lastUpdated: responseRepo.LastUpdated,
downloads: responseRepo.DownloadCount,
stars: responseRepo.StarCount,
vulnerabiltySeverity: responseRepo.NewestImage?.Vulnerabilities?.MaxSeverity,
vulnerabilityCount: responseRepo.NewestImage?.Vulnerabilities?.Count
};
@ -33,6 +34,7 @@ const mapToRepoFromRepoInfo = (responseRepoInfo) => {
title: responseRepoInfo.Summary?.NewestImage?.Title,
source: responseRepoInfo.Summary?.NewestImage?.Source,
downloads: responseRepoInfo.Summary?.NewestImage?.DownloadCount,
stars: responseRepoInfo.Summary?.NewestImage?.StarCount,
overview: responseRepoInfo.Summary?.NewestImage?.Documentation,
license: responseRepoInfo.Summary?.NewestImage?.Licenses,
vulnerabilitySeverity: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.MaxSeverity,
@ -53,6 +55,7 @@ const mapToImage = (responseImage) => {
referrers: responseImage.Referrers,
size: responseImage.Size,
downloadCount: responseImage.DownloadCount,
starCount: responseImage.StarCount,
lastUpdated: responseImage.LastUpdated,
description: responseImage.Description,
isSigned: responseImage.IsSigned,
@ -66,6 +69,7 @@ const mapToImage = (responseImage) => {
authors: responseImage.Authors,
vulnerabiltySeverity: responseImage.Vulnerabilities?.MaxSeverity,
vulnerabilityCount: responseImage.Vulnerabilities?.Count,
isDeletable: responseImage.IsDeletable,
// frontend only prop to increase interop with Repo objects and code reusability
name: `${responseImage.RepoName}:${responseImage.Tag}`
};
@ -79,6 +83,7 @@ const mapToManifest = (responseManifest) => {
size: responseManifest.Size,
platform: responseManifest.Platform,
downloadCount: responseManifest.DownloadCount,
starCount: responseManifest.StarCount,
layers: responseManifest.Layers,
history: responseManifest.History,
vulnerabilities: responseManifest.Vulnerabilities
@ -97,6 +102,24 @@ const mapCVEInfo = (cveInfo) => {
return cveList;
};
const mapAllCVEInfo = (cveInfo) => {
const cveList = cveInfo.flatMap((cve) => {
return cve.PackageList.map((packageInfo) => {
return {
id: cve.Id,
severity: cve.Severity,
title: cve.Title,
description: cve.Description,
reference: cve.Reference,
packageName: packageInfo.Name,
packageInstalledVersion: packageInfo.InstalledVersion,
packageFixedVersion: packageInfo.FixedVersion
};
});
});
return cveList;
};
const mapSignatureInfo = (signatureInfo) => {
return signatureInfo
? {
@ -119,4 +142,4 @@ const mapReferrer = (referrer) => ({
annotations: referrer.Annotations?.map((annotation) => ({ key: annotation.Key, value: annotation.Value }))
});
export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapReferrer, mapToManifest };
export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapAllCVEInfo, mapReferrer, mapToManifest };

View File

@ -4,6 +4,7 @@ const HOME_PAGE_SIZE = 10;
const HOME_POPULAR_PAGE_SIZE = 3;
const HOME_RECENT_PAGE_SIZE = 2;
const HOME_BOOKMARKS_PAGE_SIZE = 2;
const HOME_STARS_PAGE_SIZE = 2;
const CVE_FIXEDIN_PAGE_SIZE = 5;
export {
@ -13,5 +14,6 @@ export {
CVE_FIXEDIN_PAGE_SIZE,
HOME_POPULAR_PAGE_SIZE,
HOME_RECENT_PAGE_SIZE,
HOME_BOOKMARKS_PAGE_SIZE
HOME_BOOKMARKS_PAGE_SIZE,
HOME_STARS_PAGE_SIZE
};

View File

@ -148,10 +148,7 @@ const NoneVulnerabilityChip = () => {
label="None"
sx={{ backgroundColor: '#E8F5E9', color: '#388E3C', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#388E3C!important' }} />}
icon={<OutlinedBugIcon sx={{ color: '#388E3C!important' }} />}
data-testid="none-vulnerability-chip"
/>
);
@ -162,10 +159,7 @@ const UnknownVulnerabilityChip = () => {
label="Unknown"
sx={{ backgroundColor: '#ECEFF1', color: '#52637A', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#52637A!important' }} />}
icon={<OutlinedBugIcon sx={{ color: '#52637A!important' }} />}
data-testid="unknown-vulnerability-chip"
/>
);
@ -176,10 +170,7 @@ const FailedScanChip = () => {
label="Failed to scan"
sx={{ backgroundColor: '#848484', color: '#F6F7F9', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<SvgIcon component={failedScanBug} sx={{ color: '#F6F7F9!important' }} />}
icon={<SvgIcon component={failedScanBug} sx={{ color: '#F6F7F9!important' }} />}
data-testid="failed-vulnerability-chip"
/>
);
@ -190,10 +181,7 @@ const LowVulnerabilityChip = () => {
label="Low"
sx={{ backgroundColor: '#FFF3E0', color: '#FB8C00', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#FB8C00!important' }} />}
icon={<OutlinedBugIcon sx={{ color: '#FB8C00!important' }} />}
data-testid="low-vulnerability-chip"
/>
);
@ -204,10 +192,7 @@ const MediumVulnerabilityChip = () => {
label="Medium"
sx={{ backgroundColor: '#FFF3E0', color: '#FB8C00', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<FilledBugIcon sx={{ color: '#FB8C00!important' }} />}
icon={<FilledBugIcon sx={{ color: '#FB8C00!important' }} />}
data-testid="medium-vulnerability-chip"
/>
);
@ -218,10 +203,7 @@ const HighVulnerabilityChip = () => {
label="High"
sx={{ backgroundColor: '#FEEBEE', color: '#E53935', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#E53935!important' }} />}
icon={<OutlinedBugIcon sx={{ color: '#E53935!important' }} />}
data-testid="high-vulnerability-chip"
/>
);
@ -232,10 +214,7 @@ const CriticalVulnerabilityChip = () => {
label="Critical"
sx={{ backgroundColor: '#FEEBEE', color: '#E53935', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<FilledBugIcon sx={{ color: '#E53935!important' }} />}
icon={<FilledBugIcon sx={{ color: '#E53935!important' }} />}
data-testid="critical-vulnerability-chip"
/>
);

View File

@ -76,8 +76,14 @@ test.describe('explore page test', () => {
await expect(exploreFirst).toBeVisible({ timeout: 250000 });
const windowsFilter = page.getByRole('checkbox', { name: 'windows' });
await linuxFilter.uncheck();
await page.getByRole('checkbox', { name: 'windows' }).check();
await windowsFilter.check();
await expect(exploreFirst).not.toBeVisible({ timeout: 250000 });
const freebsdFilter = page.getByRole('checkbox', { name: 'freebsd' });
await windowsFilter.uncheck();
await freebsdFilter.check();
await expect(exploreFirst).not.toBeVisible({ timeout: 250000 });
});
});

View File

@ -17,13 +17,13 @@ const pageSizes = {
};
const endpoints = {
repoList: `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage:%20{limit:15%20offset:0}){Results%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20%20Licenses%20Logo%20Title%20Source%20IsSigned%20Documentation%20Vendor%20Labels}%20DownloadCount}}}`,
repoList: `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage:%20{limit:15%20offset:0}){Results%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20%20Licenses%20Logo%20Title%20Source%20IsSigned%20Documentation%20Vendor%20Labels}%20StarCount%20DownloadCount}}}`,
detailedRepoInfo: (name) =>
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:%22${name}%22){Images%20{Manifests%20{Digest%20Platform%20{Os%20Arch}%20Size}%20Vulnerabilities%20{MaxSeverity%20Count}%20Tag%20LastUpdated%20Vendor%20}%20Summary%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20Vendors%20NewestImage%20{RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20Manifests%20{Digest}%20Tag%20Title%20Documentation%20DownloadCount%20Source%20Description%20Licenses}}}}`,
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:%22${name}%22){Images%20{Manifests%20{Digest%20Platform%20{Os%20Arch}%20Size}%20Vulnerabilities%20{MaxSeverity%20Count}%20Tag%20LastUpdated%20Vendor%20IsDeletable%20}%20Summary%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20Vendors%20NewestImage%20{RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20Manifests%20{Digest}%20Tag%20Title%20Documentation%20DownloadCount%20Source%20Description%20Licenses}}}}`,
globalSearch: (searchTerm, sortCriteria, pageNumber = 1, pageSize = 10) =>
`/v2/_zot/ext/search?query={GlobalSearch(query:%22${searchTerm}%22,%20requestedPage:%20{limit:${pageSize}%20offset:${
10 * (pageNumber - 1)
}%20sortBy:%20${sortCriteria}}%20)%20{Page%20{TotalCount%20ItemCount}%20Repos%20{Name%20LastUpdated%20Size%20Platforms%20{%20Os%20Arch%20}%20IsStarred%20IsBookmarked%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20IsSigned%20SignatureInfo%20{%20Tool%20IsTrusted%20Author%20}%20Licenses%20Vendor%20Labels%20}%20DownloadCount}}}`,
}%20sortBy:%20${sortCriteria}}%20)%20{Page%20{TotalCount%20ItemCount}%20Repos%20{Name%20LastUpdated%20Size%20Platforms%20{%20Os%20Arch%20}%20IsStarred%20IsBookmarked%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20IsSigned%20SignatureInfo%20{%20Tool%20IsTrusted%20Author%20}%20Licenses%20Vendor%20Labels%20}%20StarCount%20DownloadCount}}}`,
image: (name) =>
`/v2/_zot/ext/search?query={Image(image:%20%22${name}%22){RepoName%20IsSigned%20SignatureInfo%20{%20Tool%20IsTrusted%20Author%20}%20Vulnerabilities%20{MaxSeverity%20Count}%20%20Referrers%20{MediaType%20ArtifactType%20Size%20Digest%20Annotations{Key%20Value}}%20Tag%20Manifests%20{History%20{Layer%20{Size%20Digest}%20HistoryDescription%20{CreatedBy%20EmptyLayer}}%20Digest%20ConfigDigest%20LastUpdated%20Size%20Platform%20{Os%20Arch}}%20Vendor%20Licenses%20}}`
};