Compare commits
26 Commits
commit-70a
...
commit-6cd
Author | SHA1 | Date | |
---|---|---|---|
6cda89c710 | |||
12b474e126 | |||
a9db66bd34 | |||
f4600b8b79 | |||
c375c0697a | |||
2e1e2e92b7 | |||
d9370fb9c1 | |||
e97e04eee5 | |||
a288523a3f | |||
fad5572db4 | |||
19e366ee1f | |||
b41fb2f841 | |||
b787273b84 | |||
9ecd46e4d0 | |||
845726cd08 | |||
ac84c375c0 | |||
96008d67be | |||
087b42693f | |||
8f4c23bf40 | |||
54c764c996 | |||
44289c751f | |||
8086f6880d | |||
a55248774c | |||
936590d822 | |||
05d5f744b0 | |||
769ffdc60d |
8
.github/workflows/dco.yml
vendored
@ -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
|
||||
|
27
.github/workflows/end-to-end-test.yml
vendored
@ -23,6 +23,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Cleanup disk space
|
||||
run: |
|
||||
# To free up ~15 GB of disk space
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /usr/local/share/boost
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
|
||||
- name: Checkout zui repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
@ -86,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
|
||||
@ -111,7 +119,24 @@ jobs:
|
||||
regctl registry set --tls disabled $REGISTRY_HOST:$REGISTRY_PORT
|
||||
make test-data REGISTRY_HOST=$REGISTRY_HOST REGISTRY_PORT=$REGISTRY_PORT
|
||||
|
||||
- name: Install playwright dependencies
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
make playwright-browsers
|
||||
|
||||
- name: Trigger CVE scanning
|
||||
run: |
|
||||
# trigger CVE scanning for all images before running the tests
|
||||
curl -X POST -H "Content-Type: application/json" -m 600 --data '{ "query": "{ ImageListForCVE (id:\"CVE-2021-43616\") { Results { RepoName Tag } } }" }' http://$REGISTRY_HOST:$REGISTRY_PORT/v2/_zot/ext/search
|
||||
|
||||
- name: Run integration tests
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
make integration-tests REGISTRY_HOST=$REGISTRY_HOST REGISTRY_PORT=$REGISTRY_PORT
|
||||
|
||||
- name: Upload playwright report
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
5
.gitignore
vendored
@ -129,3 +129,8 @@ dist
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
data.md
|
||||
|
9
Makefile
@ -30,8 +30,13 @@ test-data:
|
||||
--registry $(REGISTRY_HOST):$(REGISTRY_PORT) \
|
||||
--data-dir tests/data \
|
||||
--config-file tests/data/config.yaml \
|
||||
--metadata-file tests/data/image_metadata.json
|
||||
--metadata-file tests/data/image_metadata.json \
|
||||
-d
|
||||
|
||||
.PHONY: playwright-browsers
|
||||
playwright-browsers:
|
||||
npx playwright install --with-deps
|
||||
|
||||
.PHONY: integration-tests
|
||||
integration-tests: # Triggering the tests TBD
|
||||
cat tests/data/image_metadata.json | jq
|
||||
UI_HOST=$(REGISTRY_HOST):$(REGISTRY_PORT) API_HOST=$(REGISTRY_HOST):$(REGISTRY_PORT) npm run test:ui
|
||||
|
17605
package-lock.json
generated
12
package.json
@ -5,7 +5,6 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.9.3",
|
||||
"@emotion/styled": "^11.9.3",
|
||||
"@mui-treasury/styles": "^1.13.1",
|
||||
"@mui/icons-material": "^5.2.5",
|
||||
"@mui/lab": "^5.0.0-alpha.89",
|
||||
"@mui/material": "^5.8.6",
|
||||
@ -15,17 +14,20 @@
|
||||
"@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",
|
||||
"nth-check": "^2.0.1",
|
||||
"react": "^17.0.2",
|
||||
"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",
|
||||
@ -38,6 +40,10 @@
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test --detectOpenHandles",
|
||||
"test:coverage": "react-scripts test --detectOpenHandles --coverage",
|
||||
"test:ui": "playwright test",
|
||||
"test:ui-headed": "playwright test --headed --trace on",
|
||||
"test:ui-debug": "playwright test --trace on",
|
||||
"test:release": "npm run test && npm run test:ui",
|
||||
"lint": "eslint -c .eslintrc.json --ext .js,.jsx .",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"format": "prettier --write ./**/*.{js,jsx,ts,tsx,css,md,json} --config ./.prettierrc",
|
||||
|
113
playwright.config.js
Normal file
@ -0,0 +1,113 @@
|
||||
// @ts-check
|
||||
const { devices } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
|
||||
/**
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
* @type {import('@playwright/test').PlaywrightTestConfig}
|
||||
*/
|
||||
const config = {
|
||||
testDir: './tests',
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 50 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 15000
|
||||
},
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 2,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: [['html', { open: 'never' }]],
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
ignoreHTTPSErrors: true
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
ignoreHTTPSErrors: true
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
ignoreHTTPSErrors: true
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: {
|
||||
...devices['Desktop Safari'],
|
||||
ignoreHTTPSErrors: true
|
||||
},
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: {
|
||||
// ...devices['Pixel 5'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: {
|
||||
// channel: 'msedge',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: {
|
||||
// channel: 'chrome',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
// outputDir: 'test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// port: 3000,
|
||||
// },
|
||||
};
|
||||
|
||||
module.exports = config;
|
@ -76,4 +76,10 @@
|
||||
.hide-on-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 950px) {
|
||||
.hide-on-small {
|
||||
display:none
|
||||
}
|
||||
}
|
25
src/App.js
@ -1,22 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import HomePage from './pages/HomePage.jsx';
|
||||
import LoginPage from './pages/LoginPage.jsx';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
|
||||
import { isAuthenticated } from 'utilities/authUtilities';
|
||||
|
||||
import HomePage from './pages/HomePage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import { AuthWrapper } from 'utilities/AuthWrapper';
|
||||
import RepoPage from 'pages/RepoPage';
|
||||
import TagPage from 'pages/TagPage';
|
||||
import ExplorePage from 'pages/ExplorePage';
|
||||
|
||||
import './App.css';
|
||||
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthWrapper } from 'utilities/AuthWrapper.jsx';
|
||||
import RepoPage from 'pages/RepoPage.jsx';
|
||||
import TagPage from 'pages/TagPage';
|
||||
import ExplorePage from 'pages/ExplorePage.jsx';
|
||||
|
||||
function App() {
|
||||
const isToken = () => {
|
||||
const localStorageToken = localStorage.getItem('token');
|
||||
return localStorageToken ? true : false;
|
||||
};
|
||||
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(isToken());
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(isAuthenticated());
|
||||
|
||||
return (
|
||||
<div className="App" data-testid="app-container">
|
||||
|
@ -1,8 +1,13 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import App from './App';
|
||||
import MockThemeProvider from './__mocks__/MockThemeProvider';
|
||||
|
||||
it('renders the app component', () => {
|
||||
render(<App />);
|
||||
render(
|
||||
<MockThemeProvider>
|
||||
<App />
|
||||
</MockThemeProvider>
|
||||
);
|
||||
expect(screen.getByTestId('app-container')).toBeInTheDocument();
|
||||
});
|
||||
|
@ -3,8 +3,8 @@ import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
|
||||
const theme = createTheme();
|
||||
|
||||
function MockThemeProvier({ children }) {
|
||||
function MockThemeProvider({ children }) {
|
||||
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
|
||||
}
|
||||
|
||||
export default MockThemeProvier;
|
||||
export default MockThemeProvider;
|
||||
|
@ -6,7 +6,7 @@ import React from 'react';
|
||||
import { createSearchParams, MemoryRouter } from 'react-router-dom';
|
||||
import filterConstants from 'utilities/filterConstants.js';
|
||||
import { sortByCriteria } from 'utilities/sortCriteria.js';
|
||||
import MockThemeProvier from '__mocks__/MockThemeProvider';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
// router mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
@ -18,11 +18,11 @@ jest.mock('react-router-dom', () => ({
|
||||
const StateExploreWrapper = (props) => {
|
||||
const queryString = props.search || '';
|
||||
return (
|
||||
<MockThemeProvier>
|
||||
<MockThemeProvider>
|
||||
<MemoryRouter initialEntries={[`/explore?${queryString.toString()}`]}>
|
||||
<Explore />
|
||||
</MemoryRouter>
|
||||
</MockThemeProvier>
|
||||
</MockThemeProvider>
|
||||
);
|
||||
};
|
||||
const mockImageList = {
|
||||
@ -33,6 +33,8 @@ const mockImageList = {
|
||||
Name: 'alpine',
|
||||
Size: '2806985',
|
||||
LastUpdated: '2022-08-09T17:19:53.274069586Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: 'w',
|
||||
@ -44,12 +46,20 @@ const mockImageList = {
|
||||
MaxSeverity: 'LOW',
|
||||
Count: 7
|
||||
}
|
||||
}
|
||||
},
|
||||
Platforms: [
|
||||
{
|
||||
Os: 'linux',
|
||||
Arch: 'amd64'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
Name: 'mongo',
|
||||
Size: '231383863',
|
||||
LastUpdated: '2022-08-02T01:30:49.193203152Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
@ -61,12 +71,20 @@ const mockImageList = {
|
||||
MaxSeverity: 'HIGH',
|
||||
Count: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
Platforms: [
|
||||
{
|
||||
Os: 'linux',
|
||||
Arch: 'amd64'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
Name: 'node',
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
@ -78,12 +96,20 @@ const mockImageList = {
|
||||
MaxSeverity: 'CRITICAL',
|
||||
Count: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
Platforms: [
|
||||
{
|
||||
Os: 'linux',
|
||||
Arch: 'amd64'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
Name: 'centos',
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
@ -95,12 +121,20 @@ const mockImageList = {
|
||||
MaxSeverity: 'NONE',
|
||||
Count: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
Platforms: [
|
||||
{
|
||||
Os: 'linux',
|
||||
Arch: 'amd64'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
Name: 'debian',
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
@ -112,12 +146,24 @@ const mockImageList = {
|
||||
MaxSeverity: 'MEDIUM',
|
||||
Count: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
Platforms: [
|
||||
{
|
||||
Os: 'linux',
|
||||
Arch: 'amd64'
|
||||
},
|
||||
{
|
||||
Os: 'windows',
|
||||
Arch: 'amd64'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
Name: 'mysql',
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
@ -129,12 +175,20 @@ const mockImageList = {
|
||||
MaxSeverity: 'UNKNOWN',
|
||||
Count: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
Platforms: [
|
||||
{
|
||||
Os: 'linux',
|
||||
Arch: 'amd64'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
Name: 'base',
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
@ -146,12 +200,40 @@ const mockImageList = {
|
||||
MaxSeverity: '',
|
||||
Count: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
Platforms: [
|
||||
{
|
||||
Os: 'linux',
|
||||
Arch: 'amd64'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMockImageListWindows = () => {
|
||||
const filteredRepos = mockImageList.GlobalSearch.Repos.filter((r) =>
|
||||
r.Platforms.map((pf) => pf.Os).includes('windows')
|
||||
);
|
||||
return {
|
||||
GlobalSearch: {
|
||||
Page: { TotalCount: 1, ItemCount: 1 },
|
||||
Repos: filteredRepos
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const filteredMockImageListSigned = () => {
|
||||
const filteredRepos = mockImageList.GlobalSearch.Repos.filter((r) => r.NewestImage.IsSigned);
|
||||
return {
|
||||
GlobalSearch: {
|
||||
Page: { TotalCount: 6, ItemCount: 6 },
|
||||
Repos: filteredRepos
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// IntersectionObserver isn't available in test environment
|
||||
const mockIntersectionObserver = jest.fn();
|
||||
@ -161,6 +243,10 @@ beforeEach(() => {
|
||||
disconnect: () => null
|
||||
});
|
||||
window.IntersectionObserver = mockIntersectionObserver;
|
||||
Object.defineProperty(window.document, 'cookie', {
|
||||
writable: true,
|
||||
value: 'user=test'
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -235,4 +321,37 @@ describe('Explore component', () => {
|
||||
const filterCheckboxes = await screen.findAllByRole('checkbox');
|
||||
expect(filterCheckboxes[0]).toBeChecked();
|
||||
});
|
||||
|
||||
it('should filter the images based on filter cards', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
|
||||
render(<StateExploreWrapper />);
|
||||
expect(await screen.findAllByTestId('repo-card')).toHaveLength(mockImageList.GlobalSearch.Repos.length);
|
||||
const windowsCheckbox = (await screen.findAllByRole('checkbox'))[0];
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: filteredMockImageListWindows() } });
|
||||
await userEvent.click(windowsCheckbox);
|
||||
expect(windowsCheckbox).toBeChecked();
|
||||
expect(await screen.findAllByTestId('repo-card')).toHaveLength(1);
|
||||
const signedCheckboxLabel = await screen.findByText(/signed images/i);
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: filteredMockImageListSigned() } });
|
||||
await userEvent.click(signedCheckboxLabel);
|
||||
expect(await screen.findAllByTestId('repo-card')).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should bookmark a repo if bookmark button is clicked', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
|
||||
render(<StateExploreWrapper />);
|
||||
const bookmarkButton = (await screen.findAllByTestId('bookmark-button'))[0];
|
||||
jest.spyOn(api, 'put').mockResolvedValueOnce({ status: 200, data: {} });
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -2,26 +2,26 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import FilterCard from 'components/Shared/FilterCard';
|
||||
import React, { useState } from 'react';
|
||||
import filterConstants from 'utilities/filterConstants';
|
||||
import MockThemeProvier from '__mocks__/MockThemeProvider';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
const StateFilterCardWrapper = () => {
|
||||
const [filters, setFilters] = useState([]);
|
||||
return (
|
||||
<MockThemeProvier>
|
||||
<MockThemeProvider>
|
||||
<FilterCard
|
||||
title="Operating System"
|
||||
filters={filterConstants.osFilters}
|
||||
updateFilters={setFilters}
|
||||
filterValue={filters}
|
||||
/>
|
||||
</MockThemeProvier>
|
||||
</MockThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
|
@ -4,7 +4,7 @@ import Home from 'components/Home/Home';
|
||||
import React from 'react';
|
||||
import { createSearchParams } from 'react-router-dom';
|
||||
import { sortByCriteria } from 'utilities/sortCriteria';
|
||||
import MockThemeProvier from '__mocks__/MockThemeProvider';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
// useNavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
@ -15,9 +15,9 @@ jest.mock('react-router-dom', () => ({
|
||||
|
||||
const HomeWrapper = () => {
|
||||
return (
|
||||
<MockThemeProvier>
|
||||
<MockThemeProvider>
|
||||
<Home />
|
||||
</MockThemeProvier>
|
||||
</MockThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -122,6 +122,90 @@ const mockImageListRecent = {
|
||||
}
|
||||
};
|
||||
|
||||
const mockImageListBookmarks = {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
@ -134,27 +218,27 @@ afterEach(() => {
|
||||
describe('Home component', () => {
|
||||
it('fetches image data and renders popular, bookmarks and recently updated', 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').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
|
||||
render(<HomeWrapper />);
|
||||
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(2));
|
||||
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(2));
|
||||
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));
|
||||
});
|
||||
|
||||
it('renders signature icons', 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').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
|
||||
render(<HomeWrapper />);
|
||||
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(3);
|
||||
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').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
|
||||
render(<HomeWrapper />);
|
||||
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(2);
|
||||
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);
|
||||
});
|
||||
|
||||
@ -162,15 +246,18 @@ 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(2));
|
||||
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(2);
|
||||
expect(viewAllButtons).toHaveLength(4);
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: [] } });
|
||||
fireEvent.click(viewAllButtons[0]);
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith({
|
||||
pathname: `/explore`,
|
||||
@ -181,5 +268,15 @@ describe('Home component', () => {
|
||||
pathname: `/explore`,
|
||||
search: createSearchParams({ sortby: sortByCriteria.updateTime.value }).toString()
|
||||
});
|
||||
fireEvent.click(viewAllButtons[2]);
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith({
|
||||
pathname: `/explore`,
|
||||
search: createSearchParams({ filter: 'IsBookmarked' }).toString()
|
||||
});
|
||||
fireEvent.click(viewAllButtons[3]);
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith({
|
||||
pathname: `/explore`,
|
||||
search: createSearchParams({ filter: 'IsStarred' }).toString()
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -2,19 +2,22 @@ import { render, screen } from '@testing-library/react';
|
||||
import LoginPage from 'pages/LoginPage';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
it('renders the signin presentation component and signin components if auth enabled', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<LoginPage isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<MockThemeProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<LoginPage isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</MockThemeProvider>
|
||||
);
|
||||
expect(screen.getByTestId('login-container')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('presentation-container')).toBeInTheDocument();
|
||||
|
@ -4,6 +4,12 @@ import SignIn from 'components/Login/SignIn';
|
||||
import { api } from '../../api';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const mockMgmtResponse = {
|
||||
distSpecVersion: '1.1.0-dev',
|
||||
binaryType: '-apikey-lint-metrics-mgmt-scrub-search-sync-ui-userprefs',
|
||||
http: { auth: { htpasswd: {} } }
|
||||
};
|
||||
|
||||
// useNavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
@ -24,7 +30,7 @@ describe('Signin component automatic navigation', () => {
|
||||
|
||||
it('navigates to homepage when auth is disabled', async () => {
|
||||
// mock request to check auth
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: {} });
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { http: {} } });
|
||||
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith('/home');
|
||||
@ -35,7 +41,10 @@ describe('Signin component automatic navigation', () => {
|
||||
describe('Sign in form', () => {
|
||||
beforeEach(() => {
|
||||
// mock auth check request
|
||||
jest.spyOn(api, 'get').mockRejectedValue({ status: 401, data: {} });
|
||||
jest.spyOn(api, 'get').mockResolvedValue({
|
||||
status: 401,
|
||||
data: mockMgmtResponse
|
||||
});
|
||||
});
|
||||
|
||||
it('should change username and password values on user input', async () => {
|
||||
@ -77,7 +86,7 @@ describe('Sign in form', () => {
|
||||
it('should should display login error if login not successful', async () => {
|
||||
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
const submitButton = await screen.findByText('Continue');
|
||||
jest.spyOn(api, 'get').mockRejectedValue();
|
||||
jest.spyOn(api, 'get').mockRejectedValue({ status: 401, data: {} });
|
||||
fireEvent.click(submitButton);
|
||||
const errorDisplay = await screen.findByText(/Authentication Failed/i);
|
||||
await waitFor(() => {
|
||||
|
@ -3,13 +3,14 @@ import RepoDetails from 'components/Repo/RepoDetails';
|
||||
import React from 'react';
|
||||
import { api } from 'api';
|
||||
import { createSearchParams } from 'react-router-dom';
|
||||
import MockThemeProvier from '__mocks__/MockThemeProvider';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const RepoDetailsThemeWrapper = () => {
|
||||
return (
|
||||
<MockThemeProvier>
|
||||
<MockThemeProvider>
|
||||
<RepoDetails />
|
||||
</MockThemeProvier>
|
||||
</MockThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -45,6 +46,8 @@ const mockRepoDetailsData = {
|
||||
LastUpdated: '2023-01-30T15:05:35.420124619Z',
|
||||
Size: '451554070',
|
||||
Vendors: ['[The Node.js Docker Team](https://github.com/nodejs/docker-node)\n'],
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
RepoName: 'mongo',
|
||||
IsSigned: true,
|
||||
@ -232,6 +235,13 @@ const mockRepoDetailsHigh = {
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window.document, 'cookie', {
|
||||
writable: true,
|
||||
value: 'user=test'
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// restore the spy created with spyOn
|
||||
jest.restoreAllMocks();
|
||||
@ -298,4 +308,22 @@ describe('Repo details component', () => {
|
||||
search: createSearchParams({ filter: 'linux' }).toString()
|
||||
});
|
||||
});
|
||||
|
||||
it('should bookmark a repo if bookmark button is clicked', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockRepoDetailsData } });
|
||||
render(<RepoDetailsThemeWrapper />);
|
||||
const bookmarkButton = await screen.findByTestId('bookmark-button');
|
||||
jest.spyOn(api, 'put').mockResolvedValue({ status: 200, data: {} });
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
|
||||
import RepoPage from 'pages/RepoPage';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import MockThemeProvier from '__mocks__/MockThemeProvider';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
@ -27,18 +27,13 @@ afterEach(() => {
|
||||
|
||||
it('renders the repository page component', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<MockThemeProvier>
|
||||
<RepoPage />
|
||||
</MockThemeProvier>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<MockThemeProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="*" element={<RepoPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</MockThemeProvider>
|
||||
);
|
||||
expect(screen.getByTestId('repo-container')).toBeInTheDocument();
|
||||
});
|
||||
|
@ -2,13 +2,13 @@ import { fireEvent, waitFor, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Tags from 'components/Repo/Tabs/Tags';
|
||||
import React from 'react';
|
||||
import MockThemeProvier from '__mocks__/MockThemeProvider';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
const TagsThemeWrapper = () => {
|
||||
return (
|
||||
<MockThemeProvier>
|
||||
<MockThemeProvider>
|
||||
<Tags tags={mockedTagsData} />
|
||||
</MockThemeProvier>
|
||||
</MockThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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');
|
||||
|
@ -3,7 +3,7 @@ import React from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import RepoCard from 'components/Shared/RepoCard';
|
||||
import { createSearchParams } from 'react-router-dom';
|
||||
import MockThemeProvier from '__mocks__/MockThemeProvider';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
// usenavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
@ -22,23 +22,48 @@ const mockImage = {
|
||||
vendor: '',
|
||||
size: '585',
|
||||
tags: '',
|
||||
isSigned: true,
|
||||
signatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: false,
|
||||
Author: ''
|
||||
},
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: false,
|
||||
Author: ''
|
||||
},
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: false,
|
||||
Author: ''
|
||||
},
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: false,
|
||||
Author: ''
|
||||
}
|
||||
],
|
||||
platforms: [{ Os: 'linux', Arch: 'amd64' }]
|
||||
};
|
||||
|
||||
const RepoCardWrapper = (props) => {
|
||||
const { image } = props;
|
||||
return (
|
||||
<MockThemeProvier>
|
||||
<MockThemeProvider>
|
||||
<RepoCard
|
||||
name={image.name}
|
||||
version={image.latestVersion}
|
||||
description={image.description}
|
||||
vendor={image.vendor}
|
||||
isSigned={image.isSigned}
|
||||
signatureInfo={image.signatureInfo}
|
||||
key={1}
|
||||
lastUpdated={image.lastUpdated}
|
||||
platforms={image.platforms}
|
||||
/>
|
||||
</MockThemeProvier>
|
||||
</MockThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -3,7 +3,7 @@ import { api } from 'api';
|
||||
import DependsOn from 'components/Tag/Tabs/DependsOn';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import MockThemeProvier from '__mocks__/MockThemeProvider';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
const mockDependenciesList = {
|
||||
data: {
|
||||
@ -53,13 +53,13 @@ const mockDependenciesList = {
|
||||
|
||||
const RouterDependsWrapper = () => {
|
||||
return (
|
||||
<MockThemeProvier>
|
||||
<MockThemeProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="*" element={<DependsOn name="alpine:latest" />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</MockThemeProvier>
|
||||
</MockThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -3,7 +3,7 @@ import { api } from 'api';
|
||||
import IsDependentOn from 'components/Tag/Tabs/IsDependentOn';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import MockThemeProvier from '__mocks__/MockThemeProvider';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
const mockDependentsList = {
|
||||
data: {
|
||||
@ -53,13 +53,13 @@ const mockDependentsList = {
|
||||
|
||||
const RouterDependsWrapper = () => {
|
||||
return (
|
||||
<MockThemeProvier>
|
||||
<MockThemeProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="*" element={<IsDependentOn name="alpine:latest" />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</MockThemeProvier>
|
||||
</MockThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -3,18 +3,18 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { api } from 'api';
|
||||
import TagDetails from 'components/Tag/TagDetails';
|
||||
import MockThemeProvier from '__mocks__/MockThemeProvider';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||
|
||||
const TagDetailsThemeWrapper = () => {
|
||||
return (
|
||||
<MockThemeProvier>
|
||||
<MockThemeProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="*" element={<TagDetails />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</MockThemeProvier>
|
||||
</MockThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -368,7 +368,51 @@ const mockDependenciesList = {
|
||||
}
|
||||
};
|
||||
|
||||
const mockDependentsList = mockDependenciesList;
|
||||
const mockDependentsList = {
|
||||
data: {
|
||||
DerivedImageList: {
|
||||
Page: { ItemCount: 4, TotalCount: 4 },
|
||||
Results: [
|
||||
{
|
||||
RepoName: 'project-stacker/c3/static-ubuntu-amd64',
|
||||
Tag: 'tag1',
|
||||
Manifests: [],
|
||||
Vulnerabilities: {
|
||||
MaxSeverity: 'HIGH',
|
||||
Count: 5
|
||||
}
|
||||
},
|
||||
{
|
||||
RepoName: 'tag2',
|
||||
Tag: 'tag2',
|
||||
Manifests: [],
|
||||
Vulnerabilities: {
|
||||
MaxSeverity: 'CRITICAL',
|
||||
Count: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
RepoName: 'tag3',
|
||||
Tag: 'tag3',
|
||||
Manifests: [],
|
||||
Vulnerabilities: {
|
||||
MaxSeverity: 'LOW',
|
||||
Count: 7
|
||||
}
|
||||
},
|
||||
{
|
||||
RepoName: 'tag4',
|
||||
Tag: 'tag4',
|
||||
Manifests: [],
|
||||
Vulnerabilities: {
|
||||
MaxSeverity: 'HIGH',
|
||||
Count: 5
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mockCVEList = {
|
||||
CVEListForImage: {
|
||||
|
@ -1,18 +1,20 @@
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockThemeProvier from '__mocks__/MockThemeProvider';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
import { api } from 'api';
|
||||
import VulnerabilitiesDetails from 'components/Tag/Tabs/VulnerabilitiesDetails';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
jest.mock('xlsx');
|
||||
|
||||
const StateVulnerabilitiesWrapper = () => {
|
||||
return (
|
||||
<MockThemeProvier>
|
||||
<MockThemeProvider>
|
||||
<MemoryRouter>
|
||||
<VulnerabilitiesDetails name="mongo" />
|
||||
</MemoryRouter>
|
||||
</MockThemeProvier>
|
||||
</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')
|
||||
|
19
src/__tests__/api.test.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { api } from '../api';
|
||||
|
||||
describe('api module', () => {
|
||||
it('should redirect to login if a 401 error is received', () => {
|
||||
const location = new URL('https://www.test.com');
|
||||
location.replace = jest.fn();
|
||||
delete window.location;
|
||||
window.location = location;
|
||||
const axiosInstance = api.getAxiosInstance();
|
||||
expect(
|
||||
axiosInstance.interceptors.response.handlers[0].rejected({
|
||||
response: { statusText: 'Unauthorized', status: 401 }
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
response: { statusText: 'Unauthorized', status: 401 }
|
||||
});
|
||||
expect(location.replace).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
75
src/api.js
@ -1,39 +1,45 @@
|
||||
import axios from 'axios';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { sortByCriteria } from 'utilities/sortCriteria';
|
||||
import { isAuthenticationEnabled, logoutUser } from 'utilities/authUtilities';
|
||||
import { host } from 'host';
|
||||
|
||||
axios.interceptors.request.use((config) => {
|
||||
if (config.url.includes(endpoints.authConfig) || !isAuthenticationEnabled()) {
|
||||
config.withCredentials = false;
|
||||
} else {
|
||||
config.headers['X-ZOT-API-CLIENT'] = 'zot-ui';
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response.status === 401) {
|
||||
localStorage.clear();
|
||||
if (error?.response?.status === 401) {
|
||||
if (window.location.pathname.includes('/login')) return Promise.reject(error);
|
||||
logoutUser();
|
||||
window.location.replace('/login');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const api = {
|
||||
getAxiosInstance: () => axios,
|
||||
|
||||
getRequestCfg: () => {
|
||||
const authConfig = JSON.parse(localStorage.getItem('authConfig'));
|
||||
const genericHeaders = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
const authHeaders = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${token}`
|
||||
};
|
||||
return {
|
||||
headers: authHeaders
|
||||
};
|
||||
}
|
||||
|
||||
// withCredentials option must be enabled on cross-origin
|
||||
return {
|
||||
headers: genericHeaders
|
||||
headers: genericHeaders,
|
||||
withCredentials: host() !== window?.location?.origin && authConfig !== null
|
||||
};
|
||||
},
|
||||
|
||||
@ -71,14 +77,19 @@ const api = {
|
||||
};
|
||||
|
||||
const endpoints = {
|
||||
status: `/v2/`,
|
||||
authConfig: `/v2/_zot/ext/mgmt`,
|
||||
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 Documentation Vendor Labels} 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 NewestImage {RepoName IsSigned 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 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 }}`,
|
||||
`/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 = '') => {
|
||||
let query = `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${
|
||||
(pageNumber - 1) * pageSize
|
||||
@ -88,18 +99,28 @@ const endpoints = {
|
||||
}
|
||||
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:${
|
||||
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) {
|
||||
filterParam = `,filter:{`;
|
||||
if (filter.Os) filterParam += ` Os:${!isEmpty(filter.Os) ? `${JSON.stringify(filter.Os)}` : '""'}`;
|
||||
if (filter.Arch) filterParam += ` Arch:${!isEmpty(filter.Arch) ? `${JSON.stringify(filter.Arch)}` : '""'}`;
|
||||
filterParam += '}';
|
||||
}
|
||||
return `/v2/_zot/ext/search?query={ImageListWithCVEFixed(id:"${cveId}", image:"${repoName}", requestedPage: {limit:${pageSize} offset:${
|
||||
(pageNumber - 1) * pageSize
|
||||
}}) {Page {TotalCount ItemCount} Results {Tag}}}`,
|
||||
}}${filterParam}) {Page {TotalCount ItemCount} Results {Tag}}}`;
|
||||
},
|
||||
dependsOnForImage: (name, { pageNumber = 1, pageSize = 15 } = {}) =>
|
||||
`/v2/_zot/ext/search?query={BaseImageList(image: "${name}", requestedPage: {limit:${pageSize} offset:${
|
||||
(pageNumber - 1) * pageSize
|
||||
}}){Page {TotalCount ItemCount} Results { RepoName Tag Description Manifests {Digest Platform {Os Arch} Size} Vendor DownloadCount LastUpdated IsSigned Vulnerabilities {MaxSeverity Count}}}}`,
|
||||
}}){Page {TotalCount ItemCount} Results { RepoName Tag Description Manifests {Digest Platform {Os Arch} Size} Vendor DownloadCount LastUpdated IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count}}}}`,
|
||||
isDependentOnForImage: (name, { pageNumber = 1, pageSize = 15 } = {}) =>
|
||||
`/v2/_zot/ext/search?query={DerivedImageList(image: "${name}", requestedPage: {limit:${pageSize} offset:${
|
||||
(pageNumber - 1) * pageSize
|
||||
}}){Page {TotalCount ItemCount} Results {RepoName Tag Description Manifests {Digest Platform {Os Arch} Size} Vendor DownloadCount LastUpdated IsSigned Vulnerabilities {MaxSeverity Count}}}}`,
|
||||
}}){Page {TotalCount ItemCount} Results {RepoName Tag Description Manifests {Digest Platform {Os Arch} Size} Vendor DownloadCount LastUpdated IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count}}}}`,
|
||||
globalSearch: ({
|
||||
searchQuery = '""',
|
||||
pageNumber = 1,
|
||||
@ -115,9 +136,11 @@ const endpoints = {
|
||||
if (filter.Os) filterParam += ` Os:${!isEmpty(filter.Os) ? `${JSON.stringify(filter.Os)}` : '""'}`;
|
||||
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 } NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description IsSigned 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:""`;
|
||||
@ -125,7 +148,9 @@ const endpoints = {
|
||||
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam}) {Images {RepoName Tag}}}`;
|
||||
},
|
||||
referrers: ({ repo, digest, type = '' }) =>
|
||||
`/v2/_zot/ext/search?query={Referrers(repo: "${repo}" digest: "${digest}" type: "${type}"){MediaType ArtifactType Size Digest Annotations{Key Value}}}`
|
||||
`/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`,
|
||||
starToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleStar`
|
||||
};
|
||||
|
||||
export { api, endpoints };
|
||||
|
3
src/assets/GhIcon.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.0001 0.296387C5.3735 0.296387 0 5.66889 0 12.2965C0 17.5984 3.43839 22.0966 8.2064 23.6833C8.80613 23.7944 9.0263 23.423 9.0263 23.1061C9.0263 22.8199 9.01518 21.8746 9.01001 20.8719C5.67157 21.5978 4.96712 19.456 4.96712 19.456C4.42125 18.069 3.63473 17.7002 3.63473 17.7002C2.54596 16.9554 3.7168 16.9707 3.7168 16.9707C4.92181 17.0554 5.55632 18.2073 5.55632 18.2073C6.6266 20.0419 8.36359 19.5115 9.04836 19.2049C9.15607 18.4293 9.46706 17.8999 9.81024 17.6002C7.14486 17.2968 4.34295 16.2678 4.34295 11.6697C4.34295 10.3596 4.81172 9.28911 5.57937 8.44874C5.45477 8.14649 5.04402 6.92597 5.69562 5.27305C5.69562 5.27305 6.70331 4.95053 8.9965 6.5031C9.95372 6.23722 10.9803 6.10388 12.0001 6.09931C13.0199 6.10388 14.0473 6.23722 15.0063 6.5031C17.2967 4.95053 18.303 5.27305 18.303 5.27305C18.9562 6.92597 18.5452 8.14649 18.4206 8.44874C19.1901 9.28911 19.6557 10.3596 19.6557 11.6697C19.6557 16.2788 16.8484 17.2936 14.1762 17.5907C14.6067 17.9631 14.9902 18.6934 14.9902 19.8129C14.9902 21.4186 14.9763 22.7108 14.9763 23.1061C14.9763 23.4254 15.1923 23.7996 15.8006 23.6818C20.566 22.0932 24 17.5967 24 12.2965C24 5.66889 18.6273 0.296387 12.0001 0.296387ZM4.49443 17.3908C4.468 17.4504 4.3742 17.4683 4.28876 17.4273C4.20172 17.3882 4.15283 17.3069 4.18105 17.2471C4.20688 17.1857 4.30088 17.1686 4.38772 17.2098C4.47495 17.2489 4.52463 17.331 4.49443 17.3908ZM5.0847 17.9175C5.02747 17.9705 4.91559 17.9459 4.83968 17.862C4.76119 17.7784 4.74648 17.6665 4.80451 17.6126C4.86353 17.5596 4.97203 17.5844 5.05072 17.6681C5.12921 17.7527 5.14451 17.8638 5.0847 17.9175ZM5.48965 18.5914C5.41612 18.6424 5.2959 18.5945 5.22158 18.4878C5.14805 18.3811 5.14805 18.2531 5.22317 18.2019C5.29769 18.1506 5.41612 18.1967 5.49144 18.3026C5.56476 18.4111 5.56476 18.5391 5.48965 18.5914ZM6.1745 19.3718C6.10873 19.4443 5.96863 19.4249 5.86609 19.3259C5.76117 19.2291 5.73196 19.0918 5.79793 19.0193C5.8645 18.9466 6.00539 18.967 6.10873 19.0652C6.21285 19.1618 6.24465 19.3001 6.1745 19.3718ZM7.05961 19.6353C7.0306 19.7293 6.89567 19.772 6.75975 19.7321C6.62402 19.6909 6.5352 19.5808 6.56262 19.4858C6.59084 19.3913 6.72636 19.3467 6.86328 19.3895C6.9988 19.4304 7.08783 19.5397 7.05961 19.6353ZM8.0669 19.747C8.07028 19.846 7.95502 19.9281 7.81235 19.9299C7.66887 19.933 7.55282 19.853 7.55123 19.7556C7.55123 19.6556 7.6639 19.5744 7.80738 19.572C7.95006 19.5692 8.0669 19.6487 8.0669 19.747ZM9.05645 19.7091C9.07354 19.8057 8.97438 19.9048 8.8327 19.9313C8.6934 19.9567 8.56443 19.8971 8.54674 19.8013C8.52945 19.7024 8.6304 19.6032 8.7695 19.5776C8.91139 19.5529 9.03837 19.6109 9.05645 19.7091Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 86 KiB |
@ -1,6 +0,0 @@
|
||||
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29.6046 49.1571C19.5123 49.1571 8.66797 47.1782 8.66797 42.8246C8.66797 38.4711 19.5123 36.4922 29.6046 36.4922C39.697 36.4922 50.5413 38.4711 50.5413 42.8246C50.5809 47.1782 39.697 49.1571 29.6046 49.1571ZM29.6046 38.9856C24.4595 38.9856 19.631 39.5001 15.9899 40.45C12.0321 41.479 11.1614 42.5872 11.1614 42.8246C11.1614 43.0621 12.0321 44.1703 15.9899 45.1993C19.5915 46.1492 24.4595 46.6637 29.6046 46.6637C34.7498 46.6637 39.5783 46.1492 43.2194 45.1993C47.1772 44.1703 48.0479 43.0621 48.0479 42.8246C48.0479 42.5872 47.1772 41.479 43.2194 40.45C39.6178 39.5001 34.7498 38.9856 29.6046 38.9856Z" fill="white"/>
|
||||
<path d="M29.6046 23.5477C19.5123 23.5477 8.66797 21.5688 8.66797 17.2153C8.66797 12.8617 19.5123 10.8828 29.6046 10.8828C39.697 10.8828 50.5413 12.8617 50.5413 17.2153C50.5809 21.5688 39.697 23.5477 29.6046 23.5477ZM29.6046 13.3762C24.4595 13.3762 19.631 13.8907 15.9899 14.8406C12.0321 15.8696 11.1614 16.9778 11.1614 17.2153C11.1614 17.4527 12.0321 18.5609 15.9899 19.5899C19.5915 20.5398 24.4595 21.0543 29.6046 21.0543C34.7498 21.0543 39.5783 20.5398 43.2194 19.5899C47.1772 18.5609 48.0479 17.4527 48.0479 17.2153C48.0479 16.9778 47.1772 15.8696 43.2194 14.8406C39.6178 13.8907 34.7498 13.3762 29.6046 13.3762Z" fill="white"/>
|
||||
<path d="M47.0194 28.2188C32.7318 44.9602 23.8664 47.2557 21.8875 47.2557C21.7688 47.2557 11.1223 44.9206 11.0828 44.9206L10.7266 42.823C19.2358 41.9127 34.869 39.0631 47.0194 28.2188Z" fill="white"/>
|
||||
<path d="M32.6133 34.5499C35.5816 33.1647 42.9431 26.3177 42.112 21.5684L50.542 17.2148C50.5816 28.4945 35.1067 33.2043 32.6133 34.5499Z" fill="white"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 99 KiB |
Before Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 4.6 MiB |
Before Width: | Height: | Size: 26 KiB |
15
src/assets/noData.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<svg width="235" height="240" viewBox="0 0 235 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="34.8711" y="16.2017" width="36" height="249.944" rx="18" transform="rotate(-26.7465 34.8711 16.2017)" fill="#F15527" fill-opacity="0.2"/>
|
||||
<rect x="156.871" y="36.2017" width="36" height="100.235" rx="18" transform="rotate(-26.7465 156.871 36.2017)" fill="#F15527" fill-opacity="0.2"/>
|
||||
<rect x="0.871094" y="138.037" width="26.745" height="74.4663" rx="13.3725" transform="rotate(-26.7465 0.871094 138.037)" fill="#F15527" fill-opacity="0.2"/>
|
||||
<g clip-path="url(#clip0_2865_33046)">
|
||||
<path d="M117.5 199C161.225 199 197 163.225 197 119.5C197 75.775 161.225 40 117.5 40C73.775 40 38 75.775 38 119.5C38 163.225 73.775 199 117.5 199ZM119.885 82.0555C121.475 80.6245 123.383 79.75 125.45 79.75C127.596 79.75 129.425 80.6245 131.094 82.0555C132.605 83.725 133.4 85.633 133.4 87.7C133.4 89.8465 132.605 91.675 131.094 93.3445C129.425 94.855 127.596 95.65 125.45 95.65C123.383 95.65 121.475 94.855 119.885 93.3445C118.375 91.675 117.5 89.8465 117.5 87.7C117.5 85.633 118.375 83.725 119.885 82.0555ZM100.01 119.261C100.01 119.261 117.261 105.588 123.542 105.031C129.425 104.554 128.232 111.311 127.676 114.809L127.597 115.286C126.484 119.5 125.132 124.588 123.78 129.438C120.759 140.488 117.818 151.3 118.534 153.287C119.328 155.991 124.257 152.572 127.835 150.187C128.312 149.869 128.709 149.551 129.107 149.312C129.107 149.312 129.743 148.676 130.379 149.551C130.538 149.789 130.697 150.028 130.856 150.187C131.572 151.3 131.969 151.697 131.015 152.333L130.697 152.492C128.948 153.685 121.475 158.932 118.454 160.84C115.194 162.987 102.713 170.142 104.621 156.229C106.291 146.451 108.517 138.023 110.266 131.425C113.525 119.5 114.956 114.094 107.642 118.785C104.701 120.534 102.951 121.646 101.918 122.362C101.043 122.998 100.964 122.998 100.408 121.965L100.169 121.487L99.7715 120.852C99.215 120.057 99.215 119.977 100.01 119.261Z" fill="#0F2139"/>
|
||||
<path d="M119.885 82.0555C121.475 80.6245 123.383 79.75 125.45 79.75C127.596 79.75 129.425 80.6245 131.094 82.0555C132.605 83.725 133.4 85.633 133.4 87.7C133.4 89.8465 132.605 91.675 131.094 93.3445C129.425 94.855 127.596 95.65 125.45 95.65C123.383 95.65 121.475 94.855 119.885 93.3445C118.375 91.675 117.5 89.8465 117.5 87.7C117.5 85.633 118.375 83.725 119.885 82.0555Z" fill="white"/>
|
||||
<path d="M100.01 119.261C100.01 119.261 117.261 105.588 123.542 105.031C129.425 104.554 128.232 111.311 127.676 114.809L127.597 115.286C126.484 119.5 125.132 124.588 123.78 129.438C120.759 140.488 117.818 151.3 118.534 153.287C119.328 155.991 124.257 152.572 127.835 150.187C128.312 149.869 128.709 149.551 129.107 149.312C129.107 149.312 129.743 148.676 130.379 149.551C130.538 149.789 130.697 150.028 130.856 150.187C131.572 151.3 131.969 151.697 131.015 152.333L130.697 152.492C128.948 153.685 121.475 158.932 118.454 160.84C115.194 162.987 102.713 170.142 104.621 156.229C106.291 146.451 108.517 138.023 110.266 131.425C113.525 119.5 114.956 114.094 107.642 118.785C104.701 120.534 102.951 121.646 101.918 122.362C101.043 122.998 100.964 122.998 100.408 121.965L100.169 121.487L99.7715 120.852C99.215 120.057 99.215 119.977 100.01 119.261Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2865_33046">
|
||||
<rect width="159" height="159" fill="white" transform="translate(38 40)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src/assets/zot-white-horizontal.png
Normal file
After Width: | Height: | Size: 6.7 KiB |
BIN
src/assets/zot-white-icon.png
Normal file
After Width: | Height: | Size: 9.7 KiB |
Before Width: | Height: | Size: 28 KiB |
@ -1,18 +0,0 @@
|
||||
<svg width="489" height="152" viewBox="0 0 489 152" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1434_24034)">
|
||||
<path d="M75.8 151.6C117.663 151.6 151.6 117.663 151.6 75.8C151.6 33.9368 117.663 0 75.8 0C33.9368 0 0 33.9368 0 75.8C0 117.663 33.9368 151.6 75.8 151.6Z" fill="#231F20"/>
|
||||
<path d="M74.7999 124.2C49.2999 124.2 21.8999 119.2 21.8999 108.2C21.8999 97.2002 49.2999 92.2002 74.7999 92.2002C100.3 92.2002 127.7 97.2002 127.7 108.2C127.8 119.2 100.3 124.2 74.7999 124.2ZM74.7999 98.5002C61.7999 98.5002 49.5999 99.8002 40.3999 102.2C30.3999 104.8 28.1999 107.6 28.1999 108.2C28.1999 108.8 30.3999 111.6 40.3999 114.2C49.4999 116.6 61.7999 117.9 74.7999 117.9C87.7999 117.9 99.9999 116.6 109.2 114.2C119.2 111.6 121.4 108.8 121.4 108.2C121.4 107.6 119.2 104.8 109.2 102.2C100.1 99.8002 87.7999 98.5002 74.7999 98.5002Z" fill="white"/>
|
||||
<path d="M46.8999 100.8C55.0999 99.4998 75.0999 93.3998 84.3999 89.2998L103.3 92.0998C99.3999 95.6998 96.6999 98.8998 93.1999 101.8L46.8999 100.8Z" fill="#231F20"/>
|
||||
<path d="M74.7999 59.5C49.2999 59.5 21.8999 54.5 21.8999 43.5C21.8999 32.5 49.2999 27.5 74.7999 27.5C100.3 27.5 127.7 32.5 127.7 43.5C127.8 54.5 100.3 59.5 74.7999 59.5ZM74.7999 33.8C61.7999 33.8 49.5999 35.1 40.3999 37.5C30.3999 40.1 28.1999 42.9 28.1999 43.5C28.1999 44.1 30.3999 46.9 40.3999 49.5C49.4999 51.9 61.7999 53.2 74.7999 53.2C87.7999 53.2 99.9999 51.9 109.2 49.5C119.2 46.9 121.4 44.1 121.4 43.5C121.4 42.9 119.2 40.1 109.2 37.5C100.1 35.1 87.7999 33.8 74.7999 33.8Z" fill="white"/>
|
||||
<path d="M118.8 71.2998C82.7001 113.6 60.3001 119.4 55.3001 119.4C55.0001 119.4 28.1001 113.5 28.0001 113.5L27.1001 108.2C48.6001 105.9 88.1001 98.6998 118.8 71.2998Z" fill="white"/>
|
||||
<path d="M82.3999 87.3C89.8999 83.8 108.5 66.5 106.4 54.5L127.7 43.5C127.8 72 88.6999 83.9 82.3999 87.3Z" fill="white"/>
|
||||
<path d="M195.6 99.4002H241.4V95.6002C241.4 91.4002 241.9 88.7002 242.8 87.5002C243.8 86.3002 245.5 85.6002 248 85.6002C250.1 85.6002 251.7 86.1002 252.8 87.1002C253.8 88.1002 254.4 89.5002 254.4 91.5002V103.6C254.4 105.8 253.6 107.4 252 108.3C250.4 109.3 247.6 109.7 243.5 109.7H187.8C184.4 109.7 181.8 109.1 180 108C178.2 106.9 177.3 105.2 177.3 103.1C177.3 101.8 177.7 100.5 178.4 99.4002C179.2 98.2002 181 96.5002 183.8 94.3002L236.4 52.5002H192.5V56.2002C192.5 60.5002 192 63.2002 191.1 64.4002C190.1 65.6002 188.4 66.2002 185.9 66.2002C183.7 66.2002 182 65.7002 180.9 64.7002C179.8 63.7002 179.2 62.3002 179.2 60.3002V48.0002C179.2 46.0002 180.2 44.5002 182.3 43.6002C184.4 42.7002 187.6 42.2002 192 42.2002H241.2C245.5 42.2002 248.7 42.8002 250.9 44.1002C253.1 45.3002 254.2 47.2002 254.2 49.6002C254.2 51.0002 253.5 52.6002 252.1 54.4002C250.7 56.2002 248.7 58.1002 245.9 60.2002L195.6 99.4002Z" fill="#231F20"/>
|
||||
<path d="M376.9 75.9996C376.9 86.3996 372.4 94.9996 363.5 101.6C354.5 108.2 342.9 111.5 328.6 111.5C314.4 111.5 302.8 108.2 293.8 101.6C284.8 94.9996 280.4 86.3996 280.4 75.9996C280.4 65.5996 284.9 57.0996 293.8 50.4996C302.8 43.8996 314.3 40.5996 328.6 40.5996C342.8 40.5996 354.4 43.8996 363.4 50.4996C372.4 57.0996 376.9 65.5996 376.9 75.9996ZM328.6 101.2C338 101.2 345.8 98.7996 351.8 94.0996C357.8 89.3996 360.9 83.2996 360.9 75.9996C360.9 68.6996 357.9 62.5996 351.8 57.7996C345.7 52.9996 338 50.5996 328.6 50.5996C319.2 50.5996 311.5 52.9996 305.5 57.7996C299.5 62.5996 296.4 68.6996 296.4 75.9996C296.4 83.3996 299.4 89.4996 305.4 94.1996C311.5 98.7996 319.2 101.2 328.6 101.2Z" fill="#231F20"/>
|
||||
<path d="M466.8 46.2998C469.8 46.2998 472 46.6998 473.4 47.4998C474.8 48.2998 475.5 49.5998 475.5 51.3998C475.5 53.0998 474.9 54.3998 473.6 55.1998C472.3 56.0998 470.4 56.4998 467.8 56.4998H430.5V80.9998C430.5 88.9998 431.8 94.2998 434.4 96.7998C437 99.2998 441.5 100.6 447.8 100.6C453.4 100.6 460.1 99.2998 467.9 96.6998C475.7 94.0998 480.5 92.7998 482.3 92.7998C484 92.7998 485.5 93.2998 486.7 94.2998C487.9 95.2998 488.5 96.4998 488.5 97.8998C488.5 99.4998 487.7 100.9 486.2 102.1C484.7 103.3 482.1 104.5 478.5 105.6C472.5 107.5 467.1 108.9 462.3 109.8C457.5 110.7 453 111.1 448.7 111.1C441.5 111.1 435.5 110.2 430.6 108.4C425.7 106.6 422.1 103.9 419.7 100.3C418.5 98.5998 417.6 96.5998 417.1 94.1998C416.6 91.8998 416.3 88.2998 416.3 83.3998V80.9998V56.5998H400.7C398.1 56.5998 396.3 56.1998 395.1 55.3998C393.9 54.5998 393.4 53.2998 393.4 51.4998C393.4 49.4998 394.2 48.0998 395.8 47.3998C397.4 46.6998 400.8 46.2998 406 46.2998H416.4V31.2998V27.3998C416.4 25.2998 417 23.7998 418.1 22.7998C419.2 21.7998 421 21.2998 423.4 21.2998C426.1 21.2998 428 21.8998 429 23.0998C430 24.2998 430.6 27.0998 430.6 31.3998V46.1998H466.8V46.2998Z" fill="#231F20"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1434_24034">
|
||||
<rect width="488.4" height="151.6" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
Before Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 1.7 KiB |
27
src/assets/zotLogoWhiteHorizontal.svg
Normal file
@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 155.8 73.7" style="enable-background:new 0 0 155.8 73.7;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g id="Layer_2_1_">
|
||||
</g>
|
||||
<path class="st0" d="M58,45.8c6,1.1,10.4,2.4,10.4,5.6c0,6.1-11.9,9-23.1,9s-23.1-3.2-23.1-9c0-5.2,11.9-6.6,23.1-6.6h0.1
|
||||
c0,0-3.1,1.1-6,2.1c-2.5,0.9-7.9,1.9-9.2,2.3c-4.4,1.1-5.3,2.4-5.3,2.6c0,0.3,1,1.5,5.3,2.6c4,1,9.3,1.6,15,1.6s11-0.6,15-1.6
|
||||
c4.4-1.1,5.3-2.4,5.3-2.6c0-0.3-1-1.5-5.3-2.6c-1.4-0.4-3-0.7-4.8-0.9L58,45.8z"/>
|
||||
<path class="st0" d="M45.4,31.2c-11.1,0-23.1-2.7-23.1-7.7c0-5.4,11.9-8.1,23.1-8.1s23.1,2.8,23.1,8.1
|
||||
C68.5,28.5,56.5,31.2,45.4,31.2z M45.4,19.5c-5.7,0-11,0.5-15,1.5c-4.4,1.1-5.3,2.2-5.3,2.5c0,0.2,1,1.4,5.3,2.5c4,1,9.3,1.5,15,1.5
|
||||
s11-0.5,15-1.5c4.4-1.1,5.3-2.2,5.3-2.5c0-0.2-1-1.4-5.3-2.5C56.4,20.1,51.1,19.5,45.4,19.5z"/>
|
||||
<path class="st0" d="M65,36.1C49.3,54.5,39.5,56.6,37.3,56.6c-0.1,0-11.9-2.6-11.9-2.6L25,51.7C34.4,50.7,51.6,48,65,36.1z"/>
|
||||
<path class="st0" d="M48.3,43.7c3.3-1.5,11.8-10.2,10.9-15.4l9.3-4.8C68.5,35.9,51,42.2,48.3,43.7z"/>
|
||||
<g>
|
||||
<path class="st0" d="M97.2,33.4L84.1,48.1h13.1v3.5H79.3v-3.9L92.4,33H79.9v-3.4h17.2V33.4z"/>
|
||||
<path class="st0" d="M100.1,40.6c0-4,1.2-7.1,3.6-9.1c1.9-1.7,4.3-2.5,7.3-2.5c3.4,0,6.1,1.1,8,3.3c1.9,2,2.8,4.8,2.8,8.4
|
||||
c0,4-1.2,7-3.5,9.1c-1.9,1.7-4.4,2.5-7.4,2.5c-3.4,0-6.1-1.1-8.1-3.4C101.1,46.8,100.1,44,100.1,40.6z M104.4,40.6
|
||||
c0,2.8,0.7,5,2.2,6.5c1.2,1.2,2.7,1.9,4.5,1.9c2.1,0,3.8-0.8,5-2.4c1.1-1.5,1.6-3.4,1.6-5.9c0-2.9-0.7-5.1-2.1-6.5
|
||||
c-1.2-1.2-2.7-1.8-4.5-1.8c-2.1,0-3.8,0.8-5,2.4C104.9,36.1,104.4,38.1,104.4,40.6z"/>
|
||||
<path class="st0" d="M136.3,48.3v3.5c-1,0.3-2,0.4-3,0.4c-2,0-3.6-0.6-4.6-1.9c-0.9-1-1.3-2.4-1.3-4.2V33h-3.3v-3.5h3.3l0.3-5.3
|
||||
h3.8v5.3h4.7V33h-4.7v13.2c0,1.6,0.8,2.5,2.3,2.5C134.6,48.6,135.4,48.5,136.3,48.3z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.0 KiB |
@ -70,7 +70,7 @@ function Explore({ searchInputValue }) {
|
||||
const [queryParams] = useSearchParams();
|
||||
const search = queryParams.get('search');
|
||||
// filtercard filters
|
||||
const [imageFilters, setImageFilters] = useState(false);
|
||||
const [imageFilters, setImageFilters] = useState({});
|
||||
const [osFilters, setOSFilters] = useState([]);
|
||||
const [archFilters, setArchFilters] = useState([]);
|
||||
// pagination props
|
||||
@ -88,8 +88,8 @@ function Explore({ searchInputValue }) {
|
||||
let filter = {};
|
||||
filter = !isEmpty(osFilters) ? { ...filter, Os: osFilters } : filter;
|
||||
filter = !isEmpty(archFilters) ? { ...filter, Arch: archFilters } : filter;
|
||||
if (imageFilters) {
|
||||
filter = { ...filter, HasToBeSigned: imageFilters };
|
||||
if (!isEmpty(Object.keys(imageFilters))) {
|
||||
filter = { ...filter, ...imageFilters };
|
||||
}
|
||||
return filter;
|
||||
};
|
||||
@ -101,6 +101,8 @@ function Explore({ searchInputValue }) {
|
||||
setOSFilters([...osFilters, preselectedFilter]);
|
||||
} else if (filterConstants.archFilters.map((f) => f.value).includes(preselectedFilter)) {
|
||||
setArchFilters([...archFilters, preselectedFilter]);
|
||||
} else if (filterConstants.imageFilters.map((f) => f.value).includes(preselectedFilter)) {
|
||||
setImageFilters({ ...imageFilters, [preselectedFilter]: true });
|
||||
}
|
||||
queryParams.delete('filter');
|
||||
}
|
||||
@ -218,7 +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}
|
||||
|
@ -1,17 +1,18 @@
|
||||
// react global
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
// components
|
||||
import { AppBar, Toolbar, Grid } from '@mui/material';
|
||||
import { isAuthenticated, isAuthenticationEnabled, logoutUser } from '../../utilities/authUtilities';
|
||||
|
||||
// components
|
||||
import { AppBar, Toolbar, Grid, Button } from '@mui/material';
|
||||
import SearchSuggestion from './SearchSuggestion';
|
||||
import UserAccountMenu from './UserAccountMenu';
|
||||
// styling
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import logo from '../../assets/zotLogoWhite.svg';
|
||||
import logoxs from '../../assets/zotLogoWhiteSmall.svg';
|
||||
import githubLogo from '../../assets/Git.png';
|
||||
import { useState, useEffect } from 'react';
|
||||
import SearchSuggestion from './SearchSuggestion';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
barOpen: {
|
||||
@ -130,6 +131,10 @@ function Header({ setSearchCurrentValue = () => {} }) {
|
||||
const classes = useStyles();
|
||||
const path = useLocation().pathname;
|
||||
|
||||
const handleSignInClick = () => {
|
||||
logoutUser();
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar position={show ? 'fixed' : 'absolute'} sx={{ height: '5rem' }}>
|
||||
<Toolbar className={classes.header}>
|
||||
@ -144,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"
|
||||
>
|
||||
@ -168,6 +173,18 @@ function Header({ setSearchCurrentValue = () => {} }) {
|
||||
<img alt="github repository" src={githubLogo} className={classes.logo} />
|
||||
</a>
|
||||
</Grid>
|
||||
{isAuthenticated() && isAuthenticationEnabled() && (
|
||||
<Grid item>
|
||||
<UserAccountMenu />
|
||||
</Grid>
|
||||
)}
|
||||
{!isAuthenticated() && isAuthenticationEnabled() && (
|
||||
<Grid item>
|
||||
<Button className={classes.signInBtn} onClick={handleSignInClick}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Toolbar>
|
||||
|
@ -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
|
||||
|
46
src/components/Header/UserAccountMenu.jsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Menu, MenuItem, IconButton, Avatar, Divider } from '@mui/material';
|
||||
|
||||
import { getLoggedInUser, logoutUser } from '../../utilities/authUtilities';
|
||||
|
||||
function UserAccountMenu() {
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const openMenu = Boolean(anchorEl);
|
||||
|
||||
const handleUserClick = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleUserClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={handleUserClick}
|
||||
size="small"
|
||||
aria-controls={open ? 'account-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
>
|
||||
<Avatar sx={{ width: 32, height: 32 }} />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={openMenu}
|
||||
onClose={handleUserClose}
|
||||
onClick={handleUserClose}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
>
|
||||
<MenuItem onClick={handleUserClose}>{getLoggedInUser()}</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={logoutUser}>Log out</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserAccountMenu;
|
@ -8,7 +8,14 @@ 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';
|
||||
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';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
gridWrapper: {
|
||||
@ -82,13 +89,20 @@ const useStyles = makeStyles((theme) => ({
|
||||
function Home() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [popularData, setPopularData] = useState([]);
|
||||
const [isLoadingPopular, setIsLoadingPopular] = useState(true);
|
||||
const [recentData, setRecentData] = useState([]);
|
||||
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(), []);
|
||||
const classes = useStyles();
|
||||
|
||||
const getPopularData = () => {
|
||||
setIsLoading(true);
|
||||
setIsLoadingPopular(true);
|
||||
api
|
||||
.get(
|
||||
`${host()}${endpoints.globalSearch({
|
||||
@ -107,15 +121,18 @@ function Home() {
|
||||
});
|
||||
setPopularData(repoData);
|
||||
setIsLoading(false);
|
||||
setIsLoadingPopular(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setIsLoading(false);
|
||||
setIsLoadingPopular(false);
|
||||
});
|
||||
};
|
||||
|
||||
const getRecentData = () => {
|
||||
setIsLoading(true);
|
||||
setIsLoadingRecent(true);
|
||||
api
|
||||
.get(
|
||||
`${host()}${endpoints.globalSearch({
|
||||
@ -134,37 +151,120 @@ function Home() {
|
||||
});
|
||||
setRecentData(repoData);
|
||||
setIsLoading(false);
|
||||
setIsLoadingRecent(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setIsLoading(false);
|
||||
setIsLoadingRecent(false);
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const getBookmarks = () => {
|
||||
setIsLoadingBookmarks(true);
|
||||
api
|
||||
.get(
|
||||
`${host()}${endpoints.globalSearch({
|
||||
searchQuery: '',
|
||||
pageNumber: 1,
|
||||
pageSize: HOME_BOOKMARKS_PAGE_SIZE,
|
||||
sortBy: sortByCriteria.relevance?.value,
|
||||
filter: { IsBookmarked: 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);
|
||||
});
|
||||
setBookmarkData(repoData);
|
||||
setIsLoading(false);
|
||||
setIsLoadingBookmarks(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setIsLoading(false);
|
||||
setIsLoadingBookmarks(false);
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClickViewAll = (target) => {
|
||||
navigate({ pathname: `/explore`, search: createSearchParams({ sortby: target }).toString() });
|
||||
const handleClickViewAll = (type, value) => {
|
||||
navigate({ pathname: `/explore`, search: createSearchParams({ [type]: value }).toString() });
|
||||
};
|
||||
|
||||
const renderMostPopular = () => {
|
||||
const isNoData = () =>
|
||||
!isLoading &&
|
||||
!isLoadingBookmarks &&
|
||||
!isLoadingStars &&
|
||||
!isLoadingPopular &&
|
||||
!isLoadingRecent &&
|
||||
bookmarkData.length === 0 &&
|
||||
starData.length === 0 &&
|
||||
popularData.length === 0 &&
|
||||
recentData.length === 0;
|
||||
|
||||
const renderCards = (cardArray) => {
|
||||
return (
|
||||
popularData &&
|
||||
popularData.map((item, index) => {
|
||||
cardArray &&
|
||||
cardArray.map((item, index) => {
|
||||
return (
|
||||
<RepoCard
|
||||
name={item.name}
|
||||
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}
|
||||
@ -180,73 +280,89 @@ function Home() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderRecentlyUpdated = () => {
|
||||
return (
|
||||
recentData &&
|
||||
recentData.map((item, index) => {
|
||||
return (
|
||||
<RepoCard
|
||||
name={item.name}
|
||||
version={item.latestVersion}
|
||||
description={item.description}
|
||||
downloads={item.downloads}
|
||||
isSigned={item.isSigned}
|
||||
vendor={item.vendor}
|
||||
platforms={item.platforms}
|
||||
key={index}
|
||||
vulnerabilityData={{
|
||||
vulnerabilitySeverity: item.vulnerabiltySeverity,
|
||||
count: item.vulnerabilityCount
|
||||
}}
|
||||
lastUpdated={item.lastUpdated}
|
||||
logo={item.logo}
|
||||
/>
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<Stack alignItems="center" className={classes.gridWrapper}>
|
||||
<Stack className={classes.sectionHeaderContainer} sx={{ paddingTop: '3rem' }}>
|
||||
<div>
|
||||
<Typography variant="h4" align="left" className={classes.sectionTitle}>
|
||||
Most popular images
|
||||
</Typography>
|
||||
</div>
|
||||
<div onClick={() => handleClickViewAll(sortByCriteria.downloads.value)}>
|
||||
<Typography variant="body2" className={classes.viewAll}>
|
||||
View all
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
{renderMostPopular()}
|
||||
{/* currently most popular will be by downloads until stars are implemented */}
|
||||
<Stack className={classes.sectionHeaderContainer}>
|
||||
<div>
|
||||
<Typography variant="h4" align="left" className={classes.sectionTitle}>
|
||||
Recently updated images
|
||||
</Typography>
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className={classes.viewAll}
|
||||
onClick={() => handleClickViewAll(sortByCriteria.updateTime.value)}
|
||||
>
|
||||
View all
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
{renderRecentlyUpdated()}
|
||||
const renderContent = () => {
|
||||
return isNoData() === true ? (
|
||||
<NoDataComponent text="No images" />
|
||||
) : (
|
||||
<Stack alignItems="center" className={classes.gridWrapper}>
|
||||
<Stack className={classes.sectionHeaderContainer} sx={{ paddingTop: '3rem' }}>
|
||||
<div>
|
||||
<Typography variant="h4" align="left" className={classes.sectionTitle}>
|
||||
Most popular images
|
||||
</Typography>
|
||||
</div>
|
||||
<div onClick={() => handleClickViewAll('sortby', sortByCriteria.downloads.value)}>
|
||||
<Typography variant="body2" className={classes.viewAll}>
|
||||
View all
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{isLoadingPopular ? <Loading /> : renderCards(popularData, isLoadingPopular)}
|
||||
{/* currently most popular will be by downloads until stars are implemented */}
|
||||
<Stack className={classes.sectionHeaderContainer}>
|
||||
<div>
|
||||
<Typography variant="h4" align="left" className={classes.sectionTitle}>
|
||||
Recently updated images
|
||||
</Typography>
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className={classes.viewAll}
|
||||
onClick={() => handleClickViewAll('sortby', sortByCriteria.updateTime.value)}
|
||||
>
|
||||
View all
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
{isLoadingRecent ? <Loading /> : renderCards(recentData, isLoadingRecent)}
|
||||
{!isEmpty(bookmarkData) && (
|
||||
<>
|
||||
<Stack className={classes.sectionHeaderContainer}>
|
||||
<div>
|
||||
<Typography variant="h4" align="left" className={classes.sectionTitle}>
|
||||
Bookmarks
|
||||
</Typography>
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className={classes.viewAll}
|
||||
onClick={() => handleClickViewAll('filter', 'IsBookmarked')}
|
||||
>
|
||||
View all
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
return <>{isLoading ? <Loading /> : renderContent()}</>;
|
||||
}
|
||||
|
||||
export default Home;
|
||||
|
@ -1,32 +1,35 @@
|
||||
// react global
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { host } from '../../host';
|
||||
|
||||
// utility
|
||||
import { api } from '../../api';
|
||||
import { api, endpoints } from '../../api';
|
||||
import { host } from '../../host';
|
||||
import { isEmpty, isObject } from 'lodash';
|
||||
|
||||
// components
|
||||
import { Card, CardContent, CssBaseline } from '@mui/material';
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Box from '@mui/material/Box';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import TermsOfService from './TermsOfService';
|
||||
import Loading from '../Shared/Loading';
|
||||
|
||||
import { GoogleLoginButton, GithubLoginButton, OIDCLoginButton } from './ThirdPartyLoginComponents';
|
||||
|
||||
// styling
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import { Card, CardContent } from '@mui/material';
|
||||
import Loading from '../Shared/Loading';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
cardContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto',
|
||||
padding: '0.625rem',
|
||||
position: 'relative'
|
||||
},
|
||||
loginCard: {
|
||||
@ -34,45 +37,82 @@ const useStyles = makeStyles(() => ({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: '20%',
|
||||
width: '60%',
|
||||
height: '60%',
|
||||
background: '#FFFFFF',
|
||||
gap: '0.625em',
|
||||
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
|
||||
borderRadius: '1.5rem',
|
||||
borderRadius: '0.75rem',
|
||||
minWidth: '30rem'
|
||||
},
|
||||
loginCardContent: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: '0.1875rem black',
|
||||
maxWidth: '73%',
|
||||
height: '90%'
|
||||
width: '100%',
|
||||
padding: '3rem'
|
||||
},
|
||||
text: {
|
||||
color: '#14191F',
|
||||
width: '100%',
|
||||
fontSize: '1.5rem'
|
||||
fontSize: '1.5rem',
|
||||
lineHeight: '2.25rem',
|
||||
letterSpacing: '-0.01rem',
|
||||
marginBottom: '0.25rem'
|
||||
},
|
||||
subtext: {
|
||||
color: '#52637A',
|
||||
width: '100%',
|
||||
fontSize: '1rem'
|
||||
fontSize: '1rem',
|
||||
marginBottom: '2.375rem'
|
||||
},
|
||||
textField: {
|
||||
borderRadius: '0.25rem'
|
||||
borderRadius: '0.25rem',
|
||||
marginTop: 0,
|
||||
marginBottom: '1.5rem'
|
||||
},
|
||||
button: {
|
||||
textColor: {
|
||||
color: '#8596AD'
|
||||
},
|
||||
labelColor: {
|
||||
color: '#667C99',
|
||||
'&:focused': {
|
||||
color: '#667C99'
|
||||
}
|
||||
},
|
||||
continueButton: {
|
||||
textTransform: 'none',
|
||||
color: '##FFFFFF',
|
||||
fontSize: '1.4375rem',
|
||||
fontWeight: '500',
|
||||
background: '#F15527',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '1.438rem',
|
||||
fontWeight: '600',
|
||||
height: '3.125rem',
|
||||
borderRadius: '0.25rem',
|
||||
letterSpacing: '0.01rem'
|
||||
letterSpacing: '0.01rem',
|
||||
marginBottom: '1rem',
|
||||
padding: 0,
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: '#F15527',
|
||||
boxShadow: 'none'
|
||||
}
|
||||
},
|
||||
continueAsGuestButton: {
|
||||
textTransform: 'none',
|
||||
background: '#FFFFFF',
|
||||
color: '#52637A',
|
||||
fontSize: '1.438rem',
|
||||
fontWeight: '600',
|
||||
height: '3.125rem',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid #52637A',
|
||||
letterSpacing: '0.01rem',
|
||||
marginBottom: '1rem',
|
||||
padding: 0,
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
boxShadow: 'none'
|
||||
}
|
||||
},
|
||||
gitLogo: {
|
||||
height: '24px',
|
||||
@ -94,6 +134,15 @@ const useStyles = makeStyles(() => ({
|
||||
fontWeight: '400',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem'
|
||||
},
|
||||
divider: {
|
||||
color: '#C2CBD6',
|
||||
marginBottom: '2rem',
|
||||
width: '100%'
|
||||
},
|
||||
thirdPartyLoginContainer: {
|
||||
width: '100%',
|
||||
marginBottom: '2rem'
|
||||
}
|
||||
}));
|
||||
|
||||
@ -105,6 +154,8 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
const [requestProcessing, setRequestProcessing] = useState(false);
|
||||
const [requestError, setRequestError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [authMethods, setAuthMethods] = useState({});
|
||||
const [isGuestLoginEnabled, setIsGuestLoginEnabled] = useState(false);
|
||||
const abortController = useMemo(() => new AbortController(), []);
|
||||
const navigate = useNavigate();
|
||||
const classes = useStyles();
|
||||
@ -117,17 +168,31 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
navigate('/home');
|
||||
} else {
|
||||
api
|
||||
.get(`${host()}/v2/`, abortController.signal)
|
||||
.get(`${host()}${endpoints.authConfig}`, abortController.signal)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
localStorage.setItem('token', '-');
|
||||
if (response.data?.http && isEmpty(response.data?.http?.auth)) {
|
||||
localStorage.setItem('authConfig', '{}');
|
||||
setIsLoggedIn(true);
|
||||
navigate('/home');
|
||||
} else if (response.data?.http?.auth) {
|
||||
setAuthMethods(response.data?.http?.auth);
|
||||
localStorage.setItem('authConfig', JSON.stringify(response.data?.http?.auth));
|
||||
setIsLoading(false);
|
||||
wrapperSetLoading(false);
|
||||
navigate('/home');
|
||||
api
|
||||
.get(`${host()}${endpoints.status}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setIsGuestLoginEnabled(true);
|
||||
}
|
||||
})
|
||||
.catch(() => console.log('could not obtain guest login status'));
|
||||
}
|
||||
setIsLoading(false);
|
||||
wrapperSetLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setIsLoading(false);
|
||||
wrapperSetLoading(false);
|
||||
});
|
||||
@ -137,22 +202,20 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClick = (event) => {
|
||||
event.preventDefault();
|
||||
const handleBasicAuth = () => {
|
||||
setRequestProcessing(true);
|
||||
let cfg = {};
|
||||
const token = btoa(username + ':' + password);
|
||||
cfg = {
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`
|
||||
}
|
||||
},
|
||||
withCredentials: host() !== window?.location?.origin
|
||||
};
|
||||
api
|
||||
.get(`${host()}/v2/`, abortController.signal, cfg)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
const token = btoa(username + ':' + password);
|
||||
localStorage.setItem('token', token);
|
||||
setRequestProcessing(false);
|
||||
setRequestError(false);
|
||||
setIsLoggedIn(true);
|
||||
@ -165,11 +228,34 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
});
|
||||
};
|
||||
|
||||
const handleClick = (event) => {
|
||||
event.preventDefault();
|
||||
if (Object.keys(authMethods).includes('htpasswd')) {
|
||||
handleBasicAuth();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGuestClick = () => {
|
||||
setRequestProcessing(false);
|
||||
setRequestError(false);
|
||||
setIsLoggedIn(true);
|
||||
navigate('/home');
|
||||
};
|
||||
|
||||
const handleClickExternalLogin = (event, provider) => {
|
||||
event.preventDefault();
|
||||
window.location.replace(
|
||||
`${host()}${endpoints.openidAuth}?callback_ui=${encodeURIComponent(
|
||||
window?.location?.origin
|
||||
)}/home&provider=${provider}`
|
||||
);
|
||||
};
|
||||
|
||||
const handleChange = (event, type) => {
|
||||
event.preventDefault();
|
||||
setRequestError(false);
|
||||
|
||||
const val = event.target.value;
|
||||
const val = event.target?.value;
|
||||
const isEmpty = val === '';
|
||||
|
||||
switch (type) {
|
||||
@ -194,8 +280,25 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
}
|
||||
};
|
||||
|
||||
const renderThirdPartyLoginMethods = () => {
|
||||
let isGoogle = isObject(authMethods.openid?.providers?.google);
|
||||
// let isGitlab = isObject(authMethods.openid?.providers?.gitlab);
|
||||
let isGithub = isObject(authMethods.openid?.providers?.github);
|
||||
let isOIDC = isObject(authMethods.openid?.providers?.oidc);
|
||||
let oidcName = authMethods.openid?.providers?.oidc?.name;
|
||||
|
||||
return (
|
||||
<Stack direction="column" spacing="1rem" className={classes.thirdPartyLoginContainer}>
|
||||
{isGithub && <GithubLoginButton handleClick={handleClickExternalLogin} />}
|
||||
{isGoogle && <GoogleLoginButton handleClick={handleClickExternalLogin} />}
|
||||
{/* {isGitlab && <GitlabLoginButton handleClick={handleClickExternalLogin} />} */}
|
||||
{isOIDC && <OIDCLoginButton handleClick={handleClickExternalLogin} oidcName={oidcName} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={classes.cardContainer} data-testid="signin-container">
|
||||
<div className={classes.cardContainer} data-testid="signin-container">
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
@ -203,68 +306,70 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
<CardContent className={classes.loginCardContent}>
|
||||
<CssBaseline />
|
||||
<Typography align="left" className={classes.text} component="h1" variant="h4">
|
||||
Sign in
|
||||
Sign In
|
||||
</Typography>
|
||||
<Typography align="left" className={classes.subtext} variant="body1" gutterBottom>
|
||||
Welcome back! Please enter your details.
|
||||
Welcome back! Please login.
|
||||
</Typography>
|
||||
|
||||
<Box component="form" onSubmit={null} noValidate autoComplete="off" sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="username"
|
||||
label="Username"
|
||||
name="username"
|
||||
className={classes.textField}
|
||||
onInput={(e) => handleChange(e, 'username')}
|
||||
error={usernameError != null}
|
||||
helperText={usernameError}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Enter password"
|
||||
type="password"
|
||||
id="password"
|
||||
className={classes.textField}
|
||||
onInput={(e) => handleChange(e, 'password')}
|
||||
error={passwordError != null}
|
||||
helperText={passwordError}
|
||||
/>
|
||||
{requestProcessing && <CircularProgress style={{ marginTop: 20 }} color="secondary" />}
|
||||
{requestError && (
|
||||
<Alert style={{ marginTop: 20 }} severity="error">
|
||||
Authentication Failed. Please try again.
|
||||
</Alert>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
{renderThirdPartyLoginMethods()}
|
||||
{Object.keys(authMethods).length > 1 && <Divider className={classes.divider}>or</Divider>}
|
||||
{Object.keys(authMethods).includes('htpasswd') && (
|
||||
<Box component="form" onSubmit={null} noValidate autoComplete="off">
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
sx={{
|
||||
mt: 3,
|
||||
mb: 1,
|
||||
background: '#1479FF',
|
||||
'&:hover': {
|
||||
backgroundColor: '#1565C0'
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{' '}
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
<TermsOfService sx={{ mt: 2, mb: 4 }} />
|
||||
id="username"
|
||||
label="Username"
|
||||
name="username"
|
||||
className={classes.textField}
|
||||
inputProps={{ className: classes.textColor }}
|
||||
InputLabelProps={{ className: classes.labelColor }}
|
||||
onInput={(e) => handleChange(e, 'username')}
|
||||
error={usernameError != null}
|
||||
helperText={usernameError}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Enter password"
|
||||
type="password"
|
||||
id="password"
|
||||
className={classes.textField}
|
||||
inputProps={{ className: classes.textColor }}
|
||||
InputLabelProps={{ className: classes.labelColor }}
|
||||
onInput={(e) => handleChange(e, 'password')}
|
||||
error={passwordError != null}
|
||||
helperText={passwordError}
|
||||
/>
|
||||
{requestProcessing && <CircularProgress style={{ marginTop: 20 }} color="secondary" />}
|
||||
{requestError && (
|
||||
<Alert style={{ marginTop: 20 }} severity="error">
|
||||
Authentication Failed. Please try again.
|
||||
</Alert>
|
||||
)}
|
||||
<div>
|
||||
<Button fullWidth variant="contained" className={classes.continueButton} onClick={handleClick}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{isGuestLoginEnabled && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
className={classes.continueAsGuestButton}
|
||||
onClick={handleGuestClick}
|
||||
>
|
||||
Continue as guest
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,55 +1,52 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import React from 'react';
|
||||
import logoWhite from '../../assets/Zot-white.svg';
|
||||
import loginDrawing from '../../assets/codeReviewSignIn.png';
|
||||
import backgroundImage from '../../assets/backgroundSignIn.png';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
import logoWhite from '../../assets/zotLogoWhiteHorizontal.svg';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
container: {
|
||||
backgroundImage: `url(${backgroundImage})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
minHeight: '100%',
|
||||
alignItems: 'center'
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
contentContainer: {
|
||||
width: '51%',
|
||||
height: '22%'
|
||||
},
|
||||
logoContainer: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
logo: {
|
||||
maxHeight: 96,
|
||||
maxWidth: 320,
|
||||
marginTop: '17%'
|
||||
},
|
||||
loginDrawing: {
|
||||
maxHeight: 298,
|
||||
maxWidth: 464,
|
||||
marginTop: '4%'
|
||||
width: '64%'
|
||||
},
|
||||
mainText: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 700,
|
||||
maxWidth: '45%',
|
||||
marginTop: '4%',
|
||||
fontSize: '2.5rem'
|
||||
},
|
||||
captionText: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
maxWidth: '48%',
|
||||
marginTop: '2%',
|
||||
fontSize: '1.1875rem'
|
||||
color: '#F6F7F9',
|
||||
fontWeight: '700',
|
||||
width: '100%',
|
||||
fontSize: '2.5rem',
|
||||
lineHeight: '3rem'
|
||||
}
|
||||
}));
|
||||
|
||||
export default function SigninPresentation() {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Stack spacing={0} className={classes.container} data-testid="presentation-container">
|
||||
<img src={logoWhite} alt="zot logo" className={classes.logo}></img>
|
||||
<Typography variant="h2" className={classes.mainText}>
|
||||
Welcome to our repository
|
||||
</Typography>
|
||||
<Typography variant="body1" className={classes.captionText}>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Amet, dis pellentesque posuere nulla tortor ac eu arcu
|
||||
nunc.
|
||||
</Typography>
|
||||
<img src={loginDrawing} alt="drawing" className={classes.loginDrawing}></img>
|
||||
</Stack>
|
||||
<div className={classes.container}>
|
||||
<Stack spacing={'3rem'} className={classes.contentContainer} data-testid="presentation-container">
|
||||
<div className={classes.logoContainer}>
|
||||
<img src={logoWhite} alt="zot logo" className={classes.logo}></img>
|
||||
</div>
|
||||
<Typography variant="h2" className={classes.mainText}>
|
||||
OCI-native container image registry, simplified
|
||||
</Typography>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
subtext: {
|
||||
color: '#52637A',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: '400',
|
||||
lineHeight: '154%',
|
||||
letterSpacing: '0.025rem',
|
||||
marginBottom: '0'
|
||||
},
|
||||
text: {
|
||||
color: '#0F2139',
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: '154%',
|
||||
fontWeight: '600',
|
||||
letterSpacing: '0.025rem'
|
||||
}
|
||||
}));
|
||||
|
||||
export default function TermsOfService(props) {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<Typography variant="caption" className={classes.subtext} align="justify" {...props} pb={6}>
|
||||
By creating an account, you agree to the Terms of Service. For more information about our privacy practices, see
|
||||
the ZOT's Privacy Policy.
|
||||
</Typography>
|
||||
<Typography variant="caption" className={classes.text} align="center" {...props}>
|
||||
Privacy Policy | Terms of Service
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
94
src/components/Login/ThirdPartyLoginComponents.jsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import SvgIcon from '@mui/material/SvgIcon';
|
||||
import githubLogo from '../../assets/GhIcon.svg';
|
||||
|
||||
// styling
|
||||
import { makeStyles } from '@mui/styles';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
githubButton: {
|
||||
textTransform: 'none',
|
||||
background: '#161614',
|
||||
color: '#FFFFFF',
|
||||
borderRadius: '0.25rem',
|
||||
padding: 0,
|
||||
height: '3.125rem',
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: '#161614',
|
||||
boxShadow: 'none'
|
||||
}
|
||||
},
|
||||
googleButton: {
|
||||
textTransform: 'none',
|
||||
background: '#FFFFFF',
|
||||
color: '#52637A',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid #52637A',
|
||||
padding: 0,
|
||||
height: '3.125rem',
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
boxShadow: 'none'
|
||||
}
|
||||
},
|
||||
buttonsText: {
|
||||
lineHeight: '2.125rem',
|
||||
height: '2.125rem',
|
||||
fontSize: '1.438rem',
|
||||
fontWeight: '600',
|
||||
letterSpacing: '0.01rem'
|
||||
}
|
||||
}));
|
||||
|
||||
function GithubLoginButton({ handleClick }) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
className={classes.githubButton}
|
||||
endIcon={<SvgIcon fontSize="medium">{githubLogo}</SvgIcon>}
|
||||
onClick={(e) => handleClick(e, 'github')}
|
||||
>
|
||||
<span className={classes.buttonsText}>Continue with Github</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleLoginButton({ handleClick }) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Button fullWidth variant="contained" className={classes.googleButton} onClick={(e) => handleClick(e, 'google')}>
|
||||
<span className={classes.buttonsText}>Continue with Google</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function GitlabLoginButton({ handleClick }) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Button fullWidth variant="contained" className={classes.button} onClick={(e) => handleClick(e, 'gitlab')}>
|
||||
Sign in with Gitlab
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function OIDCLoginButton({ handleClick, oidcName }) {
|
||||
const classes = useStyles();
|
||||
const loginWithName = oidcName || 'OIDC';
|
||||
|
||||
return (
|
||||
<Button fullWidth variant="contained" className={classes.button} onClick={(e) => handleClick(e, 'oidc')}>
|
||||
Sign in with {loginWithName}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { GithubLoginButton, GoogleLoginButton, GitlabLoginButton, OIDCLoginButton };
|
@ -3,16 +3,20 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// external
|
||||
import { DateTime } from 'luxon';
|
||||
import { isEmpty, uniq } from 'lodash';
|
||||
|
||||
// utility
|
||||
import { api, endpoints } from '../../api';
|
||||
import { host } from '../../host';
|
||||
import { useParams, useNavigate, createSearchParams } from 'react-router-dom';
|
||||
|
||||
// components
|
||||
import Tags from './Tabs/Tags.jsx';
|
||||
import { Card, CardContent, CardMedia, Chip, Grid, Stack, Tooltip, Typography } from '@mui/material';
|
||||
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';
|
||||
import { host } from '../../host';
|
||||
|
||||
// placeholder images
|
||||
import repocube1 from '../../assets/repocube-1.png';
|
||||
@ -20,12 +24,13 @@ import repocube2 from '../../assets/repocube-2.png';
|
||||
import repocube3 from '../../assets/repocube-3.png';
|
||||
import repocube4 from '../../assets/repocube-4.png';
|
||||
|
||||
import Tags from './Tabs/Tags.jsx';
|
||||
import RepoDetailsMetadata from './RepoDetailsMetadata';
|
||||
import Loading from '../Shared/Loading';
|
||||
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
|
||||
import { isEmpty, uniq } from 'lodash';
|
||||
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
import { mapToRepoFromRepoInfo } from 'utilities/objectModels';
|
||||
import { isAuthenticated } from 'utilities/authUtilities';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
pageWrapper: {
|
||||
@ -192,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();
|
||||
@ -216,6 +225,28 @@ function RepoDetails() {
|
||||
));
|
||||
};
|
||||
|
||||
const handleBookmarkClick = () => {
|
||||
api.put(`${host()}${endpoints.bookmarkToggle(name)}`, abortController.signal).then((response) => {
|
||||
if (response && response.status === 200) {
|
||||
setRepoDetailData((prevState) => ({
|
||||
...prevState,
|
||||
isBookmarked: !prevState.isBookmarked
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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'} •`;
|
||||
};
|
||||
@ -256,11 +287,31 @@ function RepoDetails() {
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={2}>
|
||||
<VulnerabilityIconCheck
|
||||
vulnerabilitySeverity={repoDetailData.vulnerabiltySeverity}
|
||||
count={repoDetailData?.vulnerabilityCount}
|
||||
<VulnerabilityIconCheck vulnerabilitySeverity={repoDetailData?.vulnerabilitySeverity} />
|
||||
<SignatureIconCheck
|
||||
isSigned={repoDetailData.isSigned}
|
||||
signatureInfo={repoDetailData.signatureInfo}
|
||||
/>
|
||||
<SignatureIconCheck isSigned={repoDetailData.isSigned} />
|
||||
</Stack>
|
||||
<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}>
|
||||
@ -294,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>
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
54
src/components/Shared/DeleteTag.jsx
Normal 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>
|
||||
);
|
||||
}
|
30
src/components/Shared/DeleteTagConfirmDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { Card, CardContent, Checkbox, FormControlLabel, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import { isBoolean, isArray } from 'lodash';
|
||||
import { isArray, isNil } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
@ -42,17 +42,17 @@ function FilterCard(props) {
|
||||
const classes = useStyles();
|
||||
const { title, filters, updateFilters, filterValue, wrapperLoading } = props;
|
||||
|
||||
const handleFilterClicked = (event, changedFilterLabel, changedFilterValue) => {
|
||||
const handleFilterClicked = (event, changedFilterValue) => {
|
||||
const { checked } = event.target;
|
||||
if (checked) {
|
||||
if (filters[0]?.type === 'boolean') {
|
||||
updateFilters(checked);
|
||||
if (!isArray(filterValue)) {
|
||||
updateFilters({ ...filterValue, [changedFilterValue]: true });
|
||||
} else {
|
||||
updateFilters([...filterValue, changedFilterValue]);
|
||||
}
|
||||
} else {
|
||||
if (filters[0]?.type === 'boolean') {
|
||||
updateFilters(checked);
|
||||
if (!isArray(filterValue)) {
|
||||
updateFilters({ ...filterValue, [changedFilterValue]: false });
|
||||
} else {
|
||||
updateFilters(filterValue.filter((e) => e !== changedFilterValue));
|
||||
}
|
||||
@ -60,12 +60,14 @@ function FilterCard(props) {
|
||||
}
|
||||
};
|
||||
|
||||
const getCheckboxStatus = (label) => {
|
||||
if (isArray(filterValue)) {
|
||||
return filterValue?.includes(label);
|
||||
} else if (isBoolean(filterValue)) {
|
||||
return filterValue;
|
||||
const getCheckboxStatus = (filter) => {
|
||||
if (isNil(filter)) {
|
||||
return false;
|
||||
}
|
||||
if (isArray(filterValue)) {
|
||||
return filterValue?.includes(filter.label);
|
||||
}
|
||||
return filterValue[filter.value] || false;
|
||||
};
|
||||
|
||||
const getFilterRows = () => {
|
||||
@ -79,8 +81,8 @@ function FilterCard(props) {
|
||||
control={<Checkbox sx={{ padding: '0.188rem', color: '#52637A' }} />}
|
||||
label={filter.label}
|
||||
id={title}
|
||||
checked={getCheckboxStatus(filter.label)}
|
||||
onChange={() => handleFilterClicked(event, filter.label, filter.value)}
|
||||
checked={getCheckboxStatus(filter)}
|
||||
onChange={() => handleFilterClicked(event, filter.value)}
|
||||
disabled={wrapperLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
40
src/components/Shared/NoDataComponent.jsx
Normal file
@ -0,0 +1,40 @@
|
||||
// react global
|
||||
import React from 'react';
|
||||
|
||||
// components
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
|
||||
//styling
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
|
||||
import nodataImage from '../../assets/noData.svg';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
noDataContainer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
noDataImage: {
|
||||
maxWidth: '233px',
|
||||
maxHeight: '240px'
|
||||
},
|
||||
noDataText: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: '600',
|
||||
color: theme.palette.secondary.main
|
||||
}
|
||||
}));
|
||||
|
||||
function NoDataComponent({ text }) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Stack className={classes.noDataContainer}>
|
||||
<img src={nodataImage} className={classes.noDataImage} />
|
||||
<Typography className={classes.noDataText}>{text ? text : 'No Data'}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoDataComponent;
|
@ -1,9 +1,16 @@
|
||||
// react global
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useRef, useMemo, useState } from 'react';
|
||||
import { useNavigate, createSearchParams } from 'react-router-dom';
|
||||
|
||||
// utility
|
||||
import { DateTime } from 'luxon';
|
||||
import { uniq } from 'lodash';
|
||||
|
||||
// api module
|
||||
import { api, endpoints } from '../../api';
|
||||
import { host } from '../../host';
|
||||
import { isAuthenticated } from '../../utilities/authUtilities';
|
||||
|
||||
// components
|
||||
import {
|
||||
Card,
|
||||
@ -15,9 +22,15 @@ import {
|
||||
Chip,
|
||||
Grid,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
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
|
||||
import repocube1 from '../../assets/repocube-1.png';
|
||||
@ -27,8 +40,6 @@ import repocube4 from '../../assets/repocube-4.png';
|
||||
|
||||
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
|
||||
import { uniq } from 'lodash';
|
||||
import { useTheme } from '@emotion/react';
|
||||
|
||||
// temporary utility to get image
|
||||
const randomIntFromInterval = (min, max) => {
|
||||
@ -89,7 +100,8 @@ const useStyles = makeStyles((theme) => ({
|
||||
}
|
||||
},
|
||||
contentRight: {
|
||||
height: '100%'
|
||||
justifyContent: 'flex-end',
|
||||
textAlign: 'end'
|
||||
},
|
||||
contentRightLabel: {
|
||||
fontSize: '0.75rem',
|
||||
@ -105,6 +117,10 @@ const useStyles = makeStyles((theme) => ({
|
||||
textAlign: 'end',
|
||||
marginLeft: '0.5rem'
|
||||
},
|
||||
contentRightActions: {
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'flex-end'
|
||||
},
|
||||
signedBadge: {
|
||||
color: '#9ccc65',
|
||||
height: '1.375rem',
|
||||
@ -161,7 +177,31 @@ function RepoCard(props) {
|
||||
const isXsSize = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const MAX_PLATFORM_CHIPS = isXsSize ? 3 : 6;
|
||||
|
||||
const { name, vendor, platforms, description, downloads, isSigned, lastUpdated, version, vulnerabilityData } = props;
|
||||
const abortController = useMemo(() => new AbortController(), []);
|
||||
|
||||
const {
|
||||
name,
|
||||
vendor,
|
||||
platforms,
|
||||
description,
|
||||
downloads,
|
||||
stars,
|
||||
isSigned,
|
||||
signatureInfo,
|
||||
lastUpdated,
|
||||
version,
|
||||
vulnerabilityData,
|
||||
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)}`);
|
||||
@ -174,6 +214,33 @@ function RepoCard(props) {
|
||||
navigate({ pathname: `/explore`, search: createSearchParams({ filter: textContent }).toString() });
|
||||
};
|
||||
|
||||
const handleBookmarkClick = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
api.put(`${host()}${endpoints.bookmarkToggle(name)}`, abortController.signal).then((response) => {
|
||||
if (response.status === 200) {
|
||||
setCurrentBookmarkValue((prevState) => !prevState);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
@ -205,8 +272,32 @@ function RepoCard(props) {
|
||||
return lastDate;
|
||||
};
|
||||
|
||||
const renderBookmark = () => {
|
||||
return (
|
||||
isAuthenticated() && (
|
||||
<IconButton component="span" onClick={handleBookmarkClick} data-testid="bookmark-button">
|
||||
{currentBookmarkValue ? (
|
||||
<BookmarkIcon data-testid="bookmarked" />
|
||||
) : (
|
||||
<BookmarkBorderIcon data-testid="not-bookmarked" />
|
||||
)}
|
||||
</IconButton>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
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}>
|
||||
<Card variant="outlined" className={classes.card} data-testid="repo-card">
|
||||
<CardActionArea
|
||||
onClick={goToDetails}
|
||||
classes={{
|
||||
@ -236,7 +327,7 @@ function RepoCard(props) {
|
||||
<VulnerabilityIconCheck {...vulnerabilityData} className="hide-on-mobile" />
|
||||
</div>
|
||||
<div className="hide-on-mobile">
|
||||
<SignatureIconCheck isSigned={isSigned} className="hide-on-mobile" />
|
||||
<SignatureIconCheck isSigned={isSigned} signatureInfo={signatureInfo} className="hide-on-mobile" />
|
||||
</div>
|
||||
</Stack>
|
||||
<Tooltip title={description || 'Description not available'} placement="top">
|
||||
@ -265,17 +356,16 @@ function RepoCard(props) {
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={2} md={2} className={`hide-on-mobile ${classes.contentRight}`}>
|
||||
<Grid container item justifyContent="flex-end" textAlign="end">
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" component="span" className={classes.contentRightLabel}>
|
||||
Downloads •
|
||||
</Typography>
|
||||
<Typography variant="body2" component="span" className={classes.contentRightValue}>
|
||||
{!isNaN(downloads) ? downloads : `not available`}
|
||||
</Typography>
|
||||
</Grid>
|
||||
{/* <Grid item xs={12}>
|
||||
<Grid item container xs={2} md={2} className={`hide-on-mobile ${classes.contentRight}`}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" component="span" className={classes.contentRightLabel}>
|
||||
Downloads •
|
||||
</Typography>
|
||||
<Typography variant="body2" component="span" className={classes.contentRightValue}>
|
||||
{!isNaN(downloads) ? downloads : `not available`}
|
||||
</Typography>
|
||||
</Grid>
|
||||
{/* <Grid item xs={12}>
|
||||
<Typography variant="body2" component="span" className={classes.contentRightLabel}>
|
||||
Rating •
|
||||
</Typography>
|
||||
@ -283,6 +373,17 @@ 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>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
21
src/components/Shared/SignatureTooltip.jsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Typography, Stack } from '@mui/material';
|
||||
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
function SignatureTooltip({ isSigned, signatureInfo }) {
|
||||
const { tool, isTrusted, author } = !isEmpty(signatureInfo)
|
||||
? signatureInfo[0]
|
||||
: { tool: 'Unknown', isTrusted: 'Unknown', author: 'Unknown' };
|
||||
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Typography>{isSigned ? 'Verified Signature' : 'Unverified Signature'}</Typography>
|
||||
<Typography>Tool: {tool}</Typography>
|
||||
<Typography>Trusted: {!isEmpty(isTrusted) ? isTrusted : 'Unknown'}</Typography>
|
||||
<Typography>Author: {!isEmpty(author) ? author : 'Unknown'}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignatureTooltip;
|
@ -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}
|
||||
|
@ -72,7 +72,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
}));
|
||||
function VulnerabilitiyCard(props) {
|
||||
const classes = useStyles();
|
||||
const { cve, name } = props;
|
||||
const { cve, name, platform } = props;
|
||||
const [openDesc, setOpenDesc] = useState(false);
|
||||
const [openFixed, setOpenFixed] = useState(false);
|
||||
const [loadingFixed, setLoadingFixed] = useState(true);
|
||||
@ -90,7 +90,12 @@ function VulnerabilitiyCard(props) {
|
||||
setLoadingFixed(true);
|
||||
api
|
||||
.get(
|
||||
`${host()}${endpoints.imageListWithCVEFixed(cve.id, name, { pageNumber, pageSize: CVE_FIXEDIN_PAGE_SIZE })}`,
|
||||
`${host()}${endpoints.imageListWithCVEFixed(
|
||||
cve.id,
|
||||
name,
|
||||
{ pageNumber, pageSize: CVE_FIXEDIN_PAGE_SIZE },
|
||||
platform ? { Os: platform.Os, Arch: platform.Arch } : {}
|
||||
)}`,
|
||||
abortController.signal
|
||||
)
|
||||
.then((response) => {
|
||||
|
@ -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,15 +86,27 @@ 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 } = props;
|
||||
const { name, tag, digest, platform } = props;
|
||||
|
||||
// pagination props
|
||||
const [cveFilter, setCveFilter] = useState('');
|
||||
@ -81,11 +114,18 @@ 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}`;
|
||||
};
|
||||
|
||||
const getPaginatedCVEs = () => {
|
||||
api
|
||||
.get(
|
||||
`${host()}${endpoints.vulnerabilitiesForRepo(
|
||||
`${name}:${tag}`,
|
||||
getCVERequestName(),
|
||||
{ pageNumber, pageSize: EXPLORE_PAGE_SIZE },
|
||||
cveFilter
|
||||
)}`,
|
||||
@ -110,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);
|
||||
@ -120,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(() => {
|
||||
@ -168,10 +254,16 @@ function VulnerabilitiesDetails(props) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (openExport && isEmpty(allCveData)) {
|
||||
getAllCVEs();
|
||||
}
|
||||
}, [openExport]);
|
||||
|
||||
const renderCVEs = () => {
|
||||
return !isEmpty(cveData) ? (
|
||||
cveData.map((cve, index) => {
|
||||
return <VulnerabilitiyCard key={index} cve={cve} name={name} />;
|
||||
return <VulnerabilitiyCard key={index} cve={cve} name={name} platform={platform} />;
|
||||
})
|
||||
) : (
|
||||
<div>{!isLoading && <Typography className={classes.none}> No Vulnerabilities </Typography>}</div>
|
||||
@ -190,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'}
|
||||
|
@ -59,7 +59,6 @@ const useStyles = makeStyles((theme) => ({
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.5rem',
|
||||
color: '#52637A',
|
||||
padding: '1rem 0 0 0',
|
||||
maxWidth: '100%',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
padding: '0.5rem 0 0 0',
|
||||
@ -209,7 +208,14 @@ function TagDetails() {
|
||||
case 'IsDependentOn':
|
||||
return <IsDependentOn name={imageDetailData.name} digest={selectedManifest.digest} />;
|
||||
case 'Vulnerabilities':
|
||||
return <VulnerabilitiesDetails name={reponame} tag={tag} />;
|
||||
return (
|
||||
<VulnerabilitiesDetails
|
||||
name={reponame}
|
||||
tag={tag}
|
||||
digest={selectedManifest?.digest}
|
||||
platform={selectedManifest.platform}
|
||||
/>
|
||||
);
|
||||
case 'ReferredBy':
|
||||
return <ReferredBy referrers={imageDetailData.referrers} />;
|
||||
default:
|
||||
@ -227,10 +233,10 @@ function TagDetails() {
|
||||
<Card className={classes.cardRoot}>
|
||||
<CardContent className={classes.cardContent}>
|
||||
<Grid container>
|
||||
<Grid item xs={12} md={8} className={classes.header}>
|
||||
<Grid item xs={12} md={9} className={classes.header}>
|
||||
<Stack
|
||||
alignItems="center"
|
||||
sx={{ width: { xs: '100%', md: 'auto' } }}
|
||||
sx={{ width: { xs: '100%', md: 'auto' }, marginBottom: '1rem' }}
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={1}
|
||||
>
|
||||
@ -254,32 +260,34 @@ function TagDetails() {
|
||||
vulnerabilitySeverity={imageDetailData.vulnerabiltySeverity}
|
||||
count={imageDetailData.vulnerabilityCount}
|
||||
/>
|
||||
<SignatureIconCheck isSigned={imageDetailData.isSigned} />
|
||||
</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>
|
||||
<SignatureIconCheck
|
||||
isSigned={imageDetailData.isSigned}
|
||||
signatureInfo={imageDetailData.signatureInfo}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Typography gutterBottom className={classes.digest}>
|
||||
Digest: {selectedManifest?.digest}
|
||||
</Typography>
|
||||
<Stack direction="row" alignItems="center" spacing="1rem">
|
||||
<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>
|
||||
<Typography gutterBottom className={classes.digest}>
|
||||
Digest: {selectedManifest?.digest}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
|
@ -60,7 +60,6 @@ body {
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
|
||||
/* background-image: url(./assets/background.png); */
|
||||
background-color: #f6f7f9 !important;
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,11 @@ const useStyles = makeStyles(() => ({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#F6F7F9'
|
||||
},
|
||||
signinContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
loadingHidden: {
|
||||
display: 'none'
|
||||
}
|
||||
@ -27,10 +32,16 @@ function LoginPage({ isLoggedIn, setIsLoggedIn }) {
|
||||
return (
|
||||
<Grid container spacing={0} className={classes.container} data-testid="login-container">
|
||||
{isLoading && <Loading />}
|
||||
<Grid item xs={6} className={isLoading ? classes.loadingHidden : ''}>
|
||||
<Grid item xs={1} md={6} className={`${isLoading ? classes.loadingHidden : ''} hide-on-small`}>
|
||||
<SigninPresentation />
|
||||
</Grid>
|
||||
<Grid item xs={6} className={isLoading ? classes.loadingHidden : ''}>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={12}
|
||||
md={6}
|
||||
className={`${classes.signinContainer} ${isLoading ? classes.loadingHidden : ''}`}
|
||||
>
|
||||
<SignIn isLoggedIn={isLoggedIn} setIsLoggedIn={setIsLoggedIn} wrapperSetLoading={setIsLoading} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
50
src/utilities/authUtilities.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { isNil } from 'lodash';
|
||||
import { host } from '../host';
|
||||
import { api, endpoints } from '../api';
|
||||
|
||||
const getCookie = (name) => document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`))?.at(2);
|
||||
|
||||
const deleteCookie = (name, path, domain) => {
|
||||
if (getCookie(name)) {
|
||||
document.cookie =
|
||||
name +
|
||||
'=' +
|
||||
(path ? ';path=' + path : '') +
|
||||
(domain ? ';domain=' + domain : '') +
|
||||
';expires=Thu, 01 Jan 1970 00:00:01 GMT';
|
||||
}
|
||||
};
|
||||
|
||||
const logoutUser = () => {
|
||||
localStorage.clear();
|
||||
api
|
||||
.post(`${host()}${endpoints.logout}`)
|
||||
.then(() => {
|
||||
deleteCookie('user');
|
||||
window.location.replace('/login');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
const isAuthenticated = () => {
|
||||
const loggedIn = getCookie('user');
|
||||
if (loggedIn) return true;
|
||||
const authState = JSON.parse(localStorage.getItem('authConfig'));
|
||||
if (isNil(authState)) return false;
|
||||
if (Object.keys(authState).length === 0) return true;
|
||||
};
|
||||
|
||||
const isAuthenticationEnabled = () => {
|
||||
const authMethods = JSON.parse(localStorage.getItem('authConfig')) || {};
|
||||
return Object.keys(authMethods).length > 0;
|
||||
};
|
||||
|
||||
const getLoggedInUser = () => {
|
||||
const userCookie = getCookie('user');
|
||||
if (!userCookie) return null;
|
||||
return userCookie;
|
||||
};
|
||||
|
||||
export { isAuthenticated, isAuthenticationEnabled, getLoggedInUser, logoutUser };
|
@ -6,6 +6,10 @@ const osFilters = [
|
||||
{
|
||||
label: 'linux',
|
||||
value: 'linux'
|
||||
},
|
||||
{
|
||||
label: 'freebsd',
|
||||
value: 'freebsd'
|
||||
}
|
||||
];
|
||||
|
||||
@ -14,6 +18,16 @@ const imageFilters = [
|
||||
label: 'Signed Images',
|
||||
value: 'HasToBeSigned',
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
label: 'Bookmarks',
|
||||
value: 'IsBookmarked',
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
label: 'Starred Repositories',
|
||||
value: 'IsStarred',
|
||||
type: 'boolean'
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -5,6 +5,9 @@ const mapToRepo = (responseRepo) => {
|
||||
tags: responseRepo.NewestImage?.Labels,
|
||||
description: responseRepo.NewestImage?.Description,
|
||||
isSigned: responseRepo.NewestImage?.IsSigned,
|
||||
signatureInfo: responseRepo.NewestImage?.SignatureInfo?.map((sigInfo) => mapSignatureInfo(sigInfo)),
|
||||
isBookmarked: responseRepo.IsBookmarked,
|
||||
isStarred: responseRepo.IsStarred,
|
||||
platforms: responseRepo.Platforms,
|
||||
licenses: responseRepo.NewestImage?.Licenses,
|
||||
size: responseRepo.Size,
|
||||
@ -12,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
|
||||
};
|
||||
@ -30,11 +34,15 @@ 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,
|
||||
vulnerabiltySeverity: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.MaxSeverity,
|
||||
vulnerabilitySeverity: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.MaxSeverity,
|
||||
vulnerabilityCount: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.Count,
|
||||
isSigned: responseRepoInfo.Summary?.NewestImage?.IsSigned,
|
||||
signatureInfo: responseRepoInfo.Summary?.NewestImage?.SignatureInfo?.map((sigInfo) => mapSignatureInfo(sigInfo)),
|
||||
isBookmarked: responseRepoInfo.Summary?.IsBookmarked,
|
||||
isStarred: responseRepoInfo.Summary?.IsStarred,
|
||||
logo: responseRepoInfo.Summary?.NewestImage?.Logo
|
||||
};
|
||||
};
|
||||
@ -47,9 +55,11 @@ const mapToImage = (responseImage) => {
|
||||
referrers: responseImage.Referrers,
|
||||
size: responseImage.Size,
|
||||
downloadCount: responseImage.DownloadCount,
|
||||
starCount: responseImage.StarCount,
|
||||
lastUpdated: responseImage.LastUpdated,
|
||||
description: responseImage.Description,
|
||||
isSigned: responseImage.IsSigned,
|
||||
signatureInfo: responseImage.SignatureInfo?.map((sigInfo) => mapSignatureInfo(sigInfo)),
|
||||
license: responseImage.Licenses,
|
||||
labels: responseImage.Labels,
|
||||
title: responseImage.Title,
|
||||
@ -59,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}`
|
||||
};
|
||||
@ -72,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
|
||||
@ -90,6 +102,38 @@ 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
|
||||
? {
|
||||
tool: signatureInfo.Tool,
|
||||
isTrusted: signatureInfo.IsTrusted?.toString(),
|
||||
author: signatureInfo.Author
|
||||
}
|
||||
: {
|
||||
tool: 'Unknown',
|
||||
isTrusted: 'Unknown',
|
||||
author: 'Unknown'
|
||||
};
|
||||
};
|
||||
|
||||
const mapReferrer = (referrer) => ({
|
||||
mediaType: referrer.MediaType,
|
||||
artifactType: referrer.ArtifactType,
|
||||
@ -98,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 };
|
||||
|
@ -3,6 +3,8 @@ const EXPLORE_PAGE_SIZE = 10;
|
||||
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 {
|
||||
@ -11,5 +13,7 @@ export {
|
||||
HOME_PAGE_SIZE,
|
||||
CVE_FIXEDIN_PAGE_SIZE,
|
||||
HOME_POPULAR_PAGE_SIZE,
|
||||
HOME_RECENT_PAGE_SIZE
|
||||
HOME_RECENT_PAGE_SIZE,
|
||||
HOME_BOOKMARKS_PAGE_SIZE,
|
||||
HOME_STARS_PAGE_SIZE
|
||||
};
|
||||
|
@ -84,11 +84,11 @@ const VulnerabilityChipCheck = ({ vulnerabilitySeverity }) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const SignatureIconCheck = ({ isSigned }) => {
|
||||
const SignatureIconCheck = ({ isSigned, signatureInfo }) => {
|
||||
if (isSigned) {
|
||||
return <VerifiedSignatureIcon />;
|
||||
return <VerifiedSignatureIcon signatureInfo={signatureInfo} />;
|
||||
} else {
|
||||
return <UnverifiedSignatureIcon />;
|
||||
return <UnverifiedSignatureIcon signatureInfo={signatureInfo} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -3,6 +3,7 @@ import { Chip, Tooltip } from '@mui/material';
|
||||
import SvgIcon from '@mui/material/SvgIcon';
|
||||
import { ReactComponent as failedScanBug } from '../assets/failedScan.svg';
|
||||
import { createSvgIcon } from '@mui/material/utils';
|
||||
import SignatureTooltip from 'components/Shared/SignatureTooltip';
|
||||
|
||||
const FilledBugIcon = createSvgIcon(
|
||||
<path d="M17.0293 5.13093V6.1543H18.3828L21.2414 3.24068L22.2621 4.27812L19.5552 7.03876L19.5879 7.12668C20.1841 8.73695 20.4862 10.4449 20.4793 12.1662C20.4793 12.5064 20.4678 12.8466 20.4448 13.186L20.4397 13.2634H24V14.7334H20.2569L20.2466 14.7932C19.9431 16.4882 19.3517 18.0338 18.5466 19.335L18.4862 19.4335L21.9276 22.9608L20.9052 24L17.6121 20.6239L17.5138 20.7365C16.0259 22.4333 14.0983 23.4514 11.9983 23.4514C9.86724 23.4514 7.91207 22.4016 6.41552 20.6573L6.31552 20.5413L3.08966 23.833L2.06897 22.792L5.45345 19.3403L5.39483 19.2436C4.61897 17.9618 4.04655 16.4478 3.75 14.7932L3.73966 14.7334H0V13.2634H3.55862L3.55345 13.1843C3.53103 12.8502 3.51897 12.509 3.51897 12.1644C3.51202 10.4654 3.80581 8.77905 4.38621 7.18646L4.41897 7.1003L1.64138 4.2535L2.66379 3.21606L5.53103 6.1543H6.96724V5.13093C6.96724 3.77012 7.49729 2.46505 8.4408 1.50281C9.3843 0.540578 10.664 0 11.9983 0C13.3326 0 14.6123 0.540578 15.5558 1.50281C16.4993 2.46505 17.0293 3.77012 17.0293 5.13093Z" />,
|
||||
@ -23,7 +24,7 @@ const VerifiedShieldIcon = createSvgIcon(
|
||||
|
||||
const NoneVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
return (
|
||||
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
|
||||
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
|
||||
<OutlinedBugIcon
|
||||
sx={{
|
||||
color: '#43A047!important',
|
||||
@ -40,7 +41,7 @@ const NoneVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
};
|
||||
const UnknownVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
return (
|
||||
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
|
||||
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
|
||||
<OutlinedBugIcon
|
||||
sx={{
|
||||
color: '#52637A',
|
||||
@ -75,7 +76,7 @@ const FailedScanIcon = () => {
|
||||
};
|
||||
const LowVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
return (
|
||||
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
|
||||
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
|
||||
<OutlinedBugIcon
|
||||
sx={{
|
||||
color: '#FB8C00',
|
||||
@ -92,7 +93,7 @@ const LowVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
};
|
||||
const MediumVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
return (
|
||||
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
|
||||
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
|
||||
<FilledBugIcon
|
||||
sx={{
|
||||
color: '#FB8C00',
|
||||
@ -109,7 +110,7 @@ const MediumVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
};
|
||||
const HighVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
return (
|
||||
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
|
||||
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
|
||||
<OutlinedBugIcon
|
||||
sx={{
|
||||
color: '#E53935',
|
||||
@ -126,7 +127,7 @@ const HighVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
};
|
||||
const CriticalVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
return (
|
||||
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
|
||||
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
|
||||
<FilledBugIcon
|
||||
sx={{
|
||||
color: '#E53935',
|
||||
@ -144,13 +145,10 @@ const CriticalVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
const NoneVulnerabilityChip = () => {
|
||||
return (
|
||||
<Chip
|
||||
label="No Vulnerability"
|
||||
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"
|
||||
/>
|
||||
);
|
||||
@ -158,13 +156,10 @@ const NoneVulnerabilityChip = () => {
|
||||
const UnknownVulnerabilityChip = () => {
|
||||
return (
|
||||
<Chip
|
||||
label="Unknown Vulnerability"
|
||||
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"
|
||||
/>
|
||||
);
|
||||
@ -175,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"
|
||||
/>
|
||||
);
|
||||
@ -186,13 +178,10 @@ const FailedScanChip = () => {
|
||||
const LowVulnerabilityChip = () => {
|
||||
return (
|
||||
<Chip
|
||||
label="Low Vulnerability"
|
||||
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"
|
||||
/>
|
||||
);
|
||||
@ -200,13 +189,10 @@ const LowVulnerabilityChip = () => {
|
||||
const MediumVulnerabilityChip = () => {
|
||||
return (
|
||||
<Chip
|
||||
label="Medium Vulnerability"
|
||||
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"
|
||||
/>
|
||||
);
|
||||
@ -214,13 +200,10 @@ const MediumVulnerabilityChip = () => {
|
||||
const HighVulnerabilityChip = () => {
|
||||
return (
|
||||
<Chip
|
||||
label="High Vulnerability"
|
||||
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"
|
||||
/>
|
||||
);
|
||||
@ -228,21 +211,18 @@ const HighVulnerabilityChip = () => {
|
||||
const CriticalVulnerabilityChip = () => {
|
||||
return (
|
||||
<Chip
|
||||
label="Critical Vulnerability"
|
||||
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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const UnverifiedSignatureIcon = () => {
|
||||
const UnverifiedSignatureIcon = ({ signatureInfo }) => {
|
||||
return (
|
||||
<Tooltip title="Unverified Signature" placement="top">
|
||||
<Tooltip title={<SignatureTooltip isSigned={false} signatureInfo={signatureInfo} />} placement="top">
|
||||
<UnverifiedShieldIcon
|
||||
sx={{
|
||||
color: '#E53935',
|
||||
@ -257,9 +237,9 @@ const UnverifiedSignatureIcon = () => {
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
const VerifiedSignatureIcon = () => {
|
||||
const VerifiedSignatureIcon = ({ signatureInfo }) => {
|
||||
return (
|
||||
<Tooltip title="Verified Signature" placement="top">
|
||||
<Tooltip title={<SignatureTooltip isSigned={true} signatureInfo={signatureInfo} />} placement="top">
|
||||
<VerifiedShieldIcon
|
||||
viewBox="0 0 24 24"
|
||||
sx={{
|
||||
|
@ -1,4 +1,14 @@
|
||||
images:
|
||||
- name: ubuntu
|
||||
tags:
|
||||
- "18.04"
|
||||
- "bionic-20230301"
|
||||
- "bionic"
|
||||
- "22.04"
|
||||
- "jammy-20230301"
|
||||
- "jammy"
|
||||
- "latest"
|
||||
multiarch: ""
|
||||
- name: alpine
|
||||
tags:
|
||||
- "3.17"
|
||||
@ -20,16 +30,6 @@ images:
|
||||
- "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"
|
||||
|
89
tests/explore.spec.js
Normal file
@ -0,0 +1,89 @@
|
||||
// @ts-check
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { scroll } from './utils/scroll';
|
||||
import { getRepoCardNameForLocator, getRepoListOrderedAlpha } from './utils/test-data-parser';
|
||||
import { hosts, endpoints, sortCriteria } from './values/test-constants';
|
||||
|
||||
test.describe('explore page test', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('authConfig', '{}');
|
||||
});
|
||||
});
|
||||
|
||||
test('explore data', async ({ page }) => {
|
||||
const expectedRequest = `${hosts.api}${endpoints.globalSearch('', sortCriteria.relevance, 1)}`;
|
||||
const exploreDataRequest = page.waitForRequest(
|
||||
(request) => request.url() === expectedRequest && request.method() === 'GET'
|
||||
);
|
||||
await page.goto(`${hosts.ui}/explore?search=`);
|
||||
const expectDataResponse = await exploreDataRequest;
|
||||
expect(expectDataResponse).toBeTruthy();
|
||||
|
||||
// if no search query provided and no filters selected, data should be alphabetical when sorted by relevance
|
||||
const alphaOrderedData = getRepoListOrderedAlpha();
|
||||
|
||||
const exploreFirst = page.getByRole('button', {
|
||||
name: getRepoCardNameForLocator(alphaOrderedData[0])
|
||||
});
|
||||
|
||||
const exploreSecond = page.getByRole('button', {
|
||||
name: getRepoCardNameForLocator(alphaOrderedData[1])
|
||||
});
|
||||
|
||||
await expect(exploreFirst).toBeVisible({ timeout: 250000 });
|
||||
await expect(exploreSecond).toBeVisible({ timeout: 250000 });
|
||||
|
||||
const exploreNextPageRequest = page.waitForRequest(
|
||||
(request) =>
|
||||
request.url() === `${hosts.api}${endpoints.globalSearch('', sortCriteria.relevance, 2)}` &&
|
||||
request.method() === 'GET'
|
||||
);
|
||||
await page.evaluate(scroll, { direction: 'down', speed: 'fast' });
|
||||
const exploreNextPageResponse = await exploreNextPageRequest;
|
||||
expect(exploreNextPageResponse).toBeTruthy();
|
||||
|
||||
const postScrollExploreElementOne = page.getByRole('button', {
|
||||
name: getRepoCardNameForLocator(alphaOrderedData[alphaOrderedData.length - 1])
|
||||
});
|
||||
const postScrollExploreElementTwo = page.getByRole('button', {
|
||||
name: getRepoCardNameForLocator(alphaOrderedData[alphaOrderedData.length - 2])
|
||||
});
|
||||
|
||||
await expect(postScrollExploreElementOne).toBeVisible({ timeout: 250000 });
|
||||
await expect(postScrollExploreElementTwo).toBeVisible({ timeout: 250000 });
|
||||
});
|
||||
|
||||
test('explore filtering', async ({ page }) => {
|
||||
const alphaOrderedData = getRepoListOrderedAlpha();
|
||||
|
||||
await page.goto(`${hosts.ui}/explore?search=`);
|
||||
const exploreFirst = page.getByRole('button', {
|
||||
name: getRepoCardNameForLocator(alphaOrderedData[0])
|
||||
});
|
||||
|
||||
const exploreSecond = page.getByRole('button', {
|
||||
name: getRepoCardNameForLocator(alphaOrderedData[1])
|
||||
});
|
||||
|
||||
await expect(exploreFirst).toBeVisible({ timeout: 250000 });
|
||||
await expect(exploreSecond).toBeVisible({ timeout: 250000 });
|
||||
|
||||
const linuxFilter = page.getByRole('checkbox', { name: 'linux' });
|
||||
await linuxFilter.check();
|
||||
|
||||
await expect(linuxFilter).toBeChecked();
|
||||
|
||||
await expect(exploreFirst).toBeVisible({ timeout: 250000 });
|
||||
|
||||
const windowsFilter = page.getByRole('checkbox', { name: 'windows' });
|
||||
await linuxFilter.uncheck();
|
||||
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 });
|
||||
});
|
||||
});
|
25
tests/home.spec.js
Normal file
@ -0,0 +1,25 @@
|
||||
// @ts-check
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { hosts, endpoints, sortCriteria } from './values/test-constants';
|
||||
|
||||
test.describe('homepage test', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('authConfig', '{}');
|
||||
});
|
||||
});
|
||||
|
||||
test('homepage viewall navigation', async ({ page }) => {
|
||||
await page.goto(`${hosts.ui}/home`);
|
||||
const popularRequest = page.waitForRequest(
|
||||
(request) =>
|
||||
request.url() === `${hosts.api}${endpoints.globalSearch('', sortCriteria.downloads)}` &&
|
||||
request.method() === 'GET'
|
||||
);
|
||||
const viewAllButton = page.getByText('View all').first();
|
||||
await viewAllButton.click();
|
||||
const popularResponse = await popularRequest;
|
||||
expect(popularResponse).toBeTruthy();
|
||||
await expect(page).toHaveURL(`${hosts.ui}/explore?sortby=${sortCriteria.downloads}`);
|
||||
});
|
||||
});
|
38
tests/navbar.spec.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { hosts, endpoints, sortCriteria } from './values/test-constants';
|
||||
import { getRepoListOrderedAlpha } from './utils/test-data-parser';
|
||||
|
||||
test.describe('navbar test', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('authConfig', '{}');
|
||||
});
|
||||
});
|
||||
|
||||
test('nav search', async ({ page }) => {
|
||||
const alphaOrderedData = getRepoListOrderedAlpha();
|
||||
await page.goto(`${hosts.ui}/home`);
|
||||
// search results
|
||||
const searchRequest = page.waitForRequest(
|
||||
(request) =>
|
||||
request.url() ===
|
||||
`${hosts.api}${endpoints.globalSearch(
|
||||
alphaOrderedData[0].repo.substring(0, 3),
|
||||
sortCriteria.relevance,
|
||||
1,
|
||||
9
|
||||
)}` && request.method() === 'GET'
|
||||
);
|
||||
await page.getByPlaceholder('Search for content...').click();
|
||||
await page.getByPlaceholder('Search for content...').fill(alphaOrderedData[0].repo.substring(0, 3));
|
||||
const searchResponse = await searchRequest;
|
||||
expect(searchResponse).toBeTruthy();
|
||||
const searchSuggestion = await page.getByRole('option', { name: alphaOrderedData[0].repo });
|
||||
await expect(searchSuggestion).toBeVisible({ timeout: 100000 });
|
||||
|
||||
// clicking a search result
|
||||
|
||||
await searchSuggestion.click();
|
||||
await expect(page).toHaveURL(/.*\/image.*/);
|
||||
});
|
||||
});
|
48
tests/repo.spec.js
Normal file
@ -0,0 +1,48 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { hosts, endpoints } from './values/test-constants';
|
||||
import { getMultiTagRepo } from './utils/test-data-parser';
|
||||
import { head } from 'lodash';
|
||||
|
||||
const testRepo = getMultiTagRepo();
|
||||
|
||||
test.describe('Repository page test', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('authConfig', '{}');
|
||||
});
|
||||
|
||||
await page.goto(`${hosts.ui}/image/${testRepo.repo}`);
|
||||
});
|
||||
|
||||
test('Repository page data', async ({ page }) => {
|
||||
// check metadata
|
||||
const firstTag = head(testRepo.tags);
|
||||
await expect(page.getByText(firstTag.description).first()).toBeVisible({ timeout: 100000 });
|
||||
await expect(page.getByText(firstTag.source).first()).toBeVisible({ timeout: 100000 });
|
||||
|
||||
// check tags and tags search
|
||||
for (let tag of testRepo.tags) {
|
||||
await expect(page.getByText(tag.tag, { exact: true })).toBeVisible({ timeout: 100000 });
|
||||
}
|
||||
await page.getByText('Show more').first().click();
|
||||
await expect(page.getByText('linux/amd64')).toBeVisible({ timeout: 100000 });
|
||||
await page.getByPlaceholder('Search tags...').click();
|
||||
await page.getByPlaceholder('Search tags...').fill(testRepo.tags[0].tag);
|
||||
await expect(page.getByText(testRepo.tags[0].tag, { exact: true })).toBeVisible({ timeout: 100000 });
|
||||
await expect(page.getByText(testRepo.tags[1].tag, { exact: true })).not.toBeVisible({ timeout: 100000 });
|
||||
});
|
||||
|
||||
test('Repository page navigation', async ({ page }) => {
|
||||
await expect(page.getByText(testRepo.tags[0].tag, { exact: true })).toBeVisible({ timeout: 100000 });
|
||||
const tagPageRequest = page.waitForRequest(
|
||||
(request) =>
|
||||
request.url() === `${hosts.api}${endpoints.image(`${testRepo.repo}:${testRepo.tags[0].tag}`)}` &&
|
||||
request.method() === 'GET'
|
||||
);
|
||||
await page.getByText(testRepo.tags[0].tag, { exact: true }).click();
|
||||
await expect(tagPageRequest).toBeDefined();
|
||||
const tagPageResponse = await tagPageRequest;
|
||||
expect(tagPageResponse).toBeTruthy();
|
||||
await expect(page).toHaveURL(/.*\/image\/.+\/tag\/.*/);
|
||||
});
|
||||
});
|
@ -185,7 +185,7 @@ 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}
|
||||
skopeo --insecure-policy --override-os="linux" --override-arch="amd64" copy --override-os="linux" --override-arch="amd64" --format=oci ${multiarch_arg} ${remote_src_image_ref} ${local_image_ref_skopeo}
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@ -206,7 +206,7 @@ if [ ! -z "${username}" ]; then
|
||||
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}
|
||||
skopeo --override-os="linux" --override-arch="amd64" 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
|
||||
|
43
tests/tag.spec.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { getTagWithDependencies, getTagWithDependents, getTagWithVulnerabilities } from './utils/test-data-parser';
|
||||
import { hosts, pageSizes } from './values/test-constants';
|
||||
|
||||
test.describe('Tag page test', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('authConfig', '{}');
|
||||
});
|
||||
});
|
||||
|
||||
test('Tag page with dependents', async ({ page }) => {
|
||||
const tagWithDependents = getTagWithDependents();
|
||||
await page.goto(`${hosts.ui}/image/${tagWithDependents.title}/tag/${tagWithDependents.tag}`);
|
||||
await expect(page.getByRole('tab', { name: 'Layers' })).toBeVisible({ timeout: 100000 });
|
||||
await page.getByRole('tab', { name: 'Layers' }).click();
|
||||
await expect(page.getByTestId('layer-card-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
|
||||
await page.getByRole('tab', { name: 'Used by' }).click();
|
||||
await expect(page.getByTestId('dependents-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
|
||||
await expect(page.getByText('Tag').nth(1)).toBeVisible({ timeout: 100000 });
|
||||
await expect(await page.getByText('Tag').count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Tag page with dependencies', async ({ page }) => {
|
||||
const tagWithDependencies = getTagWithDependencies();
|
||||
await page.goto(`${hosts.ui}/image/${tagWithDependencies.title}/tag/${tagWithDependencies.tag}`);
|
||||
await expect(page.getByRole('tab', { name: 'Layers' })).toBeVisible({ timeout: 100000 });
|
||||
await page.getByRole('tab', { name: 'Layers' }).click();
|
||||
await expect(page.getByTestId('layer-card-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
|
||||
await page.getByRole('tab', { name: 'Uses' }).click();
|
||||
await expect(page.getByTestId('depends-on-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
|
||||
await expect(page.getByText('Tag')).toHaveCount(1, { timeout: 100000 });
|
||||
});
|
||||
|
||||
test('Tag page with vulnerabilities', async ({ page }) => {
|
||||
const tagWithVulnerabilities = getTagWithVulnerabilities();
|
||||
await page.goto(`${hosts.ui}/image/${tagWithVulnerabilities.title}/tag/${tagWithVulnerabilities.tag}`);
|
||||
await page.getByRole('tab', { name: 'Vulnerabilities' }).click();
|
||||
await expect(page.getByTestId('vulnerability-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
|
||||
await expect(await page.getByText('CVE-').count()).toBeGreaterThan(0);
|
||||
await expect(await page.getByText('CVE-').count()).toBeLessThanOrEqual(pageSizes.EXPLORE);
|
||||
});
|
||||
});
|
14
tests/utils/scroll.js
Normal file
@ -0,0 +1,14 @@
|
||||
export const scroll = async (args) => {
|
||||
const { direction, speed } = args;
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const scrollHeight = () => document.body.scrollHeight;
|
||||
const start = direction === 'down' ? 0 : scrollHeight();
|
||||
const shouldStop = (position) => (direction === 'down' ? position > scrollHeight() : position < 0);
|
||||
const increment = direction === 'down' ? 100 : -100;
|
||||
const delayTime = speed === 'slow' ? 50 : 10;
|
||||
console.error(start, shouldStop(start), increment);
|
||||
for (let i = start; !shouldStop(i); i += increment) {
|
||||
window.scrollTo(0, i);
|
||||
await delay(delayTime);
|
||||
}
|
||||
};
|
163
tests/utils/test-data-parser.js
Normal file
@ -0,0 +1,163 @@
|
||||
// read raw test data and get expected result for different queries
|
||||
import { isNil } from 'lodash';
|
||||
import * as rawData from '../data/image_metadata.json';
|
||||
|
||||
const rawDataToRepo = ([rawDataRepoKey, rawDataRepoValue]) => {
|
||||
if (rawDataRepoKey === 'default') return;
|
||||
return {
|
||||
repo: rawDataRepoKey,
|
||||
tags: Object.entries(rawDataRepoValue).map(([key, value]) => ({
|
||||
tag: key,
|
||||
title: value['org.opencontainers.image.title'],
|
||||
description: value['org.opencontainers.image.description'],
|
||||
url: value['org.opencontainers.image.url'],
|
||||
source: value['org.opencontainers.image.source'],
|
||||
license: value['org.opencontainers.image.licenses'],
|
||||
vendor: value['org.opencontainers.image.vendor'],
|
||||
documentation: value['org.opencontainers.image.documentation'],
|
||||
manifests: value['manifests'],
|
||||
cves: value['cves']
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
const getManifestDependents = (manifestValue, repoName) => {
|
||||
const dependents = [];
|
||||
Object.entries(rawData)
|
||||
.map((repo) => rawDataToRepo(repo))
|
||||
.forEach((repo) => {
|
||||
// if different repo
|
||||
repo?.tags.forEach((tag) => {
|
||||
if (tag.title !== repoName) {
|
||||
Object.values(tag?.manifests).forEach((value) => {
|
||||
if (value.layers?.length > manifestValue.layers?.length) {
|
||||
if (manifestValue.layers?.every((i) => value.layers?.includes(i))) {
|
||||
dependents.push(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return dependents;
|
||||
};
|
||||
|
||||
const getManifestDependencies = (manifestValue, repoName) => {
|
||||
const dependencies = [];
|
||||
Object.entries(rawData)
|
||||
.map((repo) => rawDataToRepo(repo))
|
||||
.forEach((repo) => {
|
||||
repo?.tags.forEach((tag) => {
|
||||
// if different repo
|
||||
if (tag.title !== repoName) {
|
||||
Object.values(tag?.manifests).forEach((value) => {
|
||||
if (value.layers?.length < manifestValue.layers?.length) {
|
||||
if (value.layers?.every((i) => manifestValue.layers?.includes(i))) {
|
||||
dependencies.push(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return dependencies;
|
||||
};
|
||||
|
||||
const getMultiTagRepo = () => {
|
||||
const multiTagImage = Object.entries(rawData)
|
||||
.find(([, value]) => Object.keys(value).length > 1)
|
||||
.filter((e) => !isNil(e));
|
||||
return rawDataToRepo(multiTagImage);
|
||||
};
|
||||
|
||||
const getTagWithDependents = (minSize = 0) => {
|
||||
const parsedRepoList = Object.entries(rawData)
|
||||
.map((repo) => rawDataToRepo(repo))
|
||||
.filter((e) => !isNil(e));
|
||||
let tagsArray = [];
|
||||
parsedRepoList.forEach((el) => (tagsArray = tagsArray.concat(el?.tags)));
|
||||
for (let tag of tagsArray) {
|
||||
if (!isNil(tag)) {
|
||||
const tagManifests = Object.values(tag?.manifests);
|
||||
const manifestWithDependent = tagManifests.findIndex(
|
||||
(manifest) => getManifestDependents(manifest, tag.title).length > minSize
|
||||
);
|
||||
if (manifestWithDependent !== -1) return tag;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTagWithDependencies = (minSize = 0) => {
|
||||
const parsedRepoList = Object.entries(rawData)
|
||||
.map((repo) => rawDataToRepo(repo))
|
||||
.filter((e) => !isNil(e));
|
||||
let tagsArray = [];
|
||||
parsedRepoList.forEach((el) => (tagsArray = tagsArray.concat(el?.tags)));
|
||||
for (let tag of tagsArray) {
|
||||
if (!isNil(tag)) {
|
||||
const tagManifests = Object.values(tag?.manifests);
|
||||
const manifestWithDependencies = tagManifests.findIndex(
|
||||
(manifest) => getManifestDependencies(manifest, tag.title).length > minSize
|
||||
);
|
||||
if (manifestWithDependencies !== -1) return tag;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTagWithVulnerabilities = () => {
|
||||
const parsedRepoList = Object.entries(rawData)
|
||||
.map((repo) => rawDataToRepo(repo))
|
||||
.filter((e) => !isNil(e));
|
||||
let tagsArray = [];
|
||||
parsedRepoList.forEach((el) => (tagsArray = tagsArray.concat(el?.tags)));
|
||||
const tagWithCves = tagsArray.find((tag) => Object.keys(tag.cves).length > 0);
|
||||
return tagWithCves;
|
||||
};
|
||||
|
||||
const getTagWithMultiarch = () => {
|
||||
const parsedRepoList = Object.entries(rawData)
|
||||
.map((repo) => rawDataToRepo(repo))
|
||||
.filter((e) => !isNil(e));
|
||||
let tagsArray = [];
|
||||
const tagsList = parsedRepoList.forEach((el) => tagsArray.concat(el?.tags));
|
||||
const tagWithMultiarch = tagsList.find((tag) => tag.multiarch === 'all');
|
||||
return tagWithMultiarch;
|
||||
};
|
||||
|
||||
const getRepoListOrderedAlpha = () => {
|
||||
const parsedRepoList = Object.entries(rawData)
|
||||
.map((repo) => rawDataToRepo(repo))
|
||||
.filter((e) => !isNil(e));
|
||||
parsedRepoList.sort((a, b) => a?.repo.localeCompare(b?.repo));
|
||||
return parsedRepoList;
|
||||
};
|
||||
|
||||
// Currently image metadata does not contain last update time for tags
|
||||
// const getLastUpdatedForRepo = (repo) => {
|
||||
// const debug = DateTime.max(...repo.tags.map((tag) => DateTime.fromISO(tag.lastUpdated)));
|
||||
// return debug;
|
||||
// };
|
||||
|
||||
// const getRepoListOrderedRecent = () => {
|
||||
// const parsedRepoList = Object.entries(rawData)
|
||||
// .map((repo) => rawDataToRepo(repo))
|
||||
// .filter((e) => !isNil(e));
|
||||
// parsedRepoList.sort((a, b) => getLastUpdatedForRepo(b).diff(getLastUpdatedForRepo(a)));
|
||||
// return parsedRepoList;
|
||||
// };
|
||||
|
||||
const getRepoCardNameForLocator = (repo) => {
|
||||
return `${repo?.repo} ${repo?.tags[0]?.description?.slice(0, 10)}`;
|
||||
};
|
||||
|
||||
export {
|
||||
getMultiTagRepo,
|
||||
getRepoListOrderedAlpha,
|
||||
getTagWithDependents,
|
||||
getTagWithDependencies,
|
||||
getTagWithVulnerabilities,
|
||||
getTagWithMultiarch,
|
||||
getRepoCardNameForLocator
|
||||
};
|
31
tests/values/test-constants.js
Normal file
@ -0,0 +1,31 @@
|
||||
const hosts = {
|
||||
ui: process.env.UI_HOST ? `http://${process.env.UI_HOST}` : 'http://localhost:5000',
|
||||
api: process.env.API_HOST ? `http://${process.env.API_HOST}` : 'http://localhost:5000'
|
||||
};
|
||||
|
||||
const sortCriteria = {
|
||||
relevance: 'RELEVANCE',
|
||||
updateTime: 'UPDATE_TIME',
|
||||
alphabetic: 'ALPHABETIC_ASC',
|
||||
alphabeticDesc: 'ALPHABETIC_DSC',
|
||||
downloads: 'DOWNLOADS'
|
||||
};
|
||||
|
||||
const pageSizes = {
|
||||
EXPLORE: 10,
|
||||
HOME: 10
|
||||
};
|
||||
|
||||
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}%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%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}%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}}`
|
||||
};
|
||||
|
||||
export { hosts, endpoints, sortCriteria, pageSizes };
|