Compare commits
49 Commits
commit-808
...
zui
Author | SHA1 | Date | |
---|---|---|---|
3dc49925d0 | |||
303dfb3253 | |||
203b0973a5 | |||
930ae7e0d3 | |||
5918aebbf3 | |||
508fe324b0 | |||
5a9533d95e | |||
182ef55166 | |||
f67c1c8c1e | |||
6cadd7feac | |||
7bd1d7dfc7 | |||
f0bf77a487 | |||
317820926e | |||
c78b303ee8 | |||
9de2337809 | |||
c4d595c782 | |||
09ab4474e9 | |||
177406df41 | |||
e2367c2a33 | |||
33524ce3cc | |||
e037c6c577 | |||
c268991495 | |||
0edfe0f73a | |||
f4a6030d93 | |||
9358539e0c | |||
5bf7d5652c | |||
12f9229320 | |||
df19fa811c | |||
6cda89c710 | |||
12b474e126 | |||
a9db66bd34 | |||
f4600b8b79 | |||
c375c0697a | |||
2e1e2e92b7 | |||
d9370fb9c1 | |||
e97e04eee5 | |||
a288523a3f | |||
fad5572db4 | |||
19e366ee1f | |||
b41fb2f841 | |||
b787273b84 | |||
9ecd46e4d0 | |||
845726cd08 | |||
ac84c375c0 | |||
96008d67be | |||
087b42693f | |||
8f4c23bf40 | |||
54c764c996 | |||
44289c751f |
@ -1,10 +0,0 @@
|
||||
**/.git
|
||||
**/.svn
|
||||
**/.hg
|
||||
**/node_modules
|
||||
**/.github
|
||||
README.md
|
||||
LICENSE
|
||||
Makefile
|
||||
**/coverage
|
||||
**/build
|
@ -1,54 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"jest": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:prettier/recommended"],
|
||||
"overrides": [],
|
||||
"rules": {
|
||||
"react/prop-types": "off"
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["react"],
|
||||
"settings": {
|
||||
"react": {
|
||||
"createClass": "createReactClass", // Regex for Component Factory to use,
|
||||
// default to "createReactClass"
|
||||
"pragma": "React", // Pragma to use, default to "React"
|
||||
"fragment": "Fragment", // Fragment to use (may be a property of <pragma>), default to "Fragment"
|
||||
"version": "detect", // React version. "detect" automatically picks the version you have installed.
|
||||
// You can also use `16.0`, `16.3`, etc, if you want to override the detected value.
|
||||
// It will default to "latest" and warn if missing, and to "detect" in the future
|
||||
"flowVersion": "0.53" // Flow version
|
||||
},
|
||||
"propWrapperFunctions": [
|
||||
// The names of any function used to wrap propTypes, e.g. `forbidExtraProps`. If this isn't set, any propTypes wrapped in a function will be skipped.
|
||||
"forbidExtraProps",
|
||||
{ "property": "freeze", "object": "Object" },
|
||||
{ "property": "myFavoriteWrapper" },
|
||||
// for rules that check exact prop wrappers
|
||||
{ "property": "forbidExtraProps", "exact": true }
|
||||
],
|
||||
"componentWrapperFunctions": [
|
||||
// The name of any function used to wrap components, e.g. Mobx `observer` function. If this isn't set, components wrapped by these functions will be skipped.
|
||||
"observer", // `property`
|
||||
{ "property": "styled" }, // `object` is optional
|
||||
{ "property": "observer", "object": "Mobx" },
|
||||
{ "property": "observer", "object": "<pragma>" } // sets `object` to whatever value `settings.react.pragma` is set to
|
||||
],
|
||||
"formComponents": [
|
||||
// Components used as alternatives to <form> for forms, eg. <Form endpoint={ url } />
|
||||
"CustomForm",
|
||||
{ "name": "Form", "formAttribute": "endpoint" }
|
||||
],
|
||||
"linkComponents": [
|
||||
// Components used as alternatives to <a> for linking, eg. <Link to={ url } />
|
||||
"Hyperlink",
|
||||
{ "name": "Link", "linkAttribute": "to" }
|
||||
]
|
||||
}
|
||||
}
|
2
.github/workflows/build-test.yml
vendored
2
.github/workflows/build-test.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x]
|
||||
node-version: [20.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
|
13
.github/workflows/coverage.yml
vendored
13
.github/workflows/coverage.yml
vendored
@ -9,26 +9,27 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x]
|
||||
node-version: [20.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: make install
|
||||
|
||||
- name: Run the tests
|
||||
run: npm test -- --coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v1
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
verbose: true
|
||||
|
8
.github/workflows/dco.yml
vendored
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
|
||||
|
30
.github/workflows/end-to-end-test.yml
vendored
30
.github/workflows/end-to-end-test.yml
vendored
@ -18,20 +18,28 @@ jobs:
|
||||
name: Test zui/zot integration
|
||||
env:
|
||||
CI: ""
|
||||
REGISTRY_HOST: "localhost"
|
||||
REGISTRY_HOST: "127.0.0.1"
|
||||
REGISTRY_PORT: "8080"
|
||||
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
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
- name: Set up Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.x
|
||||
node-version: 18.x
|
||||
cache: 'npm'
|
||||
|
||||
- name: Build zui
|
||||
@ -73,7 +81,7 @@ jobs:
|
||||
- name: Install go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.20.x
|
||||
go-version: 1.22.x
|
||||
|
||||
- name: Checkout zot repo
|
||||
uses: actions/checkout@v3
|
||||
@ -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
|
||||
@ -116,13 +124,19 @@ jobs:
|
||||
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
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
|
3
Makefile
3
Makefile
@ -6,7 +6,8 @@ all: install audit build
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
npm install --no-audit
|
||||
npm install --no-audit;\
|
||||
npx update-browserslist-db@latest
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
|
@ -1,4 +1,5 @@
|
||||
# zot UI [](https://github.com/project-zot/zui/actions/workflows/build-test.yml) [](http://codecov.io/github/project-zot/zui?branch=main) [](https://github.com/project-zot/zui/actions?query=workflow%3ACodeQL)
|
||||
# zot UI [](https://github.com/project-zot/zui/actions/workflows/build-test.yml) [](http://codecov.io/github/project-zot/zui?branch=main) [](https://github.com/project-zot/zui/actions?query=workflow%3ACodeQL) [](https://app.fossa.com/projects/git%2Bgithub.com%2Fproject-zot%2Fzui?ref=badge_shield)
|
||||
|
||||
A graphical user interface to interact with a [zot](https://github.com/project-zot/zot) server instance.
|
||||
|
||||
Built with [React JS](https://reactjs.org/) and [Material UI](https://mui.com/).
|
||||
@ -59,3 +60,7 @@ This project was bootstrapped with [Create React App](https://github.com/faceboo
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
To learn Material UI, check out the [Material UI Library](https://mui.com/).
|
||||
|
||||
|
||||
## License
|
||||
[](https://app.fossa.com/projects/git%2Bgithub.com%2Fproject-zot%2Fzui?ref=badge_large)
|
108
eslint.config.mjs
Normal file
108
eslint.config.mjs
Normal file
@ -0,0 +1,108 @@
|
||||
import react from 'eslint-plugin-react';
|
||||
import globals from 'globals';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import js from '@eslint/js';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all
|
||||
});
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ['**/*.js', '**/*.jsx'],
|
||||
ignores: [
|
||||
'**/.git',
|
||||
'**/.svn',
|
||||
'**/.hg',
|
||||
'**/node_modules',
|
||||
'**/.github',
|
||||
'**/README.md',
|
||||
'**/LICENSE',
|
||||
'**/Makefile',
|
||||
'**/coverage',
|
||||
'**/build'
|
||||
]
|
||||
},
|
||||
...compat.extends('eslint:recommended', 'plugin:react/recommended', 'plugin:prettier/recommended'),
|
||||
{
|
||||
plugins: {
|
||||
react
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.jest,
|
||||
...globals.node
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
},
|
||||
|
||||
settings: {
|
||||
react: {
|
||||
createClass: 'createReactClass',
|
||||
pragma: 'React',
|
||||
fragment: 'Fragment',
|
||||
version: 'detect',
|
||||
flowVersion: '0.53'
|
||||
},
|
||||
|
||||
propWrapperFunctions: [
|
||||
'forbidExtraProps',
|
||||
{
|
||||
property: 'freeze',
|
||||
object: 'Object'
|
||||
},
|
||||
{
|
||||
property: 'myFavoriteWrapper'
|
||||
},
|
||||
{
|
||||
property: 'forbidExtraProps',
|
||||
exact: true
|
||||
}
|
||||
],
|
||||
|
||||
componentWrapperFunctions: [
|
||||
'observer',
|
||||
{
|
||||
property: 'styled'
|
||||
},
|
||||
{
|
||||
property: 'observer',
|
||||
object: 'Mobx'
|
||||
},
|
||||
{
|
||||
property: 'observer',
|
||||
object: '<pragma>'
|
||||
}
|
||||
],
|
||||
|
||||
formComponents: [
|
||||
'CustomForm',
|
||||
{
|
||||
name: 'Form',
|
||||
formAttribute: 'endpoint'
|
||||
}
|
||||
],
|
||||
|
||||
linkComponents: [
|
||||
'Hyperlink',
|
||||
{
|
||||
name: 'Link',
|
||||
linkAttribute: 'to'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
rules: {
|
||||
'react/prop-types': 'off'
|
||||
}
|
||||
}
|
||||
];
|
15953
package-lock.json
generated
15953
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
71
package.json
71
package.json
@ -3,56 +3,59 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.9.3",
|
||||
"@emotion/styled": "^11.9.3",
|
||||
"@mui/icons-material": "^5.2.5",
|
||||
"@mui/lab": "^5.0.0-alpha.89",
|
||||
"@mui/material": "^5.8.6",
|
||||
"@mui/styles": "^5.8.6",
|
||||
"@testing-library/jest-dom": "^5.16.1",
|
||||
"@testing-library/react": "^12.1.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^0.24.0",
|
||||
"@adobe/css-tools": "^4.4.1",
|
||||
"@emotion/react": "^11.13.5",
|
||||
"@emotion/styled": "^11.13.5",
|
||||
"@mui/icons-material": "^6.1.10",
|
||||
"@mui/lab": "^6.0.0-beta.18",
|
||||
"@mui/material": "^6.1.10",
|
||||
"@mui/styles": "^6.1.10",
|
||||
"@mui/x-date-pickers": "^7.23.1",
|
||||
"axios": "^1.7.8",
|
||||
"downshift": "^6.1.12",
|
||||
"export-from-json": "^1.7.4",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^2.5.2",
|
||||
"markdown-to-jsx": "^7.1.7",
|
||||
"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"
|
||||
"luxon": "^3.5.0",
|
||||
"markdown-to-jsx": "^7.6.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.0.2",
|
||||
"react-sticky-el": "^2.1.1",
|
||||
"web-vitals": "^2.1.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.28.1",
|
||||
"eslint": "^8.23.1",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.31.8",
|
||||
"prettier": "^2.7.1",
|
||||
"react-scripts": "^5.0.1",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.16.7"
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.16.7",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@playwright/test": "^1.46.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"globals": "^14.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"react-scripts": "^5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test --detectOpenHandles",
|
||||
"test:coverage": "react-scripts test --detectOpenHandles --coverage",
|
||||
"test": "react-scripts test --detectOpenHandles --max_old_space_size=4096 --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
|
||||
"test:coverage": "react-scripts test --detectOpenHandles --max_old_space_size=4096 --transformIgnorePatterns 'node_modules/(?!my-library-dir)/' --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": "eslint -c eslint.config.mjs",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"format": "prettier --write ./**/*.{js,jsx,ts,tsx,css,md,json} --config ./.prettierrc",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
|
@ -7,7 +7,6 @@ const { devices } = require('@playwright/test');
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
|
||||
/**
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
* @type {import('@playwright/test').PlaywrightTestConfig}
|
||||
@ -42,7 +41,8 @@ const config = {
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
ignoreHTTPSErrors: true
|
||||
ignoreHTTPSErrors: true,
|
||||
screenshot: 'only-on-failure'
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
@ -52,7 +52,7 @@ const config = {
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
ignoreHTTPSErrors: true
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
@ -60,7 +60,7 @@ const config = {
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
ignoreHTTPSErrors: true
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
@ -68,8 +68,8 @@ const config = {
|
||||
use: {
|
||||
...devices['Desktop Safari'],
|
||||
ignoreHTTPSErrors: true
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
@ -101,7 +101,7 @@ const config = {
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
// outputDir: 'test-results/',
|
||||
outputDir: 'test-results/'
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
|
@ -1,14 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router';
|
||||
|
||||
import { isAuthenticated } from 'utilities/authUtilities';
|
||||
import { isAuthenticated, isApiKeyEnabled } from 'utilities/authUtilities';
|
||||
import { AuthWrapper } from 'utilities/AuthWrapper';
|
||||
|
||||
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 UserManagementPage from 'pages/UserManagementPage';
|
||||
|
||||
import './App.css';
|
||||
|
||||
@ -25,6 +26,7 @@ function App() {
|
||||
<Route path="/explore" element={<ExplorePage />} />
|
||||
<Route path="/image/:name" element={<RepoPage />} />
|
||||
<Route path="/image/:reponame/tag/:tag" element={<TagPage />} />
|
||||
{isApiKeyEnabled() && <Route path="/user/apikey" element={<UserManagementPage />} />}
|
||||
<Route path="*" element={<Navigate to="/home" />} />
|
||||
</Route>
|
||||
<Route element={<AuthWrapper isLoggedIn={!isLoggedIn} redirect="/" />}>
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { api } from 'api';
|
||||
import Explore from 'components/Explore/Explore';
|
||||
import React from 'react';
|
||||
import { createSearchParams, MemoryRouter } from 'react-router-dom';
|
||||
import { createSearchParams, MemoryRouter } from 'react-router';
|
||||
import filterConstants from 'utilities/filterConstants.js';
|
||||
import { sortByCriteria } from 'utilities/sortCriteria.js';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
// router mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useNavigate: () => mockedUsedNavigate
|
||||
}));
|
||||
|
||||
@ -34,10 +34,11 @@ const mockImageList = {
|
||||
Size: '2806985',
|
||||
LastUpdated: '2022-08-09T17:19:53.274069586Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: 'w',
|
||||
IsSigned: false,
|
||||
SignatureInfo: [],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -58,10 +59,22 @@ const mockImageList = {
|
||||
Size: '231383863',
|
||||
LastUpdated: '2022-08-02T01:30:49.193203152Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
SignatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: false,
|
||||
Author: ''
|
||||
},
|
||||
{
|
||||
Tool: 'notation',
|
||||
IsTrusted: false,
|
||||
Author: ''
|
||||
}
|
||||
],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -82,10 +95,22 @@ const mockImageList = {
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
SignatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
},
|
||||
{
|
||||
Tool: 'notation',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
}
|
||||
],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -106,10 +131,22 @@ const mockImageList = {
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
SignatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
},
|
||||
{
|
||||
Tool: 'notation',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
}
|
||||
],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -130,10 +167,22 @@ const mockImageList = {
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
SignatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
},
|
||||
{
|
||||
Tool: 'notation',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
}
|
||||
],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -158,10 +207,22 @@ const mockImageList = {
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
SignatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
},
|
||||
{
|
||||
Tool: 'notation',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
}
|
||||
],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -182,10 +243,22 @@ const mockImageList = {
|
||||
Size: '369311301',
|
||||
LastUpdated: '2022-08-23T00:20:40.144281895Z',
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
SignatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: true,
|
||||
Author: 'author1'
|
||||
},
|
||||
{
|
||||
Tool: 'notation',
|
||||
IsTrusted: true,
|
||||
Author: 'author2'
|
||||
}
|
||||
],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -218,7 +291,7 @@ const filteredMockImageListWindows = () => {
|
||||
};
|
||||
|
||||
const filteredMockImageListSigned = () => {
|
||||
const filteredRepos = mockImageList.GlobalSearch.Repos.filter((r) => r.NewestImage.IsSigned);
|
||||
const filteredRepos = mockImageList.GlobalSearch.Repos.filter((r) => r.NewestImage.SignatureInfo?.length > 0);
|
||||
return {
|
||||
GlobalSearch: {
|
||||
Page: { TotalCount: 6, ItemCount: 6 },
|
||||
@ -266,7 +339,22 @@ describe('Explore component', () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
|
||||
render(<StateExploreWrapper />);
|
||||
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(1);
|
||||
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(6);
|
||||
expect(await screen.findAllByTestId('untrusted-icon')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(10);
|
||||
|
||||
const allUntrustedSignaturesIcons = await screen.findAllByTestId('untrusted-icon');
|
||||
fireEvent.mouseOver(allUntrustedSignaturesIcons[0]);
|
||||
expect(await screen.findByText('Signed-by: Unknown')).toBeInTheDocument();
|
||||
const allTrustedSignaturesIcons = await screen.findAllByTestId('verified-icon');
|
||||
fireEvent.mouseOver(allTrustedSignaturesIcons[8]);
|
||||
expect(await screen.findByText('Tool: cosign')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Signed-by: author1')).toBeInTheDocument();
|
||||
fireEvent.mouseOver(allTrustedSignaturesIcons[9]);
|
||||
expect(await screen.findByText('Tool: notation')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Signed-by: author2')).toBeInTheDocument();
|
||||
const allNoSignedIcons = await screen.findAllByTestId('unverified-icon');
|
||||
fireEvent.mouseOver(allNoSignedIcons[0]);
|
||||
expect(await screen.findByText('Not signed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders vulnerability icons', async () => {
|
||||
@ -338,4 +426,13 @@ describe('Explore component', () => {
|
||||
await userEvent.click(bookmarkButton);
|
||||
expect(await screen.findAllByTestId('bookmarked')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should star a repo if star button is clicked', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
|
||||
render(<StateExploreWrapper />);
|
||||
const starButton = (await screen.findAllByTestId('star-button'))[0];
|
||||
jest.spyOn(api, 'put').mockResolvedValueOnce({ status: 200, data: {} });
|
||||
await userEvent.click(starButton);
|
||||
expect(await screen.findAllByTestId('starred')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ExplorePage from 'pages/ExplorePage';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router';
|
||||
|
||||
jest.mock(
|
||||
'components/Explore/Explore',
|
||||
|
@ -21,7 +21,7 @@ const StateFilterCardWrapper = () => {
|
||||
describe('Filters components', () => {
|
||||
it('renders the filters cards', async () => {
|
||||
render(<StateFilterCardWrapper />);
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(3);
|
||||
|
||||
const checkbox = screen.getAllByRole('checkbox');
|
||||
expect(checkbox[0]).not.toBeChecked();
|
||||
|
40
src/__tests__/Header/UserAccountMenu.test.js
Normal file
40
src/__tests__/Header/UserAccountMenu.test.js
Normal file
@ -0,0 +1,40 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import UserAccountMenu from 'components/Header/UserAccountMenu';
|
||||
import React from 'react';
|
||||
|
||||
const mockIsApiKeyEnabled = jest.fn();
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useNavigate: () => {}
|
||||
}));
|
||||
|
||||
jest.mock('../../utilities/authUtilities', () => ({
|
||||
isApiKeyEnabled: () => {
|
||||
return mockIsApiKeyEnabled();
|
||||
},
|
||||
getLoggedInUser: () => {
|
||||
return 'jest-user';
|
||||
},
|
||||
logoutUser: () => {}
|
||||
}));
|
||||
|
||||
describe('Account Menu', () => {
|
||||
it('displays Api Keys menu item with its divider when the API Keys config is enabled', async () => {
|
||||
mockIsApiKeyEnabled.mockReturnValue(true);
|
||||
render(<UserAccountMenu />);
|
||||
const userIconButton = await screen.getByTestId('user-icon-header-button');
|
||||
fireEvent.click(userIconButton);
|
||||
expect(await screen.queryByTestId('api-keys-menu-item')).toBeInTheDocument();
|
||||
expect(await screen.queryByTestId('api-keys-menu-item-divider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display Api Keys menu item and divider when the API Keys config is disabled', async () => {
|
||||
mockIsApiKeyEnabled.mockReturnValue(false);
|
||||
render(<UserAccountMenu />);
|
||||
const userIconButton = await screen.getByTestId('user-icon-header-button');
|
||||
fireEvent.click(userIconButton);
|
||||
expect(await screen.queryByTestId('api-keys-menu-item')).not.toBeInTheDocument();
|
||||
expect(await screen.queryByTestId('api-keys-menu-item-divider')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -2,14 +2,14 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { api } from 'api';
|
||||
import Home from 'components/Home/Home';
|
||||
import React from 'react';
|
||||
import { createSearchParams } from 'react-router-dom';
|
||||
import { createSearchParams } from 'react-router';
|
||||
import { sortByCriteria } from 'utilities/sortCriteria';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
// useNavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useNavigate: () => mockedUsedNavigate
|
||||
}));
|
||||
|
||||
@ -32,7 +32,7 @@ const mockImageList = {
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: 'w',
|
||||
IsSigned: false,
|
||||
SignatureInfo: [],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -49,7 +49,18 @@ const mockImageList = {
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
SignatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
},
|
||||
{
|
||||
Tool: 'notation',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
}
|
||||
],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -66,7 +77,18 @@ const mockImageList = {
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
SignatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
},
|
||||
{
|
||||
Tool: 'notation',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
}
|
||||
],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -91,7 +113,7 @@ const mockImageListRecent = {
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: 'w',
|
||||
IsSigned: false,
|
||||
SignatureInfo: [],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -108,7 +130,18 @@ const mockImageListRecent = {
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
SignatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
},
|
||||
{
|
||||
Tool: 'notation',
|
||||
IsTrusted: true,
|
||||
Author: ''
|
||||
}
|
||||
],
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
@ -164,6 +197,48 @@ const mockImageListBookmarks = {
|
||||
}
|
||||
};
|
||||
|
||||
const mockImageListStars = {
|
||||
GlobalSearch: {
|
||||
Page: { TotalCount: 3, ItemCount: 2 },
|
||||
Repos: [
|
||||
{
|
||||
Name: 'alpine',
|
||||
Size: '2806985',
|
||||
LastUpdated: '2022-08-09T17:19:53.274069586Z',
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: 'w',
|
||||
IsSigned: false,
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
Vulnerabilities: {
|
||||
MaxSeverity: 'LOW',
|
||||
Count: 7
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Name: 'mongo',
|
||||
Size: '231383863',
|
||||
LastUpdated: '2022-08-02T01:30:49.193203152Z',
|
||||
NewestImage: {
|
||||
Tag: 'latest',
|
||||
Description: '',
|
||||
IsSigned: true,
|
||||
Licenses: '',
|
||||
Vendor: '',
|
||||
Labels: '',
|
||||
Vulnerabilities: {
|
||||
MaxSeverity: 'HIGH',
|
||||
Count: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.scrollTo = jest.fn();
|
||||
});
|
||||
@ -178,8 +253,8 @@ describe('Home component', () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
|
||||
render(<HomeWrapper />);
|
||||
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(3));
|
||||
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(3));
|
||||
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(4));
|
||||
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(4));
|
||||
await waitFor(() => expect(screen.getAllByText(/node/i)).toHaveLength(1));
|
||||
});
|
||||
|
||||
@ -187,16 +262,16 @@ describe('Home component', () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
|
||||
render(<HomeWrapper />);
|
||||
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(3);
|
||||
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(4);
|
||||
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(4);
|
||||
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('renders vulnerability icons', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
|
||||
render(<HomeWrapper />);
|
||||
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(3);
|
||||
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(3);
|
||||
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(4);
|
||||
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(4);
|
||||
expect(await screen.findAllByTestId('critical-vulnerability-icon')).toHaveLength(1);
|
||||
});
|
||||
|
||||
@ -204,16 +279,17 @@ describe('Home component', () => {
|
||||
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
|
||||
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<HomeWrapper />);
|
||||
await waitFor(() => expect(error).toBeCalledTimes(3));
|
||||
await waitFor(() => expect(error).toBeCalledTimes(4));
|
||||
});
|
||||
|
||||
it('should redirect to explore page when clicking view all popular', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListBookmarks } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListStars } });
|
||||
render(<HomeWrapper />);
|
||||
const viewAllButtons = await screen.findAllByText(/view all/i);
|
||||
expect(viewAllButtons).toHaveLength(3);
|
||||
expect(viewAllButtons).toHaveLength(4);
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: [] } });
|
||||
fireEvent.click(viewAllButtons[0]);
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith({
|
||||
@ -230,5 +306,10 @@ describe('Home component', () => {
|
||||
pathname: `/explore`,
|
||||
search: createSearchParams({ filter: 'IsBookmarked' }).toString()
|
||||
});
|
||||
fireEvent.click(viewAllButtons[3]);
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith({
|
||||
pathname: `/explore`,
|
||||
search: createSearchParams({ filter: 'IsStarred' }).toString()
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import HomePage from 'pages/HomePage';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router';
|
||||
|
||||
jest.mock(
|
||||
'components/Home/Home',
|
||||
|
@ -1,7 +1,7 @@
|
||||
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 { BrowserRouter, Route, Routes } from 'react-router';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
it('renders the signin presentation component and signin components if auth enabled', () => {
|
||||
|
@ -7,13 +7,13 @@ 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: {} } }
|
||||
http: { auth: { htpasswd: {}, openid: { providers: { github: {} } } } }
|
||||
};
|
||||
|
||||
// useNavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useNavigate: () => mockedUsedNavigate
|
||||
}));
|
||||
|
||||
@ -24,14 +24,14 @@ afterEach(() => {
|
||||
|
||||
describe('Signin component automatic navigation', () => {
|
||||
it('navigates to homepage when user is already logged in', async () => {
|
||||
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={true} setIsLoggedIn={() => {}} />);
|
||||
render(<SignIn isLoggedIn={true} setIsLoggedIn={() => {}} />);
|
||||
await expect(mockedUsedNavigate).toHaveBeenCalledWith('/home');
|
||||
});
|
||||
|
||||
it('navigates to homepage when auth is disabled', async () => {
|
||||
// mock request to check auth
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { http: {} } });
|
||||
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith('/home');
|
||||
});
|
||||
@ -48,49 +48,180 @@ describe('Sign in form', () => {
|
||||
});
|
||||
|
||||
it('should change username and password values on user input', async () => {
|
||||
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
const usernameInput = await screen.findByLabelText(/^Username/i);
|
||||
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
|
||||
fireEvent.change(usernameInput, { target: { value: 'test' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
expect(usernameInput).toHaveValue('test');
|
||||
expect(passwordInput).toHaveValue('test');
|
||||
expect(screen.getByTestId('openid-divider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display error if username and password values are empty after change', async () => {
|
||||
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
const usernameInput = await screen.findByLabelText(/^Username/i);
|
||||
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
|
||||
userEvent.click(usernameInput);
|
||||
userEvent.type(usernameInput, 't');
|
||||
userEvent.type(usernameInput, '{backspace}');
|
||||
userEvent.click(passwordInput);
|
||||
userEvent.type(passwordInput, 't');
|
||||
userEvent.type(passwordInput, '{backspace}');
|
||||
await fireEvent.click(usernameInput);
|
||||
await userEvent.type(usernameInput, 't');
|
||||
await userEvent.type(usernameInput, '{backspace}');
|
||||
await fireEvent.click(passwordInput);
|
||||
await userEvent.type(passwordInput, 't');
|
||||
await userEvent.type(passwordInput, '{backspace}');
|
||||
const usernameError = await screen.findByText(/enter a username/i);
|
||||
const passwordError = await screen.findByText(/enter a password/i);
|
||||
await waitFor(() => expect(usernameError).toBeInTheDocument());
|
||||
await waitFor(() => expect(passwordError).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should log in the user and navigate to homepage if login is successful', async () => {
|
||||
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
const submitButton = await screen.findByText('Continue');
|
||||
it('should log in the user and navigate to homepage if login is successful using button', async () => {
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
|
||||
const usernameInput = await screen.findByLabelText(/^Username/i);
|
||||
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
|
||||
await userEvent.type(usernameInput, 'test');
|
||||
await userEvent.type(passwordInput, 'test');
|
||||
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: {} } });
|
||||
const submitButton = await screen.findByText('Continue');
|
||||
fireEvent.click(submitButton);
|
||||
await waitFor(() => {
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith('/home');
|
||||
});
|
||||
});
|
||||
|
||||
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({ status: 401, data: {} });
|
||||
fireEvent.click(submitButton);
|
||||
const errorDisplay = await screen.findByText(/Authentication Failed/i);
|
||||
it('should display an error if username is blank and login is attempted using button', async () => {
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
|
||||
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
|
||||
await userEvent.type(passwordInput, 'test');
|
||||
const submitButton = await screen.findByTestId('basic-auth-submit-btn');
|
||||
await fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/enter a username/i)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText(/enter a password/i)).not.toBeInTheDocument());
|
||||
await waitFor(() => {
|
||||
expect(errorDisplay).toBeInTheDocument();
|
||||
expect(mockedUsedNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display an error if password is blank and login is attempted using button', async () => {
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
|
||||
const usernameInput = await screen.findByLabelText(/^Username/i);
|
||||
userEvent.type(usernameInput, 'test');
|
||||
const submitButton = await screen.findByTestId('basic-auth-submit-btn');
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/enter a username/i)).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText(/enter a password/i)).toBeInTheDocument());
|
||||
await waitFor(() => {
|
||||
expect(mockedUsedNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display an error if username and password are both blank and login is attempted using button', async () => {
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
|
||||
const submitButton = await screen.findByTestId('basic-auth-submit-btn');
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/enter a username/i)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText(/enter a password/i)).toBeInTheDocument());
|
||||
await waitFor(() => {
|
||||
expect(mockedUsedNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should log in the user and navigate to homepage if login is successful using enter key on username field', async () => {
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
|
||||
const usernameInput = await screen.findByLabelText(/^Username/i);
|
||||
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
|
||||
await userEvent.type(usernameInput, 'test');
|
||||
await userEvent.type(passwordInput, 'test');
|
||||
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: {} } });
|
||||
userEvent.type(usernameInput, '{enter}');
|
||||
await waitFor(() => {
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith('/home');
|
||||
});
|
||||
});
|
||||
|
||||
it('should log in the user and navigate to homepage if login is successful using enter key on password field', async () => {
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
|
||||
const usernameInput = await screen.findByLabelText(/^Username/i);
|
||||
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
|
||||
await userEvent.type(usernameInput, 'test');
|
||||
await userEvent.type(passwordInput, 'test');
|
||||
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: {} } });
|
||||
await userEvent.type(passwordInput, '{enter}');
|
||||
await waitFor(() => {
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith('/home');
|
||||
});
|
||||
});
|
||||
|
||||
it('should display an error if username is blank and login is attempted using enter key', async () => {
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
|
||||
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
|
||||
userEvent.type(passwordInput, 'test');
|
||||
userEvent.type(passwordInput, '{enter}');
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/enter a username/i)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText(/enter a password/i)).not.toBeInTheDocument());
|
||||
await waitFor(() => {
|
||||
expect(mockedUsedNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display an error if password is blank and login is attempted using enter key', async () => {
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
|
||||
const usernameInput = await screen.findByLabelText(/^Username/i);
|
||||
userEvent.type(usernameInput, 'test');
|
||||
userEvent.type(usernameInput, '{enter}');
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/enter a username/i)).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText(/enter a password/i)).toBeInTheDocument());
|
||||
await waitFor(() => {
|
||||
expect(mockedUsedNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display an error if username and password are both blank and login is attempted using enter key', async () => {
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
|
||||
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
|
||||
userEvent.type(passwordInput, '{enter}');
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/enter a username/i)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText(/enter a password/i)).toBeInTheDocument());
|
||||
await waitFor(() => {
|
||||
expect(mockedUsedNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should should display login error if login not successful', async () => {
|
||||
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
|
||||
|
||||
const usernameInput = await screen.findByLabelText(/^Username/i);
|
||||
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
|
||||
await userEvent.type(usernameInput, 'test');
|
||||
await userEvent.type(passwordInput, 'test');
|
||||
|
||||
jest.spyOn(api, 'get').mockRejectedValue({ status: 401, data: {} });
|
||||
|
||||
const submitButton = await screen.findByText('Continue');
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Authentication Failed/i)).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedUsedNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import RepoDetails from 'components/Repo/RepoDetails';
|
||||
import React from 'react';
|
||||
import { api } from 'api';
|
||||
import { createSearchParams } from 'react-router-dom';
|
||||
import { createSearchParams } from 'react-router';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
@ -22,8 +22,8 @@ const mockUseLocationValue = {
|
||||
|
||||
const mockUseNavigate = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useParams: () => {
|
||||
return { name: 'test' };
|
||||
},
|
||||
@ -47,9 +47,22 @@ const mockRepoDetailsData = {
|
||||
Size: '451554070',
|
||||
Vendors: ['[The Node.js Docker Team](https://github.com/nodejs/docker-node)\n'],
|
||||
IsBookmarked: false,
|
||||
IsStarred: false,
|
||||
NewestImage: {
|
||||
RepoName: 'mongo',
|
||||
IsSigned: true,
|
||||
SignatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: true,
|
||||
Author: 'author1'
|
||||
},
|
||||
{
|
||||
Tool: 'notation',
|
||||
IsTrusted: true,
|
||||
Author: 'author2'
|
||||
}
|
||||
],
|
||||
Vulnerabilities: {
|
||||
MaxSeverity: 'CRITICAL',
|
||||
Count: 15
|
||||
@ -284,6 +297,20 @@ describe('Repo details component', () => {
|
||||
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders signature icons', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockRepoDetailsData } });
|
||||
render(<RepoDetailsThemeWrapper />);
|
||||
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(2);
|
||||
|
||||
const allTrustedSignaturesIcons = await screen.findAllByTestId('verified-icon');
|
||||
fireEvent.mouseOver(allTrustedSignaturesIcons[0]);
|
||||
expect(await screen.findByText('Tool: cosign')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Signed-by: author1')).toBeInTheDocument();
|
||||
fireEvent.mouseOver(allTrustedSignaturesIcons[1]);
|
||||
expect(await screen.findByText('Tool: notation')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Signed-by: author2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should log error if data can't be fetched", async () => {
|
||||
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
|
||||
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
@ -316,4 +343,13 @@ describe('Repo details component', () => {
|
||||
await userEvent.click(bookmarkButton);
|
||||
expect(await screen.findByTestId('bookmarked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should star a repo if star button is clicked', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockRepoDetailsData } });
|
||||
render(<RepoDetailsThemeWrapper />);
|
||||
const starButton = await screen.findByTestId('star-button');
|
||||
jest.spyOn(api, 'put').mockResolvedValue({ status: 200, data: {} });
|
||||
await userEvent.click(starButton);
|
||||
expect(await screen.findByTestId('starred')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@ -1,11 +1,11 @@
|
||||
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 { BrowserRouter, Route, Routes } from 'react-router';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useLocation: () => ({
|
||||
pathname: 'localhost:3000/image/test',
|
||||
state: { lastDate: '' }
|
||||
|
@ -13,8 +13,8 @@ const TagsThemeWrapper = () => {
|
||||
};
|
||||
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useNavigate: () => mockedUsedNavigate
|
||||
}));
|
||||
|
||||
@ -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');
|
||||
@ -112,9 +127,9 @@ describe('Tags component', () => {
|
||||
render(<TagsThemeWrapper />);
|
||||
const selectFilter = await screen.findByText('Newest');
|
||||
expect(selectFilter).toBeInTheDocument();
|
||||
userEvent.click(selectFilter);
|
||||
await userEvent.click(selectFilter);
|
||||
const newOption = await screen.findByText('A - Z');
|
||||
userEvent.click(newOption);
|
||||
await userEvent.click(newOption);
|
||||
expect(await screen.findByText('A - Z')).toBeInTheDocument();
|
||||
expect(await screen.queryByText('Newest')).not.toBeInTheDocument();
|
||||
});
|
||||
|
@ -5,8 +5,8 @@ import PreviewCard from 'components/Shared/PreviewCard';
|
||||
|
||||
// usenavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useNavigate: () => mockedUsedNavigate
|
||||
}));
|
||||
|
||||
@ -32,7 +32,7 @@ describe('Preview card component', () => {
|
||||
render(<PreviewCard name={mockImage.name} lastUpdated={mockImage.lastUpdated} />);
|
||||
const cardTitle = await screen.findByText('alpine');
|
||||
expect(cardTitle).toBeInTheDocument();
|
||||
userEvent.click(cardTitle);
|
||||
await userEvent.click(cardTitle);
|
||||
expect(mockedUsedNavigate).toBeCalledWith(`/image/${mockImage.name}`);
|
||||
});
|
||||
});
|
||||
|
@ -2,13 +2,13 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import RepoCard from 'components/Shared/RepoCard';
|
||||
import { createSearchParams } from 'react-router-dom';
|
||||
import { createSearchParams } from 'react-router';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
// usenavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useNavigate: () => mockedUsedNavigate
|
||||
}));
|
||||
|
||||
@ -22,6 +22,29 @@ 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' }]
|
||||
};
|
||||
|
||||
@ -34,6 +57,8 @@ const RepoCardWrapper = (props) => {
|
||||
version={image.latestVersion}
|
||||
description={image.description}
|
||||
vendor={image.vendor}
|
||||
isSigned={image.isSigned}
|
||||
signatureInfo={image.signatureInfo}
|
||||
key={1}
|
||||
lastUpdated={image.lastUpdated}
|
||||
platforms={image.platforms}
|
||||
@ -52,7 +77,7 @@ describe('Repo card component', () => {
|
||||
render(<RepoCardWrapper image={mockImage} />);
|
||||
const cardTitle = await screen.findByText('alpine');
|
||||
expect(cardTitle).toBeInTheDocument();
|
||||
userEvent.click(cardTitle);
|
||||
await userEvent.click(cardTitle);
|
||||
expect(mockedUsedNavigate).toBeCalledWith(`/image/${mockImage.name}`);
|
||||
});
|
||||
|
||||
@ -60,7 +85,7 @@ describe('Repo card component', () => {
|
||||
render(<RepoCardWrapper image={{ ...mockImage, lastUpdated: '' }} />);
|
||||
const cardTitle = await screen.findByText('alpine');
|
||||
expect(cardTitle).toBeInTheDocument();
|
||||
userEvent.click(cardTitle);
|
||||
await userEvent.click(cardTitle);
|
||||
expect(mockedUsedNavigate).toBeCalledWith(`/image/${mockImage.name}`);
|
||||
expect(await screen.findByText(/timestamp n\/a/i)).toBeInTheDocument();
|
||||
});
|
||||
|
@ -2,13 +2,13 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { api } from 'api';
|
||||
import SearchSuggestion from 'components/Header/SearchSuggestion';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import React from 'react';
|
||||
|
||||
// router mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useNavigate: () => mockedUsedNavigate
|
||||
}));
|
||||
|
||||
|
@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { api } from 'api';
|
||||
import DependsOn from 'components/Tag/Tabs/DependsOn';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
const mockDependenciesList = {
|
||||
@ -65,8 +65,8 @@ const RouterDependsWrapper = () => {
|
||||
|
||||
// useNavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useNavigate: () => mockedUsedNavigate
|
||||
}));
|
||||
|
||||
|
@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { api } from 'api';
|
||||
import IsDependentOn from 'components/Tag/Tabs/IsDependentOn';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
|
||||
const mockDependentsList = {
|
||||
@ -65,8 +65,8 @@ const RouterDependsWrapper = () => {
|
||||
|
||||
// useNavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useNavigate: () => mockedUsedNavigate
|
||||
}));
|
||||
|
||||
|
@ -40,8 +40,8 @@ const mockReferrersList = [
|
||||
|
||||
// useNavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useNavigate: () => mockedUsedNavigate
|
||||
}));
|
||||
|
||||
@ -71,7 +71,7 @@ describe('Referred by tab', () => {
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(firstDigest);
|
||||
expect(
|
||||
await screen.findByText(/sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c/i)
|
||||
await screen.queryByText(/sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { api } from 'api';
|
||||
import TagDetails from 'components/Tag/TagDetails';
|
||||
import MockThemeProvider from '__mocks__/MockThemeProvider';
|
||||
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { BrowserRouter, Routes, Route, useLocation } from 'react-router';
|
||||
|
||||
const TagDetailsThemeWrapper = () => {
|
||||
return (
|
||||
@ -174,7 +174,20 @@ const mockImage = {
|
||||
MaxSeverity: 'CRITICAL',
|
||||
Count: 10
|
||||
},
|
||||
Vendor: 'CentOS'
|
||||
Vendor: 'CentOS',
|
||||
IsSigned: true,
|
||||
SignatureInfo: [
|
||||
{
|
||||
Tool: 'cosign',
|
||||
IsTrusted: true,
|
||||
Author: 'author1'
|
||||
},
|
||||
{
|
||||
Tool: 'notation',
|
||||
IsTrusted: true,
|
||||
Author: 'author2'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@ -842,8 +855,8 @@ Object.assign(navigator, {
|
||||
}
|
||||
});
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useParams: () => {
|
||||
return { name: 'test', tag: '1.0.1' };
|
||||
},
|
||||
@ -963,6 +976,20 @@ describe('Tags details', () => {
|
||||
expect(await screen.findByTestId('high-vulnerability-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders signature icons', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImage } });
|
||||
render(<TagDetailsThemeWrapper />);
|
||||
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(2);
|
||||
|
||||
const allTrustedSignaturesIcons = await screen.findAllByTestId('verified-icon');
|
||||
fireEvent.mouseOver(allTrustedSignaturesIcons[0]);
|
||||
expect(await screen.findByText('Tool: cosign')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Signed-by: author1')).toBeInTheDocument();
|
||||
fireEvent.mouseOver(allTrustedSignaturesIcons[1]);
|
||||
expect(await screen.findByText('Tool: notation')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Signed-by: author2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should copy the docker pull string to clipboard', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImage } });
|
||||
render(<TagDetailsThemeWrapper />);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import TagPage from 'pages/TagPage';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router';
|
||||
|
||||
afterEach(() => {
|
||||
// restore the spy created with spyOn
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
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';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
|
||||
jest.mock('xlsx');
|
||||
|
||||
const StateVulnerabilitiesWrapper = () => {
|
||||
return (
|
||||
@ -16,10 +18,18 @@ const StateVulnerabilitiesWrapper = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const mockCVEList = {
|
||||
const simpleMockCVEList = {
|
||||
CVEListForImage: {
|
||||
Tag: '',
|
||||
Page: { ItemCount: 20, TotalCount: 20 },
|
||||
Page: { ItemCount: 2, TotalCount: 2 },
|
||||
Summary: {
|
||||
Count: 2,
|
||||
UnknownCount: 0,
|
||||
LowCount: 0,
|
||||
MediumCount: 1,
|
||||
HighCount: 0,
|
||||
CriticalCount: 1
|
||||
},
|
||||
CVEList: [
|
||||
{
|
||||
Id: 'CVE-2020-16156',
|
||||
@ -29,6 +39,54 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'perl-base',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '5.30.0-9ubuntu0.2',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
Id: 'CVE-2016-1000027',
|
||||
Title: 'spring: HttpInvokerServiceExporter readRemoteInvocation method untrusted java deserialization',
|
||||
Description:
|
||||
"Pivotal Spring Framework through 5.3.16 suffers from a potential remote code execution (RCE) issue if used for Java deserialization of untrusted data. Depending on how the library is implemented within a product, this issue may or not occur, and authentication may be required. NOTE: the vendor's position is that untrusted data is not an intended use case. The product's behavior will not be changed because some users rely on deserialization of trusted data.",
|
||||
Severity: 'CRITICAL',
|
||||
Reference: 'https://avd.aquasec.com/nvd/cve-2016-1000027',
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'org.springframework:spring-web',
|
||||
PackagePath: 'usr/local/tomcat/webapps/spring4shell.war/WEB-INF/lib/spring-web-5.3.15.jar',
|
||||
InstalledVersion: '5.3.15',
|
||||
FixedVersion: '6.0.0'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const mockCVEList = {
|
||||
CVEListForImage: {
|
||||
Tag: '',
|
||||
Page: { ItemCount: 20, TotalCount: 20 },
|
||||
Summary: {
|
||||
Count: 5,
|
||||
UnknownCount: 1,
|
||||
LowCount: 1,
|
||||
MediumCount: 1,
|
||||
HighCount: 1,
|
||||
CriticalCount: 1
|
||||
},
|
||||
CVEList: [
|
||||
{
|
||||
Id: 'CVE-2020-16156',
|
||||
Title: 'perl-CPAN: Bypass of verification of signatures in CHECKSUMS files',
|
||||
Description: 'CPAN 2.28 allows Signature Verification Bypass.',
|
||||
Severity: 'MEDIUM',
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'perl-base',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '5.30.0-9ubuntu0.2',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -44,26 +102,31 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'krb5-locales',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1.17-6ubuntu4.1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libgssapi-krb5-2',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1.17-6ubuntu4.1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libk5crypto3',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1.17-6ubuntu4.1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libkrb5-3',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1.17-6ubuntu4.1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libkrb5support0',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1.17-6ubuntu4.1',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -78,6 +141,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libgnutls30',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '3.6.13-2ubuntu1.6',
|
||||
FixedVersion: '3.6.13-2ubuntu1.7'
|
||||
}
|
||||
@ -92,6 +156,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libpcre2-8-0',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '10.34-7',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -106,6 +171,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libsqlite3-0',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '3.31.1-4ubuntu0.3',
|
||||
FixedVersion: '3.31.1-4ubuntu0.4'
|
||||
}
|
||||
@ -120,6 +186,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libpcre3',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '2:8.39-12ubuntu0.1',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -134,6 +201,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libsqlite3-0',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '3.31.1-4ubuntu0.3',
|
||||
FixedVersion: '3.31.1-4ubuntu0.4'
|
||||
}
|
||||
@ -148,11 +216,13 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'login',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1:4.8.1-1ubuntu5.20.04.2',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'passwd',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1:4.8.1-1ubuntu5.20.04.2',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -167,6 +237,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libgmp10',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '2:6.2.0+dfsg-4',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -181,6 +252,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libgnutls30',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '3.6.13-2ubuntu1.6',
|
||||
FixedVersion: '3.6.13-2ubuntu1.7'
|
||||
}
|
||||
@ -195,26 +267,31 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libncurses6',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '6.2-0ubuntu2',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libncursesw6',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '6.2-0ubuntu2',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libtinfo6',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '6.2-0ubuntu2',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'ncurses-base',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '6.2-0ubuntu2',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'ncurses-bin',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '6.2-0ubuntu2',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -229,6 +306,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libpcre2-8-0',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '10.34-7',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -243,26 +321,31 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libncurses6',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '6.2-0ubuntu2',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libncursesw6',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '6.2-0ubuntu2',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libtinfo6',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '6.2-0ubuntu2',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'ncurses-base',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '6.2-0ubuntu2',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'ncurses-bin',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '6.2-0ubuntu2',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -277,6 +360,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'coreutils',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '8.30-3ubuntu2',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -291,46 +375,55 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libasn1-8-heimdal',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libgssapi3-heimdal',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libhcrypto4-heimdal',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libheimbase1-heimdal',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libheimntlm0-heimdal',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libhx509-5-heimdal',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libkrb5-26-heimdal',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libroken18-heimdal',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libwind0-heimdal',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -345,11 +438,13 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libc-bin',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '2.31-0ubuntu9.9',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libc6',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '2.31-0ubuntu9.9',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -363,6 +458,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libcurl4',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '7.68.0-1ubuntu2.12',
|
||||
FixedVersion: '7.68.0-1ubuntu2.13'
|
||||
}
|
||||
@ -378,26 +474,31 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'krb5-locales',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1.17-6ubuntu4.1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libgssapi-krb5-2',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1.17-6ubuntu4.1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libk5crypto3',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1.17-6ubuntu4.1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libkrb5-3',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1.17-6ubuntu4.1',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'libkrb5support0',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1.17-6ubuntu4.1',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -412,6 +513,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'libsqlite3-0',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '3.31.1-4ubuntu0.3',
|
||||
FixedVersion: '3.31.1-4ubuntu0.4'
|
||||
}
|
||||
@ -427,6 +529,7 @@ const mockCVEList = {
|
||||
PackageList: [
|
||||
{
|
||||
Name: 'zlib1g',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '1:1.2.11.dfsg-2ubuntu1.3',
|
||||
FixedVersion: 'Not Specified'
|
||||
}
|
||||
@ -440,10 +543,52 @@ const mockCVEListFiltered = {
|
||||
CVEListForImage: {
|
||||
Tag: '',
|
||||
Page: { ItemCount: 20, TotalCount: 20 },
|
||||
Summary: {
|
||||
Count: 5,
|
||||
UnknownCount: 1,
|
||||
LowCount: 1,
|
||||
MediumCount: 1,
|
||||
HighCount: 1,
|
||||
CriticalCount: 1
|
||||
},
|
||||
CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => e.Id.includes('2022'))
|
||||
}
|
||||
};
|
||||
|
||||
const mockCVEListFilteredBySeverity = (severity) => {
|
||||
return {
|
||||
CVEListForImage: {
|
||||
Tag: '',
|
||||
Page: { ItemCount: 20, TotalCount: 20 },
|
||||
Summary: {
|
||||
Count: 5,
|
||||
UnknownCount: 1,
|
||||
LowCount: 1,
|
||||
MediumCount: 1,
|
||||
HighCount: 1,
|
||||
CriticalCount: 1
|
||||
},
|
||||
CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => e.Severity.includes(severity))
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const mockCVEListFilteredExclude = {
|
||||
CVEListForImage: {
|
||||
Tag: '',
|
||||
Page: { ItemCount: 20, TotalCount: 20 },
|
||||
Summary: {
|
||||
Count: 5,
|
||||
UnknownCount: 1,
|
||||
LowCount: 1,
|
||||
MediumCount: 1,
|
||||
HighCount: 1,
|
||||
CriticalCount: 1
|
||||
},
|
||||
CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => !e.Id.includes('2022'))
|
||||
}
|
||||
};
|
||||
|
||||
const mockCVEFixed = {
|
||||
pageOne: {
|
||||
ImageListWithCVEFixed: {
|
||||
@ -497,41 +642,100 @@ describe('Vulnerabilties page', () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
|
||||
await waitFor(() => expect(screen.getAllByText(/fixed in/i)).toHaveLength(20));
|
||||
await waitFor(() => expect(screen.getAllByText('Total 5')).toHaveLength(1));
|
||||
await waitFor(() => expect(screen.getAllByText(/CVE/)).toHaveLength(20));
|
||||
});
|
||||
|
||||
it('renders the vulnerabilities by severity', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
|
||||
await waitFor(() => expect(screen.getAllByText('Total 5')).toHaveLength(1));
|
||||
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(20));
|
||||
expect(screen.getByLabelText('Medium')).toBeInTheDocument();
|
||||
const mediumSeverity = await screen.getByLabelText('Medium');
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('MEDIUM') } });
|
||||
fireEvent.click(mediumSeverity);
|
||||
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(6));
|
||||
expect(screen.getByLabelText('High')).toBeInTheDocument();
|
||||
const highSeverity = await screen.getByLabelText('High');
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('HIGH') } });
|
||||
fireEvent.click(highSeverity);
|
||||
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(1));
|
||||
expect(screen.getByLabelText('Critical')).toBeInTheDocument();
|
||||
const criticalSeverity = await screen.getByLabelText('Critical');
|
||||
jest
|
||||
.spyOn(api, 'get')
|
||||
.mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('CRITICAL') } });
|
||||
fireEvent.click(criticalSeverity);
|
||||
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(1));
|
||||
expect(screen.getByLabelText('Low')).toBeInTheDocument();
|
||||
const lowSeverity = await screen.getByLabelText('Low');
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('LOW') } });
|
||||
fireEvent.click(lowSeverity);
|
||||
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(10));
|
||||
expect(screen.getByLabelText('Unknown')).toBeInTheDocument();
|
||||
const unknownSeverity = await screen.getByLabelText('Unknown');
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('UNKNOWN') } });
|
||||
fireEvent.click(unknownSeverity);
|
||||
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(1));
|
||||
expect(screen.getByText('Total 5')).toBeInTheDocument();
|
||||
const totalSeverity = await screen.getByText('Total 5');
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('') } });
|
||||
fireEvent.click(totalSeverity);
|
||||
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(20));
|
||||
});
|
||||
|
||||
it('sends filtered query if user types in the search bar', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
await waitFor(() => expect(screen.getAllByText(/CVE/)).toHaveLength(20));
|
||||
const cveSearchInput = screen.getByPlaceholderText(/search/i);
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFiltered } });
|
||||
await userEvent.type(cveSearchInput, '2022');
|
||||
expect(cveSearchInput).toHaveValue('2022');
|
||||
await waitFor(() => expect(screen.queryAllByText(/2022/i)).toHaveLength(7));
|
||||
await waitFor(() => expect(screen.queryAllByText(/2021/i)).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('should have a collapsable search bar', async () => {
|
||||
jest
|
||||
.spyOn(api, 'get')
|
||||
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
|
||||
.mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredExclude } });
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
const cveSearchInput = screen.getByPlaceholderText(/search/i);
|
||||
jest.spyOn(api, 'get').mockRejectedValueOnce({ status: 200, data: { data: mockCVEListFiltered } });
|
||||
await userEvent.type(cveSearchInput, '2022');
|
||||
expect((await screen.queryAllByText(/2023/i).length) === 0);
|
||||
expect((await screen.findAllByText(/2022/i)).length === 6);
|
||||
const expandSearch = cveSearchInput.parentElement.parentElement.parentElement.parentElement.childNodes[0];
|
||||
await fireEvent.click(expandSearch);
|
||||
await waitFor(() => expect(screen.getAllByPlaceholderText('Exclude')).toHaveLength(1));
|
||||
const excludeInput = screen.getByPlaceholderText('Exclude');
|
||||
await userEvent.type(excludeInput, '2022');
|
||||
expect(excludeInput).toHaveValue('2022');
|
||||
await waitFor(() => expect(screen.queryAllByText(/2022/i)).toHaveLength(0));
|
||||
await waitFor(() => expect(screen.queryAllByText(/2021/i)).toHaveLength(6));
|
||||
});
|
||||
|
||||
it('renders no vulnerabilities if there are not any', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({
|
||||
status: 200,
|
||||
data: { data: { CVEListForImage: { Tag: '', Page: {}, CVEList: [] } } }
|
||||
data: { data: { CVEListForImage: { Tag: '', Page: {}, CVEList: [], Summary: {} } } }
|
||||
});
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
await waitFor(() => expect(screen.getAllByText('No Vulnerabilities')).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('should open and close description dropdown for vulnerabilities', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
|
||||
it('should show description for vulnerabilities', async () => {
|
||||
jest
|
||||
.spyOn(api, 'get')
|
||||
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
|
||||
.mockResolvedValue({ status: 200, data: { data: mockCVEFixed.pageOne } });
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
await waitFor(() => expect(screen.getAllByText(/description/i)).toHaveLength(20));
|
||||
const openText = screen.getAllByText(/description/i);
|
||||
await fireEvent.click(openText[0]);
|
||||
const expandListBtn = await screen.findAllByTestId('ViewAgendaIcon');
|
||||
fireEvent.click(expandListBtn[0]);
|
||||
await waitFor(() => expect(screen.getAllByText(/Description/)).toHaveLength(20));
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText(/CPAN 2.28 allows Signature Verification Bypass./i)).toHaveLength(1)
|
||||
);
|
||||
await fireEvent.click(openText[0]);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/CPAN 2.28 allows Signature Verification Bypass./i)).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it("should log an error when data can't be fetched", async () => {
|
||||
@ -549,15 +753,114 @@ describe('Vulnerabilties page', () => {
|
||||
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageTwo } });
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
|
||||
await fireEvent.click(screen.getAllByText(/fixed in/i)[0]);
|
||||
const expandListBtn = await screen.findAllByTestId('KeyboardArrowRightIcon');
|
||||
fireEvent.click(expandListBtn[1]);
|
||||
await waitFor(() => expect(screen.getByText('1.0.16')).toBeInTheDocument());
|
||||
const loadMoreBtn = screen.getByText(/load more/i);
|
||||
expect(loadMoreBtn).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getAllByText(/Load more/).length).toBe(1));
|
||||
const loadMoreBtn = screen.getAllByText(/Load more/)[0];
|
||||
await fireEvent.click(loadMoreBtn);
|
||||
await waitFor(() => expect(loadMoreBtn).not.toBeInTheDocument());
|
||||
expect(await screen.findByText('latest')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the list of vulnerable packages for the CVEs', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: simpleMockCVEList } });
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
const expandListBtn = await screen.findByTestId('expand-list-view-toggle');
|
||||
fireEvent.click(expandListBtn);
|
||||
const packageLists = await screen.findAllByTestId('cve-package-list');
|
||||
expect(packageLists.length).toEqual(2); // Data set has 2 CVEs, so 2 package lists
|
||||
|
||||
const expectedData = [
|
||||
{
|
||||
Name: 'perl-base',
|
||||
PackagePath: 'Not Specified',
|
||||
InstalledVersion: '5.30.0-9ubuntu0.2',
|
||||
FixedVersion: 'Not Specified'
|
||||
},
|
||||
{
|
||||
Name: 'org.springframework:spring-web',
|
||||
PackagePath: 'usr/local/tomcat/webapps/spring4shell.war/WEB-INF/lib/spring-web-5.3.15.jar',
|
||||
InstalledVersion: '5.3.15',
|
||||
FixedVersion: '6.0.0'
|
||||
}
|
||||
];
|
||||
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const expectedPackageData = expectedData[index];
|
||||
const container = packageLists[index];
|
||||
const pkgName = await within(container).findAllByTestId('cve-info-pkg-name');
|
||||
expect(pkgName).toHaveLength(1);
|
||||
expect(pkgName[0]).toHaveTextContent(expectedPackageData.Name);
|
||||
|
||||
const pkgPath = await within(container).findAllByTestId('cve-info-pkg-path');
|
||||
expect(pkgPath).toHaveLength(1);
|
||||
expect(pkgPath[0]).toHaveTextContent(expectedPackageData.PackagePath);
|
||||
|
||||
const pkgInstalledVer = await within(container).findAllByTestId('cve-info-pkg-install-ver');
|
||||
expect(pkgInstalledVer).toHaveLength(1);
|
||||
expect(pkgInstalledVer[0]).toHaveTextContent(expectedPackageData.InstalledVersion);
|
||||
|
||||
const pkgFixedVer = await within(container).findAllByTestId('cve-info-pkg-fixed-ver');
|
||||
expect(pkgFixedVer).toHaveLength(1);
|
||||
expect(pkgFixedVer[0]).toHaveTextContent(expectedPackageData.FixedVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow export of vulnerabilities list', async () => {
|
||||
const xlsxMock = jest.createMockFromModule('xlsx');
|
||||
xlsxMock.writeFile = jest.fn();
|
||||
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
|
||||
const downloadBtn = await screen.findAllByTestId('DownloadIcon');
|
||||
await 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);
|
||||
await waitFor(() => expect(screen.queryByTestId('export-csv-menuItem')).not.toBeInTheDocument());
|
||||
await fireEvent.click(downloadBtn[0]);
|
||||
const exportAsExcelBtn = screen.getByText(/xlsx/i);
|
||||
expect(exportAsExcelBtn).toBeInTheDocument();
|
||||
await userEvent.click(exportAsExcelBtn);
|
||||
expect(await screen.queryByTestId('export-excel-menuItem')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should log an error when data can't be fetched for downloading", async () => {
|
||||
const xlsxMock = jest.createMockFromModule('xlsx');
|
||||
xlsxMock.writeFile = jest.fn();
|
||||
|
||||
jest
|
||||
.spyOn(api, 'get')
|
||||
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
|
||||
.mockRejectedValue({ status: 500, data: {} });
|
||||
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
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();
|
||||
await waitFor(() => expect(error).toBeCalledTimes(1));
|
||||
});
|
||||
|
||||
it('should expand/collapse the list of CVEs', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEFixed.pageOne } });
|
||||
const expandListBtn = await screen.findAllByTestId('ViewAgendaIcon');
|
||||
await fireEvent.click(expandListBtn[0]);
|
||||
await waitFor(() => expect(screen.getAllByText('Fixed in')).toHaveLength(20));
|
||||
const collapseListBtn = await screen.findAllByTestId('ViewHeadlineIcon');
|
||||
await fireEvent.click(collapseListBtn[0]);
|
||||
await waitFor(() => expect(screen.queryByText('Fixed in')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should handle fixed CVE query errors', async () => {
|
||||
jest
|
||||
.spyOn(api, 'get')
|
||||
@ -566,7 +869,8 @@ describe('Vulnerabilties page', () => {
|
||||
render(<StateVulnerabilitiesWrapper />);
|
||||
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
|
||||
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await fireEvent.click(screen.getAllByText(/fixed in/i)[0]);
|
||||
const expandListBtn = await screen.findAllByTestId('KeyboardArrowRightIcon');
|
||||
fireEvent.click(expandListBtn[1]);
|
||||
await waitFor(() => expect(screen.getByText(/not fixed/i)).toBeInTheDocument());
|
||||
await waitFor(() => expect(error).toBeCalledTimes(1));
|
||||
});
|
||||
|
64
src/api.js
64
src/api.js
@ -1,11 +1,11 @@
|
||||
import axios from 'axios';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { sortByCriteria } from 'utilities/sortCriteria';
|
||||
import { logoutUser } from 'utilities/authUtilities';
|
||||
import { isAuthenticationEnabled, logoutUser } from 'utilities/authUtilities';
|
||||
import { host } from 'host';
|
||||
|
||||
axios.interceptors.request.use((config) => {
|
||||
if (config.url.includes(endpoints.authConfig)) {
|
||||
if (config.url.includes(endpoints.authConfig) || !isAuthenticationEnabled()) {
|
||||
config.withCredentials = false;
|
||||
} else {
|
||||
config.headers['X-ZOT-API-CLIENT'] = 'zot-ui';
|
||||
@ -19,6 +19,7 @@ axios.interceptors.response.use(
|
||||
},
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
if (window.location.pathname.includes('/login')) return Promise.reject(error);
|
||||
logoutUser();
|
||||
window.location.replace('/login');
|
||||
return Promise.reject(error);
|
||||
@ -66,11 +67,14 @@ const api = {
|
||||
return axios.put(urli, payload, config);
|
||||
},
|
||||
|
||||
delete(urli, abortSignal, cfg) {
|
||||
delete(urli, params, abortSignal, cfg) {
|
||||
let config = isEmpty(cfg) ? this.getRequestCfg() : cfg;
|
||||
if (!isEmpty(abortSignal) && isEmpty(config.signal)) {
|
||||
config = { ...config, signal: abortSignal };
|
||||
}
|
||||
if (!isEmpty(params)) {
|
||||
config = { ...config, params };
|
||||
}
|
||||
return axios.delete(urli, config);
|
||||
}
|
||||
};
|
||||
@ -78,37 +82,61 @@ const api = {
|
||||
const endpoints = {
|
||||
status: `/v2/`,
|
||||
authConfig: `/v2/_zot/ext/mgmt`,
|
||||
openidAuth: `/auth/login`,
|
||||
logout: `/auth/logout`,
|
||||
openidAuth: `/zot/auth/login`,
|
||||
logout: `/zot/auth/logout`,
|
||||
apiKeys: '/zot/auth/apikey',
|
||||
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} IsStarred IsBookmarked DownloadCount}}}`,
|
||||
}}){Results {Name LastUpdated Size Platforms {Os Arch} NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description Licenses Title Source IsSigned SignatureInfo { Tool IsTrusted Author } Documentation Vendor Labels} IsStarred IsBookmarked StarCount DownloadCount}}}`,
|
||||
detailedRepoInfo: (name) =>
|
||||
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch} Size} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors IsStarred IsBookmarked NewestImage {RepoName IsSigned 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 }}`,
|
||||
vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }, searchTerm = '') => {
|
||||
`/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} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}}} Vendor Licenses }}`,
|
||||
vulnerabilitiesForRepo: (
|
||||
name,
|
||||
{ pageNumber = 1, pageSize = 15 },
|
||||
searchTerm = '',
|
||||
excludedTerm = '',
|
||||
severity = ''
|
||||
) => {
|
||||
let query = `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${
|
||||
(pageNumber - 1) * pageSize
|
||||
}}`;
|
||||
if (!isEmpty(searchTerm)) {
|
||||
query += `, searchedCVE: "${searchTerm}"`;
|
||||
}
|
||||
return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`;
|
||||
if (!isEmpty(excludedTerm)) {
|
||||
query += `, excludedCVE: "${excludedTerm}"`;
|
||||
}
|
||||
if (!isEmpty(severity)) {
|
||||
query += `, severity: "${severity}"`;
|
||||
}
|
||||
return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name PackagePath InstalledVersion FixedVersion}} Summary {Count UnknownCount LowCount MediumCount HighCount CriticalCount}}}`;
|
||||
},
|
||||
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 PackagePath 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,
|
||||
@ -125,9 +153,10 @@ const endpoints = {
|
||||
if (filter.Arch) filterParam += ` Arch:${!isEmpty(filter.Arch) ? `${JSON.stringify(filter.Arch)}` : '""'}`;
|
||||
if (filter.HasToBeSigned) filterParam += ` HasToBeSigned: ${filter.HasToBeSigned}`;
|
||||
if (filter.IsBookmarked) filterParam += ` IsBookmarked: ${filter.IsBookmarked}`;
|
||||
if (filter.IsStarred) filterParam += ` IsStarred: ${filter.IsStarred}`;
|
||||
filterParam += '}';
|
||||
if (Object.keys(filter).length === 0) filterParam = '';
|
||||
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam} ${filterParam}) {Page {TotalCount ItemCount} Repos {Name LastUpdated Size Platforms { Os Arch } IsStarred IsBookmarked NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description IsSigned 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:""`;
|
||||
@ -136,7 +165,8 @@ const endpoints = {
|
||||
},
|
||||
referrers: ({ repo, digest, type = '' }) =>
|
||||
`/v2/_zot/ext/search?query={Referrers(repo: "${repo}" digest: "${digest}" type: "${type}"){MediaType ArtifactType Size Digest Annotations{Key Value}}}`,
|
||||
bookmarkToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleBookmark`
|
||||
bookmarkToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleBookmark`,
|
||||
starToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleStar`
|
||||
};
|
||||
|
||||
export { api, endpoints };
|
||||
|
15
src/assets/noData.svg
Normal file
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 |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 1.7 KiB |
@ -15,7 +15,7 @@ import makeStyles from '@mui/styles/makeStyles';
|
||||
import { api, endpoints } from '../../api';
|
||||
import { host } from '../../host';
|
||||
import { mapToRepo } from 'utilities/objectModels.js';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import FilterCard from '../Shared/FilterCard.jsx';
|
||||
import { isEmpty, isNil } from 'lodash';
|
||||
import filterConstants from 'utilities/filterConstants.js';
|
||||
@ -220,8 +220,10 @@ function Explore({ searchInputValue }) {
|
||||
version={item.latestVersion}
|
||||
description={item.description}
|
||||
downloads={item.downloads}
|
||||
isSigned={item.isSigned}
|
||||
stars={item.stars}
|
||||
signatureInfo={item.signatureInfo}
|
||||
isBookmarked={item.isBookmarked}
|
||||
isStarred={item.isStarred}
|
||||
vendor={item.vendor}
|
||||
platforms={item.platforms}
|
||||
key={index}
|
||||
|
@ -1,5 +1,5 @@
|
||||
// react global
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
|
||||
// components
|
||||
import { Typography, Breadcrumbs } from '@mui/material';
|
||||
|
@ -1,6 +1,6 @@
|
||||
// react global
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import { isAuthenticated, isAuthenticationEnabled, logoutUser } from '../../utilities/authUtilities';
|
||||
|
||||
@ -149,14 +149,14 @@ function Header({ setSearchCurrentValue = () => {} }) {
|
||||
</Link>
|
||||
</Grid>
|
||||
<Grid item className={classes.headerLinkContainer}>
|
||||
<a className={classes.link} href="https://zotregistry.io" target="_blank" rel="noreferrer">
|
||||
<a className={classes.link} href="https://zotregistry.dev" target="_blank" rel="noreferrer">
|
||||
Product
|
||||
</a>
|
||||
</Grid>
|
||||
<Grid item className={classes.headerLinkContainer}>
|
||||
<a
|
||||
className={classes.link}
|
||||
href="https://zotregistry.io/v1.4.3/general/concepts/"
|
||||
href="https://zotregistry.dev/v2.0.0/general/concepts/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
@ -6,7 +6,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api, endpoints } from 'api';
|
||||
import { host } from 'host';
|
||||
import { mapToImage, mapToRepo } from 'utilities/objectModels';
|
||||
import { createSearchParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { createSearchParams, useNavigate, useSearchParams } from 'react-router';
|
||||
import { debounce, isEmpty } from 'lodash';
|
||||
import { useCombobox } from 'downshift';
|
||||
import { HEADER_SEARCH_PAGE_SIZE } from 'utilities/paginationConstants';
|
||||
@ -132,8 +132,14 @@ function SearchSuggestion({ setSearchCurrentValue = () => {} }) {
|
||||
|
||||
const handleSearch = (event) => {
|
||||
const { key, type } = event;
|
||||
const name = event.target.value;
|
||||
if (key === 'Enter' || type === 'click') {
|
||||
navigate({ pathname: `/explore`, search: createSearchParams({ search: inputValue || '' }).toString() });
|
||||
if (name?.includes(':')) {
|
||||
const splitName = name.split(':');
|
||||
navigate(`/image/${encodeURIComponent(splitName[0])}/tag/${splitName[1]}`);
|
||||
} else {
|
||||
navigate({ pathname: `/explore`, search: createSearchParams({ search: inputValue || '' }).toString() });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -295,12 +301,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
|
||||
|
@ -2,11 +2,17 @@ import React, { useState } from 'react';
|
||||
|
||||
import { Menu, MenuItem, IconButton, Avatar, Divider } from '@mui/material';
|
||||
|
||||
import { getLoggedInUser, logoutUser } from '../../utilities/authUtilities';
|
||||
import { getLoggedInUser, logoutUser, isApiKeyEnabled } from '../../utilities/authUtilities';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
function UserAccountMenu() {
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const openMenu = Boolean(anchorEl);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const apiKeyManagement = () => {
|
||||
navigate('/user/apikey');
|
||||
};
|
||||
|
||||
const handleUserClick = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
@ -24,6 +30,7 @@ function UserAccountMenu() {
|
||||
aria-controls={open ? 'account-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
data-testid="user-icon-header-button"
|
||||
>
|
||||
<Avatar sx={{ width: 32, height: 32 }} />
|
||||
</IconButton>
|
||||
@ -37,6 +44,12 @@ function UserAccountMenu() {
|
||||
>
|
||||
<MenuItem onClick={handleUserClose}>{getLoggedInUser()}</MenuItem>
|
||||
<Divider />
|
||||
{isApiKeyEnabled() && (
|
||||
<MenuItem onClick={apiKeyManagement} data-testid="api-keys-menu-item">
|
||||
API Keys
|
||||
</MenuItem>
|
||||
)}
|
||||
{isApiKeyEnabled() && <Divider data-testid="api-keys-menu-item-divider" />}
|
||||
<MenuItem onClick={logoutUser}>Log out</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
|
@ -6,10 +6,16 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import RepoCard from '../Shared/RepoCard';
|
||||
import { mapToRepo } from 'utilities/objectModels';
|
||||
import Loading from '../Shared/Loading';
|
||||
import { useNavigate, createSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, createSearchParams } from 'react-router';
|
||||
import { sortByCriteria } from 'utilities/sortCriteria';
|
||||
import { HOME_POPULAR_PAGE_SIZE, HOME_RECENT_PAGE_SIZE, HOME_BOOKMARKS_PAGE_SIZE } from 'utilities/paginationConstants';
|
||||
import {
|
||||
HOME_POPULAR_PAGE_SIZE,
|
||||
HOME_RECENT_PAGE_SIZE,
|
||||
HOME_BOOKMARKS_PAGE_SIZE,
|
||||
HOME_STARS_PAGE_SIZE
|
||||
} from 'utilities/paginationConstants';
|
||||
import { isEmpty } from 'lodash';
|
||||
import NoDataComponent from 'components/Shared/NoDataComponent';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
gridWrapper: {
|
||||
@ -88,6 +94,8 @@ function Home() {
|
||||
const [isLoadingRecent, setIsLoadingRecent] = useState(true);
|
||||
const [bookmarkData, setBookmarkData] = useState([]);
|
||||
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(true);
|
||||
const [starData, setStarData] = useState([]);
|
||||
const [isLoadingStars, setIsLoadingStars] = useState(true);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const abortController = useMemo(() => new AbortController(), []);
|
||||
@ -184,12 +192,44 @@ function Home() {
|
||||
});
|
||||
};
|
||||
|
||||
const getStars = () => {
|
||||
setIsLoadingStars(true);
|
||||
api
|
||||
.get(
|
||||
`${host()}${endpoints.globalSearch({
|
||||
searchQuery: '',
|
||||
pageNumber: 1,
|
||||
pageSize: HOME_STARS_PAGE_SIZE,
|
||||
sortBy: sortByCriteria.relevance?.value,
|
||||
filter: { IsStarred: true }
|
||||
})}`,
|
||||
abortController.signal
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.data && response.data.data) {
|
||||
let repoList = response.data.data.GlobalSearch.Repos;
|
||||
let repoData = repoList.map((responseRepo) => {
|
||||
return mapToRepo(responseRepo);
|
||||
});
|
||||
setStarData(repoData);
|
||||
setIsLoading(false);
|
||||
setIsLoadingStars(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setIsLoading(false);
|
||||
setIsLoadingStars(false);
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
setIsLoading(true);
|
||||
getPopularData();
|
||||
getRecentData();
|
||||
getBookmarks();
|
||||
getStars();
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
@ -199,6 +239,17 @@ function Home() {
|
||||
navigate({ pathname: `/explore`, search: createSearchParams({ [type]: value }).toString() });
|
||||
};
|
||||
|
||||
const isNoData = () =>
|
||||
!isLoading &&
|
||||
!isLoadingBookmarks &&
|
||||
!isLoadingStars &&
|
||||
!isLoadingPopular &&
|
||||
!isLoadingRecent &&
|
||||
bookmarkData.length === 0 &&
|
||||
starData.length === 0 &&
|
||||
popularData.length === 0 &&
|
||||
recentData.length === 0;
|
||||
|
||||
const renderCards = (cardArray) => {
|
||||
return (
|
||||
cardArray &&
|
||||
@ -209,8 +260,10 @@ function Home() {
|
||||
version={item.latestVersion}
|
||||
description={item.description}
|
||||
downloads={item.downloads}
|
||||
isSigned={item.isSigned}
|
||||
stars={item.stars}
|
||||
signatureInfo={item.signatureInfo}
|
||||
isBookmarked={item.isBookmarked}
|
||||
isStarred={item.isStarred}
|
||||
vendor={item.vendor}
|
||||
platforms={item.platforms}
|
||||
key={index}
|
||||
@ -226,68 +279,89 @@ function Home() {
|
||||
);
|
||||
};
|
||||
|
||||
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('sortby', sortByCriteria.downloads.value)}>
|
||||
<Typography variant="body2" className={classes.viewAll}>
|
||||
View all
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
{isLoadingPopular ? <Loading /> : renderCards(popularData)}
|
||||
{/* 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)}
|
||||
{!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)}
|
||||
</>
|
||||
)}
|
||||
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,6 +1,6 @@
|
||||
// react global
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
// utility
|
||||
import { api, endpoints } from '../../api';
|
||||
@ -20,7 +20,7 @@ import Alert from '@mui/material/Alert';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Loading from '../Shared/Loading';
|
||||
|
||||
import { GoogleLoginButton, GithubLoginButton, DexLoginButton } from './ThirdPartyLoginComponents';
|
||||
import { GoogleLoginButton, GithubLoginButton, GitlabLoginButton, OIDCLoginButton } from './ThirdPartyLoginComponents';
|
||||
|
||||
// styling
|
||||
import { makeStyles } from '@mui/styles';
|
||||
@ -149,8 +149,8 @@ const useStyles = makeStyles(() => ({
|
||||
export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading = () => {} }) {
|
||||
const [usernameError, setUsernameError] = useState(null);
|
||||
const [passwordError, setPasswordError] = useState(null);
|
||||
const [username, setUsername] = useState(null);
|
||||
const [password, setPassword] = useState(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [requestProcessing, setRequestProcessing] = useState(false);
|
||||
const [requestError, setRequestError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@ -228,13 +228,20 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
});
|
||||
};
|
||||
|
||||
const handleClick = (event) => {
|
||||
event.preventDefault();
|
||||
if (Object.keys(authMethods).includes('htpasswd')) {
|
||||
const handleBasicAuthSubmit = () => {
|
||||
setRequestError(false);
|
||||
const isUsernameValid = handleUsernameValidation(username);
|
||||
const isPasswordValid = handlePasswordValidation(password);
|
||||
if (Object.keys(authMethods).includes('htpasswd') && isUsernameValid && isPasswordValid) {
|
||||
handleBasicAuth();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (event) => {
|
||||
event.preventDefault();
|
||||
handleBasicAuthSubmit();
|
||||
};
|
||||
|
||||
const handleGuestClick = () => {
|
||||
setRequestProcessing(false);
|
||||
setRequestError(false);
|
||||
@ -251,47 +258,68 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
);
|
||||
};
|
||||
|
||||
const handleUsernameValidation = (username) => {
|
||||
let isValid = true;
|
||||
if (username === '') {
|
||||
setUsernameError('Please enter a username');
|
||||
isValid = false;
|
||||
} else {
|
||||
setUsernameError(null);
|
||||
}
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handlePasswordValidation = (password) => {
|
||||
let isValid = true;
|
||||
if (password === '') {
|
||||
setPasswordError('Please enter a password');
|
||||
isValid = false;
|
||||
} else {
|
||||
setPasswordError(null);
|
||||
}
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleChange = (event, type) => {
|
||||
event.preventDefault();
|
||||
setRequestError(false);
|
||||
|
||||
const val = event.target?.value;
|
||||
const isEmpty = val === '';
|
||||
|
||||
switch (type) {
|
||||
case 'username':
|
||||
setUsername(val);
|
||||
if (isEmpty) {
|
||||
setUsernameError('Please enter a username');
|
||||
} else {
|
||||
setUsernameError(null);
|
||||
}
|
||||
handleUsernameValidation(val);
|
||||
break;
|
||||
case 'password':
|
||||
setPassword(val);
|
||||
if (isEmpty) {
|
||||
setPasswordError('Please enter a password');
|
||||
} else {
|
||||
setPasswordError(null);
|
||||
}
|
||||
handlePasswordValidation(val);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginInputFieldKeyDown = (event) => {
|
||||
const keyPressed = event.key;
|
||||
if (keyPressed === 'Enter') {
|
||||
handleBasicAuthSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const renderThirdPartyLoginMethods = () => {
|
||||
let isGoogle = isObject(authMethods.openid?.providers?.google);
|
||||
// let isGitlab = isObject(authMethods.openid?.providers?.gitlab);
|
||||
let isGitlab = isObject(authMethods.openid?.providers?.gitlab);
|
||||
let isGithub = isObject(authMethods.openid?.providers?.github);
|
||||
let isDex = isObject(authMethods.openid?.providers?.dex);
|
||||
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} />} */}
|
||||
{isDex && <DexLoginButton handleClick={handleClickExternalLogin} />}
|
||||
{isGitlab && <GitlabLoginButton handleClick={handleClickExternalLogin} />}
|
||||
{isOIDC && <OIDCLoginButton handleClick={handleClickExternalLogin} oidcName={oidcName} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@ -308,10 +336,16 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
Sign In
|
||||
</Typography>
|
||||
<Typography align="left" className={classes.subtext} variant="body1" gutterBottom>
|
||||
Welcome back! Please enter your details.
|
||||
Welcome back! Please login.
|
||||
</Typography>
|
||||
{renderThirdPartyLoginMethods()}
|
||||
{Object.keys(authMethods).length > 1 && <Divider className={classes.divider}>or</Divider>}
|
||||
{Object.keys(authMethods).length > 1 &&
|
||||
Object.keys(authMethods).includes('openid') &&
|
||||
Object.keys(authMethods.openid.providers).length > 0 && (
|
||||
<Divider className={classes.divider} data-testid="openid-divider">
|
||||
or
|
||||
</Divider>
|
||||
)}
|
||||
{Object.keys(authMethods).includes('htpasswd') && (
|
||||
<Box component="form" onSubmit={null} noValidate autoComplete="off">
|
||||
<TextField
|
||||
@ -327,6 +361,7 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
onInput={(e) => handleChange(e, 'username')}
|
||||
error={usernameError != null}
|
||||
helperText={usernameError}
|
||||
onKeyDown={(e) => handleLoginInputFieldKeyDown(e)}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
@ -342,6 +377,7 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
onInput={(e) => handleChange(e, 'password')}
|
||||
error={passwordError != null}
|
||||
helperText={passwordError}
|
||||
onKeyDown={(e) => handleLoginInputFieldKeyDown(e)}
|
||||
/>
|
||||
{requestProcessing && <CircularProgress style={{ marginTop: 20 }} color="secondary" />}
|
||||
{requestError && (
|
||||
@ -350,7 +386,13 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
|
||||
</Alert>
|
||||
)}
|
||||
<div>
|
||||
<Button fullWidth variant="contained" className={classes.continueButton} onClick={handleClick}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
className={classes.continueButton}
|
||||
onClick={handleClick}
|
||||
data-testid="basic-auth-submit-btn"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</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 using zot UI, you agree to the Terms of Service. For more information about our privacy practices, see
|
||||
zot's Privacy Policy.
|
||||
</Typography>
|
||||
<Typography variant="caption" className={classes.text} align="center" {...props}>
|
||||
Privacy Policy | Terms of Service
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@ -80,14 +80,15 @@ function GitlabLoginButton({ handleClick }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DexLoginButton({ handleClick }) {
|
||||
function OIDCLoginButton({ handleClick, oidcName }) {
|
||||
const classes = useStyles();
|
||||
const loginWithName = oidcName || 'OIDC';
|
||||
|
||||
return (
|
||||
<Button fullWidth variant="contained" className={classes.button} onClick={(e) => handleClick(e, 'dex')}>
|
||||
Sign in with Dex
|
||||
<Button fullWidth variant="contained" className={classes.button} onClick={(e) => handleClick(e, 'oidc')}>
|
||||
Sign in with {loginWithName}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { GithubLoginButton, GoogleLoginButton, GitlabLoginButton, DexLoginButton };
|
||||
export { GithubLoginButton, GoogleLoginButton, GitlabLoginButton, OIDCLoginButton };
|
||||
|
@ -8,13 +8,22 @@ import { isEmpty, uniq } from 'lodash';
|
||||
// utility
|
||||
import { api, endpoints } from '../../api';
|
||||
import { host } from '../../host';
|
||||
import { useParams, useNavigate, createSearchParams } from 'react-router-dom';
|
||||
import { useParams, useNavigate, createSearchParams } from 'react-router';
|
||||
import { mapToRepoFromRepoInfo } from 'utilities/objectModels';
|
||||
import { isAuthenticated } from 'utilities/authUtilities';
|
||||
import filterConstants from 'utilities/filterConstants';
|
||||
|
||||
// components
|
||||
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 makeStyles from '@mui/styles/makeStyles';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import StarBorderIcon from '@mui/icons-material/StarBorder';
|
||||
import Tags from './Tabs/Tags.jsx';
|
||||
import RepoDetailsMetadata from './RepoDetailsMetadata';
|
||||
import Loading from '../Shared/Loading';
|
||||
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
|
||||
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
|
||||
// placeholder images
|
||||
import repocube1 from '../../assets/repocube-1.png';
|
||||
@ -22,13 +31,7 @@ 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 { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
import { mapToRepoFromRepoInfo } from 'utilities/objectModels';
|
||||
import { isAuthenticated } from 'utilities/authUtilities';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
pageWrapper: {
|
||||
@ -195,6 +198,10 @@ function RepoDetails() {
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
const handleDeleteTag = (removed) => {
|
||||
setTags((prevState) => prevState.filter((tag) => tag.tag !== removed));
|
||||
};
|
||||
|
||||
const handlePlatformChipClick = (event) => {
|
||||
const { textContent } = event.target;
|
||||
event.stopPropagation();
|
||||
@ -221,7 +228,7 @@ function RepoDetails() {
|
||||
|
||||
const handleBookmarkClick = () => {
|
||||
api.put(`${host()}${endpoints.bookmarkToggle(name)}`, abortController.signal).then((response) => {
|
||||
if (response.status === 200) {
|
||||
if (response && response.status === 200) {
|
||||
setRepoDetailData((prevState) => ({
|
||||
...prevState,
|
||||
isBookmarked: !prevState.isBookmarked
|
||||
@ -230,6 +237,17 @@ function RepoDetails() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleStarClick = () => {
|
||||
api.put(`${host()}${endpoints.starToggle(name)}`, abortController.signal).then((response) => {
|
||||
if (response.status === 200) {
|
||||
setRepoDetailData((prevState) => ({
|
||||
...prevState,
|
||||
isStarred: !prevState.isStarred
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getVendor = () => {
|
||||
return `${repoDetailData.newestTag?.Vendor || 'Vendor not available'} •`;
|
||||
};
|
||||
@ -243,6 +261,28 @@ function RepoDetails() {
|
||||
return lastDate;
|
||||
};
|
||||
|
||||
const getSignatureChips = () => {
|
||||
const cosign = repoDetailData.signatureInfo
|
||||
?.map((s) => s.tool)
|
||||
.includes(filterConstants.signatureToolConstants.COSIGN)
|
||||
? repoDetailData.signatureInfo.filter((si) => si.tool == filterConstants.signatureToolConstants.COSIGN)
|
||||
: null;
|
||||
const notation = repoDetailData.signatureInfo
|
||||
?.map((s) => s.tool)
|
||||
.includes(filterConstants.signatureToolConstants.NOTATION)
|
||||
? repoDetailData.signatureInfo.filter((si) => si.tool == filterConstants.signatureToolConstants.NOTATION)
|
||||
: null;
|
||||
const sigArray = [];
|
||||
if (cosign) sigArray.push(cosign);
|
||||
if (notation) sigArray.push(notation);
|
||||
if (sigArray.length === 0) return <SignatureIconCheck />;
|
||||
return sigArray.map((sig, index) => (
|
||||
<div className="hide-on-mobile" key={`${name}sig${index}`}>
|
||||
<SignatureIconCheck signatureInfo={sig} />
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
@ -271,17 +311,35 @@ function RepoDetails() {
|
||||
</Stack>
|
||||
<Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={2}>
|
||||
<VulnerabilityIconCheck vulnerabilitySeverity={repoDetailData?.vulnerabilitySeverity} />
|
||||
<SignatureIconCheck isSigned={repoDetailData.isSigned} />
|
||||
{getSignatureChips()}
|
||||
</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() && (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
sx={{ width: { xs: '100%', md: 'auto' } }}
|
||||
direction="row"
|
||||
spacing={2}
|
||||
>
|
||||
<IconButton component="span" onClick={handleBookmarkClick} data-testid="bookmark-button">
|
||||
{repoDetailData?.isBookmarked ? (
|
||||
<BookmarkIcon data-testid="bookmarked" />
|
||||
) : (
|
||||
<BookmarkBorderIcon data-testid="not-bookmarked" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
{isAuthenticated() && (
|
||||
<IconButton component="span" onClick={handleBookmarkClick} data-testid="bookmark-button">
|
||||
{repoDetailData?.isBookmarked ? (
|
||||
<BookmarkIcon data-testid="bookmarked" />
|
||||
) : (
|
||||
<BookmarkBorderIcon data-testid="not-bookmarked" />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography gutterBottom className={classes.repoTitle}>
|
||||
{repoDetailData?.title || 'Title not available'}
|
||||
@ -314,7 +372,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
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
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,9 +1,11 @@
|
||||
import React from 'react';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import transform from 'utilities/transform';
|
||||
|
||||
import { Card, CardContent, Typography, Grid, Divider, Stack, Collapse } from '@mui/material';
|
||||
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
|
||||
import transform from 'utilities/transform';
|
||||
import { useState } from 'react';
|
||||
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
card: {
|
||||
|
40
src/components/Shared/NoDataComponent.jsx
Normal file
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,7 +1,7 @@
|
||||
import { Card, CardActionArea, CardContent, CardMedia, Grid, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
// placeholder images
|
||||
import repocube1 from '../../assets/repocube-1.png';
|
||||
@ -10,7 +10,7 @@ import repocube3 from '../../assets/repocube-3.png';
|
||||
import repocube4 from '../../assets/repocube-4.png';
|
||||
|
||||
import { isEmpty } from 'lodash';
|
||||
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
import { VulnerabilityIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
|
||||
// temporary utility to get image
|
||||
const randomIntFromInterval = (min, max) => {
|
||||
@ -67,7 +67,7 @@ const useStyles = makeStyles(() => ({
|
||||
function PreviewCard(props) {
|
||||
const classes = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const { name, isSigned, vulnerabilityData, logo } = props;
|
||||
const { name, vulnerabilityData, logo } = props;
|
||||
|
||||
const goToDetails = () => {
|
||||
navigate(`/image/${encodeURIComponent(name)}`);
|
||||
@ -108,7 +108,6 @@ function PreviewCard(props) {
|
||||
</Tooltip>
|
||||
<Stack direction="row" spacing={0.5} sx={{ marginLeft: 'auto', marginRight: 0 }}>
|
||||
<VulnerabilityIconCheck {...vulnerabilityData} />
|
||||
<SignatureIconCheck isSigned={isSigned} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
@ -1,6 +1,6 @@
|
||||
// react global
|
||||
import React, { useRef, useMemo, useState } from 'react';
|
||||
import { useNavigate, createSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, createSearchParams } from 'react-router';
|
||||
|
||||
// utility
|
||||
import { DateTime } from 'luxon';
|
||||
@ -28,17 +28,20 @@ import {
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import StarBorderIcon from '@mui/icons-material/StarBorder';
|
||||
import { useTheme } from '@emotion/react';
|
||||
|
||||
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
|
||||
import filterConstants from 'utilities/filterConstants';
|
||||
|
||||
// placeholder images
|
||||
import repocube1 from '../../assets/repocube-1.png';
|
||||
import repocube2 from '../../assets/repocube-2.png';
|
||||
import repocube3 from '../../assets/repocube-3.png';
|
||||
import repocube4 from '../../assets/repocube-4.png';
|
||||
|
||||
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
|
||||
|
||||
// temporary utility to get image
|
||||
const randomIntFromInterval = (min, max) => {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
@ -183,16 +186,23 @@ function RepoCard(props) {
|
||||
platforms,
|
||||
description,
|
||||
downloads,
|
||||
isSigned,
|
||||
stars,
|
||||
signatureInfo,
|
||||
lastUpdated,
|
||||
version,
|
||||
vulnerabilityData,
|
||||
isBookmarked
|
||||
isBookmarked,
|
||||
isStarred
|
||||
} = props;
|
||||
|
||||
// keep a local bookmark state to display in the ui dynamically on updates
|
||||
const [currentBookmarkValue, setCurrentBookmarkValue] = useState(isBookmarked);
|
||||
|
||||
// keep a local star state to display in the ui dynamically on updates
|
||||
const [currentStarValue, setCurrentStarValue] = useState(isStarred);
|
||||
|
||||
const [currentStarCount, setCurrentStarCount] = useState(stars);
|
||||
|
||||
const goToDetails = () => {
|
||||
navigate(`/image/${encodeURIComponent(name)}`);
|
||||
};
|
||||
@ -214,6 +224,23 @@ function RepoCard(props) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleStarClick = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
api.put(`${host()}${endpoints.starToggle(name)}`, abortController.signal).then((response) => {
|
||||
if (response.status === 200) {
|
||||
setCurrentStarValue((prevState) => !prevState);
|
||||
currentStarValue
|
||||
? setCurrentStarCount((prevState) => {
|
||||
return !isNaN(prevState) ? prevState - 1 : prevState;
|
||||
})
|
||||
: setCurrentStarCount((prevState) => {
|
||||
return !isNaN(prevState) ? prevState + 1 : prevState;
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const platformChips = () => {
|
||||
const filteredPlatforms = uniq(platforms?.flatMap((platform) => [platform.Os, platform.Arch]));
|
||||
const hiddenChips = filteredPlatforms.length - MAX_PLATFORM_CHIPS;
|
||||
@ -259,6 +286,34 @@ function RepoCard(props) {
|
||||
);
|
||||
};
|
||||
|
||||
const renderStar = () => {
|
||||
return (
|
||||
isAuthenticated() && (
|
||||
<IconButton component="span" onClick={handleStarClick} data-testid="star-button">
|
||||
{currentStarValue ? <StarIcon data-testid="starred" /> : <StarBorderIcon data-testid="not-starred" />}
|
||||
</IconButton>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const getSignatureChips = () => {
|
||||
const cosign = signatureInfo?.map((s) => s.tool).includes(filterConstants.signatureToolConstants.COSIGN)
|
||||
? signatureInfo.filter((si) => si.tool == filterConstants.signatureToolConstants.COSIGN)
|
||||
: null;
|
||||
const notation = signatureInfo?.map((s) => s.tool).includes(filterConstants.signatureToolConstants.NOTATION)
|
||||
? signatureInfo.filter((si) => si.tool == filterConstants.signatureToolConstants.NOTATION)
|
||||
: null;
|
||||
const sigArray = [];
|
||||
if (cosign) sigArray.push(cosign);
|
||||
if (notation) sigArray.push(notation);
|
||||
if (sigArray.length === 0) return <SignatureIconCheck />;
|
||||
return sigArray.map((sig, index) => (
|
||||
<div className="hide-on-mobile" key={`${name}sig${index}`}>
|
||||
<SignatureIconCheck signatureInfo={sig} />
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined" className={classes.card} data-testid="repo-card">
|
||||
<CardActionArea
|
||||
@ -286,12 +341,10 @@ function RepoCard(props) {
|
||||
{name}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<div className="hide-on-mobile">
|
||||
<div className="hide-on-mobile" style={{ display: 'inline-flex' }}>
|
||||
<VulnerabilityIconCheck {...vulnerabilityData} className="hide-on-mobile" />
|
||||
</div>
|
||||
<div className="hide-on-mobile">
|
||||
<SignatureIconCheck isSigned={isSigned} className="hide-on-mobile" />
|
||||
</div>
|
||||
{getSignatureChips()}
|
||||
</Stack>
|
||||
<Tooltip title={description || 'Description not available'} placement="top">
|
||||
<Typography className={classes.description} pt={1} sx={{ fontSize: 12 }} gutterBottom noWrap>
|
||||
@ -336,6 +389,15 @@ function RepoCard(props) {
|
||||
#1
|
||||
</Typography>
|
||||
</Grid> */}
|
||||
<Grid item xs={12}>
|
||||
{renderStar()}
|
||||
<Typography variant="body2" component="span" className={classes.contentRightLabel}>
|
||||
Stars •
|
||||
</Typography>
|
||||
<Typography variant="body2" component="span" className={classes.contentRightValue}>
|
||||
{!isNaN(currentStarCount) ? currentStarCount : `not available`}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid container item xs={12} className={classes.contentRightActions}>
|
||||
<Grid item>{renderBookmark()}</Grid>
|
||||
</Grid>
|
||||
|
19
src/components/Shared/SignatureTooltip.jsx
Normal file
19
src/components/Shared/SignatureTooltip.jsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Typography, Stack } from '@mui/material';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { getStrongestSignature, getAllAuthorsOfSignatures } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
|
||||
function SignatureTooltip({ signatureInfo }) {
|
||||
const strongestSignature = useMemo(() => getStrongestSignature(signatureInfo));
|
||||
|
||||
return isEmpty(strongestSignature) ? (
|
||||
<Typography>Not signed</Typography>
|
||||
) : (
|
||||
<Stack direction="column">
|
||||
<Typography>Tool: {strongestSignature?.tool || 'Unknown'}</Typography>
|
||||
<Typography>Signed-by: {getAllAuthorsOfSignatures(signatureInfo) || 'Unknown'}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignatureTooltip;
|
@ -1,11 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Box, Card, CardContent, Collapse, Grid, Stack, Tooltip, Typography, Divider } from '@mui/material';
|
||||
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}
|
||||
@ -163,7 +167,7 @@ export default function TagCard(props) {
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="body1" color="primary">
|
||||
{el.platform?.Os}/{el.platform?.Arch}
|
||||
{el.platform?.Os || '----'}/{el.platform?.Arch || '----'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid
|
||||
|
@ -9,10 +9,11 @@ import { Box, Card, CardContent, Stack, Typography, Divider } from '@mui/materia
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import { host } from '../../host';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
|
||||
import { VulnerabilityChipCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
import { CVE_FIXEDIN_PAGE_SIZE } from 'utilities/paginationConstants';
|
||||
import VulnerabilityPackageSection from './VulnerabilityPackageSection';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
card: {
|
||||
@ -29,18 +30,46 @@ const useStyles = makeStyles((theme) => ({
|
||||
marginTop: '2rem',
|
||||
marginBottom: '2rem'
|
||||
},
|
||||
cardCollapsed: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
|
||||
border: '1px solid #E0E5EB',
|
||||
borderRadius: '0.75rem',
|
||||
flex: 'none',
|
||||
alignSelf: 'stretch',
|
||||
width: '100%'
|
||||
},
|
||||
content: {
|
||||
textAlign: 'left',
|
||||
color: '#606060',
|
||||
padding: '2% 3% 2% 3%',
|
||||
width: '100%'
|
||||
},
|
||||
contentCollapsed: {
|
||||
textAlign: 'left',
|
||||
color: '#606060',
|
||||
padding: '1% 3% 1% 3%',
|
||||
width: '100%',
|
||||
'&:last-child': {
|
||||
paddingBottom: '1%'
|
||||
}
|
||||
},
|
||||
cveId: {
|
||||
color: theme.palette.primary.main,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 400,
|
||||
textDecoration: 'underline'
|
||||
},
|
||||
cveIdCollapsed: {
|
||||
color: theme.palette.primary.main,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
textDecoration: 'underline',
|
||||
flexBasis: '19%'
|
||||
},
|
||||
cveSummary: {
|
||||
color: theme.palette.secondary.dark,
|
||||
fontSize: '0.75rem',
|
||||
@ -48,6 +77,13 @@ const useStyles = makeStyles((theme) => ({
|
||||
textOverflow: 'ellipsis',
|
||||
marginTop: '0.5rem'
|
||||
},
|
||||
cveSummaryCollapsed: {
|
||||
color: theme.palette.secondary.dark,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '600',
|
||||
textOverflow: 'ellipsis',
|
||||
flexBasis: '82%'
|
||||
},
|
||||
link: {
|
||||
color: '#52637A',
|
||||
fontSize: '1rem',
|
||||
@ -66,15 +102,21 @@ const useStyles = makeStyles((theme) => ({
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center'
|
||||
},
|
||||
dropdownCVE: {
|
||||
color: '#1479FF',
|
||||
cursor: 'pointer'
|
||||
},
|
||||
vulnerabilityCardDivider: {
|
||||
margin: '1rem 0'
|
||||
},
|
||||
cveInfo: {
|
||||
marginTop: '2%'
|
||||
}
|
||||
}));
|
||||
function VulnerabilitiyCard(props) {
|
||||
const classes = useStyles();
|
||||
const { cve, name } = props;
|
||||
const [openDesc, setOpenDesc] = useState(false);
|
||||
const [openFixed, setOpenFixed] = useState(false);
|
||||
const { cve, name, platform, expand } = props;
|
||||
const [openCVE, setOpenCVE] = useState(expand);
|
||||
const [loadingFixed, setLoadingFixed] = useState(true);
|
||||
const [fixedInfo, setFixedInfo] = useState([]);
|
||||
const abortController = useMemo(() => new AbortController(), []);
|
||||
@ -82,15 +124,21 @@ function VulnerabilitiyCard(props) {
|
||||
// pagination props
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [isEndOfList, setIsEndOfList] = useState(false);
|
||||
const [loadMoreInfo, setLoadMoreInfo] = useState(false);
|
||||
|
||||
const getPaginatedResults = () => {
|
||||
if (!openFixed || isEndOfList) {
|
||||
if (!openCVE || (!loadMoreInfo && !isEmpty(fixedInfo)) || isEndOfList) {
|
||||
return;
|
||||
}
|
||||
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) => {
|
||||
@ -102,11 +150,13 @@ function VulnerabilitiyCard(props) {
|
||||
);
|
||||
}
|
||||
setLoadingFixed(false);
|
||||
setLoadMoreInfo(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setIsEndOfList(true);
|
||||
setLoadingFixed(false);
|
||||
setLoadMoreInfo(false);
|
||||
});
|
||||
};
|
||||
|
||||
@ -115,10 +165,15 @@ function VulnerabilitiyCard(props) {
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [openFixed, pageNumber]);
|
||||
}, [openCVE, pageNumber, loadMoreInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenCVE(expand);
|
||||
}, [expand]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (loadingFixed || isEndOfList) return;
|
||||
setLoadMoreInfo(true);
|
||||
setPageNumber((pageNumber) => pageNumber + 1);
|
||||
};
|
||||
|
||||
@ -158,27 +213,65 @@ function VulnerabilitiyCard(props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={classes.card} raised>
|
||||
<CardContent className={classes.content}>
|
||||
<Stack direction="row" spacing="1.25rem">
|
||||
<Typography variant="body1" align="left" className={classes.cveId}>
|
||||
<Card className={openCVE ? classes.card : classes.cardCollapsed} raised>
|
||||
<CardContent className={openCVE ? classes.content : classes.contentCollapsed}>
|
||||
<Stack direction="row" spacing={openCVE ? '1.25rem' : '0.5rem'}>
|
||||
{!openCVE ? (
|
||||
<KeyboardArrowRight className={classes.dropdownCVE} onClick={() => setOpenCVE(!openCVE)} />
|
||||
) : (
|
||||
<KeyboardArrowDown className={classes.dropdownCVE} onClick={() => setOpenCVE(!openCVE)} />
|
||||
)}
|
||||
<Typography variant="body1" align="left" className={openCVE ? classes.cveId : classes.cveIdCollapsed}>
|
||||
{cve.id}
|
||||
</Typography>
|
||||
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
|
||||
</Stack>
|
||||
<Typography variant="body1" align="left" className={classes.cveSummary}>
|
||||
{cve.title}
|
||||
</Typography>
|
||||
<Divider className={classes.vulnerabilityCardDivider} />
|
||||
<Stack className={classes.dropdown} onClick={() => setOpenFixed(!openFixed)}>
|
||||
{!openFixed ? (
|
||||
<KeyboardArrowRight className={classes.dropdownText} />
|
||||
{openCVE ? (
|
||||
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
|
||||
) : (
|
||||
<KeyboardArrowDown className={classes.dropdownText} />
|
||||
<Stack direction="row" spacing="0.5rem" flexBasis="90%">
|
||||
<div style={{ transform: 'scale(0.8)', flexBasis: '18%', flexShrink: '0' }}>
|
||||
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
|
||||
</div>
|
||||
<Typography variant="body1" align="left" className={classes.cveSummaryCollapsed}>
|
||||
{cve.title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
<Typography className={classes.dropdownText}>Fixed in</Typography>
|
||||
</Stack>
|
||||
<Collapse in={openFixed} timeout="auto" unmountOnExit>
|
||||
<Collapse in={openCVE} timeout="auto" unmountOnExit>
|
||||
<Typography variant="body1" align="left" className={classes.cveSummary}>
|
||||
{cve.title}
|
||||
</Typography>
|
||||
<Divider className={classes.vulnerabilityCardDivider} />
|
||||
<Typography variant="body2" align="left" className={classes.cveInfo}>
|
||||
External reference
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
align="left"
|
||||
sx={{ color: '#0F2139', fontSize: '1rem', textDecoration: 'underline' }}
|
||||
component={Link}
|
||||
to={cve.reference}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{cve.reference}
|
||||
</Typography>
|
||||
<Typography variant="body2" align="left" className={classes.cveInfo}>
|
||||
Packages
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing="0.3rem"
|
||||
sx={{ width: '100%', padding: '0.5rem 0' }}
|
||||
data-testid="cve-package-list"
|
||||
>
|
||||
{cve.packageList.map((pkg) => (
|
||||
<VulnerabilityPackageSection key={`${cve.id}-${pkg.packageName}`} cve={pkg} />
|
||||
))}
|
||||
</Stack>
|
||||
<Typography variant="body2" align="left" className={classes.cveInfo}>
|
||||
Fixed in
|
||||
</Typography>
|
||||
<Box sx={{ width: '100%', padding: '0.5rem 0' }}>
|
||||
{loadingFixed ? (
|
||||
'Loading...'
|
||||
@ -189,16 +282,9 @@ function VulnerabilitiyCard(props) {
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
<Stack className={classes.dropdown} onClick={() => setOpenDesc(!openDesc)}>
|
||||
{!openDesc ? (
|
||||
<KeyboardArrowRight className={classes.dropdownText} />
|
||||
) : (
|
||||
<KeyboardArrowDown className={classes.dropdownText} />
|
||||
)}
|
||||
<Typography className={classes.dropdownText}>Description</Typography>
|
||||
</Stack>
|
||||
<Collapse in={openDesc} timeout="auto" unmountOnExit>
|
||||
<Typography variant="body2" align="left" className={classes.cveInfo}>
|
||||
Description
|
||||
</Typography>
|
||||
<Box sx={{ padding: '0.5rem 0' }}>
|
||||
<Typography variant="body2" align="left" sx={{ color: '#0F2139', fontSize: '1rem' }}>
|
||||
{cve.description}
|
||||
|
100
src/components/Shared/VulnerabilityCountCard.jsx
Normal file
100
src/components/Shared/VulnerabilityCountCard.jsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import { Stack, Tooltip } from '@mui/material';
|
||||
|
||||
const criticalColor = '#ff5c74';
|
||||
const criticalBorderColor = '#f9546d';
|
||||
|
||||
const highColor = '#ff6840';
|
||||
const highBorderColor = '#ee6b49';
|
||||
|
||||
const mediumColor = '#ffa052';
|
||||
const mediumBorderColor = '#f19d5b';
|
||||
|
||||
const lowColor = '#f9f486';
|
||||
const lowBorderColor = '#f0ed94';
|
||||
|
||||
const unknownColor = '#f2ffdd';
|
||||
const unknownBorderColor = '#e9f4d7';
|
||||
|
||||
const totalBorderColor = '#e0e5eb';
|
||||
|
||||
const fontSize = '0.75rem';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
cveCountCard: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '0.5rem',
|
||||
paddingRight: '0.5rem',
|
||||
color: theme.palette.primary.main,
|
||||
fontSize: fontSize,
|
||||
fontWeight: '600',
|
||||
borderRadius: '3px',
|
||||
marginBottom: '0',
|
||||
cursor: 'pointer'
|
||||
},
|
||||
totalSeverity: {
|
||||
border: '1px solid ' + totalBorderColor
|
||||
},
|
||||
severityList: {
|
||||
fontSize: fontSize,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '0.5em'
|
||||
},
|
||||
criticalSeverity: {
|
||||
backgroundColor: criticalColor,
|
||||
border: '1px solid ' + criticalBorderColor
|
||||
},
|
||||
highSeverity: {
|
||||
backgroundColor: highColor,
|
||||
border: '1px solid ' + highBorderColor
|
||||
},
|
||||
mediumSeverity: {
|
||||
backgroundColor: mediumColor,
|
||||
border: '1px solid ' + mediumBorderColor
|
||||
},
|
||||
lowSeverity: {
|
||||
backgroundColor: lowColor,
|
||||
border: '1px solid ' + lowBorderColor
|
||||
},
|
||||
unknownSeverity: {
|
||||
backgroundColor: unknownColor,
|
||||
border: '1px solid ' + unknownBorderColor
|
||||
}
|
||||
}));
|
||||
|
||||
function VulnerabilitiyCountCard(props) {
|
||||
const classes = useStyles();
|
||||
const { total, critical, high, medium, low, unknown, filterBySeverity } = props;
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing="0.5em">
|
||||
<Tooltip title="Total" onClick={() => filterBySeverity('')}>
|
||||
<div className={[classes.cveCountCard, classes.totalSeverity].join(' ')}>Total {total}</div>
|
||||
</Tooltip>
|
||||
<div className={classes.severityList}>
|
||||
<Tooltip title="Critical" onClick={() => filterBySeverity('CRITICAL')}>
|
||||
<div className={[classes.cveCountCard, classes.criticalSeverity].join(' ')}>C {critical}</div>
|
||||
</Tooltip>
|
||||
<Tooltip title="High" onClick={() => filterBySeverity('HIGH')}>
|
||||
<div className={[classes.cveCountCard, classes.highSeverity].join(' ')}>H {high}</div>
|
||||
</Tooltip>
|
||||
<Tooltip title="Medium" onClick={() => filterBySeverity('MEDIUM')}>
|
||||
<div className={[classes.cveCountCard, classes.mediumSeverity].join(' ')}>M {medium}</div>
|
||||
</Tooltip>
|
||||
<Tooltip title="Low" onClick={() => filterBySeverity('LOW')}>
|
||||
<div className={[classes.cveCountCard, classes.lowSeverity].join(' ')}>L {low}</div>
|
||||
</Tooltip>
|
||||
<Tooltip title="Unknown" onClick={() => filterBySeverity('UNKNOWN')}>
|
||||
<div className={[classes.cveCountCard, classes.unknownSeverity].join(' ')}>U {unknown}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default VulnerabilitiyCountCard;
|
69
src/components/Shared/VulnerabilityPackageSection.jsx
Normal file
69
src/components/Shared/VulnerabilityPackageSection.jsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { Divider, Grid, Stack, Typography } from '@mui/material';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
cvePackageCard: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
|
||||
border: '1px solid #E0E5EB',
|
||||
borderRadius: '0.75rem',
|
||||
flex: 'none',
|
||||
alignSelf: 'stretch',
|
||||
width: '100%'
|
||||
},
|
||||
cveInfo: {
|
||||
marginTop: '2%'
|
||||
},
|
||||
vulnerabilityCardDivider: {
|
||||
margin: '1rem 1rem'
|
||||
}
|
||||
}));
|
||||
|
||||
function VulnerabilityPackageSection(props) {
|
||||
const { cve } = props;
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing="0.2rem"
|
||||
sx={{ width: '100%', padding: '0.2rem 0.5rem' }}
|
||||
data-testid="cve-package-section"
|
||||
>
|
||||
<Typography variant="overline" color="primary" data-testid="cve-info-pkg-name" sx={{ fontWeight: 'bold' }}>
|
||||
{cve.packageName}
|
||||
</Typography>
|
||||
<Typography variant="body2" className={classes.cveInfo}>
|
||||
Package Path
|
||||
</Typography>
|
||||
<Typography variant="body1" color="primary" data-testid="cve-info-pkg-path">
|
||||
{cve.packagePath}
|
||||
</Typography>
|
||||
<Grid container>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" className={classes.cveInfo}>
|
||||
Installed Version
|
||||
</Typography>
|
||||
<Typography variant="body1" color="primary" data-testid="cve-info-pkg-install-ver">
|
||||
{cve.packageInstalledVersion}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" className={classes.cveInfo}>
|
||||
Fixed Version
|
||||
</Typography>
|
||||
<Typography variant="body1" color="primary" data-testid="cve-info-pkg-fixed-ver">
|
||||
{cve.packageFixedVersion}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Divider className={classes.vulnerabilityCardDivider} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default VulnerabilityPackageSection;
|
@ -107,7 +107,7 @@ function DependsOn(props) {
|
||||
repoName={dependence.repoName}
|
||||
tag={dependence.tag}
|
||||
vendor={dependence.vendor}
|
||||
isSigned={dependence.isSigned}
|
||||
signatureInfo={dependence.signatureInfo}
|
||||
manifests={dependence.manifests}
|
||||
key={index}
|
||||
lastUpdated={dependence.lastUpdated}
|
||||
|
@ -107,7 +107,7 @@ function IsDependentOn(props) {
|
||||
repoName={dependence.repoName}
|
||||
tag={dependence.tag}
|
||||
vendor={dependence.vendor}
|
||||
isSigned={dependence.isSigned}
|
||||
signatureInfo={dependence.signatureInfo}
|
||||
manifests={dependence.manifests}
|
||||
key={index}
|
||||
lastUpdated={dependence.lastUpdated}
|
||||
|
@ -4,24 +4,55 @@ 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,
|
||||
ToggleButton,
|
||||
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 ViewHeadlineIcon from '@mui/icons-material/ViewHeadline';
|
||||
import ViewAgendaIcon from '@mui/icons-material/ViewAgenda';
|
||||
|
||||
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
|
||||
import VulnerabilitiyCard from '../../Shared/VulnerabilityCard';
|
||||
import VulnerabilityCountCard from '../../Shared/VulnerabilityCountCard';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
searchAndDisplayBar: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
title: {
|
||||
color: theme.palette.primary.main,
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0'
|
||||
},
|
||||
cveCountSummary: {
|
||||
color: theme.palette.primary.main,
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0'
|
||||
},
|
||||
cveId: {
|
||||
color: theme.palette.primary.main,
|
||||
fontSize: '1rem',
|
||||
@ -40,9 +71,17 @@ 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%',
|
||||
flex: 0.95,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
@ -50,6 +89,20 @@ const useStyles = makeStyles((theme) => ({
|
||||
border: '0.063rem solid #E7E7E7',
|
||||
borderRadius: '0.625rem'
|
||||
},
|
||||
expandableSearchInput: {
|
||||
flexGrow: 0.95
|
||||
},
|
||||
view: {
|
||||
alignContent: 'right',
|
||||
variant: 'outlined'
|
||||
},
|
||||
viewModes: {
|
||||
position: 'relative',
|
||||
alignItems: 'baseline',
|
||||
maxWidth: '100%',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'right'
|
||||
},
|
||||
searchIcon: {
|
||||
color: '#52637A',
|
||||
paddingRight: '3%'
|
||||
@ -65,38 +118,92 @@ const useStyles = makeStyles((theme) => ({
|
||||
'&::placeholder': {
|
||||
opacity: '1'
|
||||
}
|
||||
},
|
||||
popper: {
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
padding: '0.3rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'left'
|
||||
},
|
||||
dropdownArrowBox: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
dropdownText: {
|
||||
color: '#1479FF',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center'
|
||||
},
|
||||
test: {
|
||||
width: '95%'
|
||||
}
|
||||
}));
|
||||
|
||||
function VulnerabilitiesDetails(props) {
|
||||
const classes = useStyles();
|
||||
const [cveData, setCveData] = useState([]);
|
||||
const [allCveData, setAllCveData] = useState([]);
|
||||
const [cveSummary, setCVESummary] = 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;
|
||||
|
||||
const [openExcludeSearch, setOpenExcludeSearch] = useState(false);
|
||||
|
||||
// pagination props
|
||||
const [cveFilter, setCveFilter] = useState('');
|
||||
const [cveExcludeFilter, setCveExcludeFilter] = useState('');
|
||||
const [cveSeverityFilter, setCveSeverityFilter] = useState('');
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [isEndOfList, setIsEndOfList] = useState(false);
|
||||
const listBottom = useRef(null);
|
||||
|
||||
const [anchorExport, setAnchorExport] = useState(null);
|
||||
const openExport = Boolean(anchorExport);
|
||||
|
||||
const [selectedViewMore, setSelectedViewMore] = useState(false);
|
||||
|
||||
const getCVERequestName = () => {
|
||||
return digest !== '' ? `${name}@${digest}` : `${name}:${tag}`;
|
||||
};
|
||||
|
||||
const getPaginatedCVEs = () => {
|
||||
api
|
||||
.get(
|
||||
`${host()}${endpoints.vulnerabilitiesForRepo(
|
||||
`${name}:${tag}`,
|
||||
getCVERequestName(),
|
||||
{ pageNumber, pageSize: EXPLORE_PAGE_SIZE },
|
||||
cveFilter
|
||||
cveFilter,
|
||||
cveExcludeFilter,
|
||||
cveSeverityFilter
|
||||
)}`,
|
||||
abortController.signal
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.data && response.data.data) {
|
||||
let cveInfo = response.data.data.CVEListForImage?.CVEList;
|
||||
let summary = response.data.data.CVEListForImage?.Summary;
|
||||
let cveListData = mapCVEInfo(cveInfo);
|
||||
setCveData((previousState) => (pageNumber === 1 ? cveListData : [...previousState, ...cveListData]));
|
||||
setIsEndOfList(response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE);
|
||||
setCVESummary((previousState) => {
|
||||
if (isEmpty(summary)) {
|
||||
return previousState;
|
||||
}
|
||||
return {
|
||||
Count: summary.Count,
|
||||
UnknownCount: summary.UnknownCount,
|
||||
LowCount: summary.LowCount,
|
||||
MediumCount: summary.MediumCount,
|
||||
HighCount: summary.HighCount,
|
||||
CriticalCount: summary.CriticalCount
|
||||
};
|
||||
});
|
||||
} else if (response.data.errors) {
|
||||
setIsEndOfList(true);
|
||||
}
|
||||
@ -106,10 +213,29 @@ function VulnerabilitiesDetails(props) {
|
||||
console.error(e);
|
||||
setIsLoading(false);
|
||||
setCveData([]);
|
||||
setCVESummary(() => {});
|
||||
setIsEndOfList(true);
|
||||
});
|
||||
};
|
||||
|
||||
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,12 +246,50 @@ 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, 'vulnerabilities');
|
||||
|
||||
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 handleExpandCVESearch = () => {
|
||||
setOpenExcludeSearch((openExcludeSearch) => !openExcludeSearch);
|
||||
};
|
||||
|
||||
const handleCveExcludeFilterChange = (e) => {
|
||||
const { value } = e.target;
|
||||
setCveExcludeFilter(value);
|
||||
};
|
||||
|
||||
const debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300));
|
||||
const debouncedExcludeFilterChangeHandler = useMemo(() => debounce(handleCveExcludeFilterChange, 300));
|
||||
|
||||
useEffect(() => {
|
||||
getPaginatedCVEs();
|
||||
@ -159,25 +323,52 @@ function VulnerabilitiesDetails(props) {
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
resetPagination();
|
||||
}, [cveFilter]);
|
||||
}, [cveFilter, cveExcludeFilter, cveSeverityFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortController.abort();
|
||||
debouncedChangeHandler.cancel();
|
||||
debouncedExcludeFilterChangeHandler.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
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} expand={selectedViewMore} />;
|
||||
})
|
||||
) : (
|
||||
<div>{!isLoading && <Typography className={classes.none}> No Vulnerabilities </Typography>}</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCVESummary = () => {
|
||||
if (cveSummary === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
return !isEmpty(cveSummary) ? (
|
||||
<VulnerabilityCountCard
|
||||
total={cveSummary.Count}
|
||||
critical={cveSummary.CriticalCount}
|
||||
high={cveSummary.HighCount}
|
||||
medium={cveSummary.MediumCount}
|
||||
low={cveSummary.LowCount}
|
||||
unknown={cveSummary.UnknownCount}
|
||||
filterBySeverity={setCveSeverityFilter}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
const renderListBottom = () => {
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
@ -190,21 +381,112 @@ 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.search}>
|
||||
<InputBase
|
||||
placeholder={'Search'}
|
||||
classes={{ root: classes.searchInputBase, input: classes.input }}
|
||||
onChange={debouncedChangeHandler}
|
||||
/>
|
||||
<div className={classes.searchIcon}>
|
||||
<SearchIcon />
|
||||
</div>
|
||||
<Stack className={classes.vulnerabilities}>
|
||||
<Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}>
|
||||
Vulnerabilities
|
||||
</Typography>
|
||||
<Stack direction="row" spacing="1rem" className={classes.viewModes}>
|
||||
<IconButton disableRipple onClick={handleClickExport}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
<Snackbar
|
||||
open={openExport && isLoadingAllCve}
|
||||
message="Getting your data ready for export"
|
||||
action={<CircularProgress size="2rem" sx={{ color: '#FFFFFF' }} />}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="viewLess"
|
||||
title="Collapse list view"
|
||||
size="small"
|
||||
className={classes.view}
|
||||
selected={!selectedViewMore}
|
||||
onChange={() => setSelectedViewMore(false)}
|
||||
>
|
||||
<ViewHeadlineIcon />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
value="viewMore"
|
||||
title="Expand list view"
|
||||
size="small"
|
||||
className={classes.view}
|
||||
selected={selectedViewMore}
|
||||
onChange={() => setSelectedViewMore(true)}
|
||||
data-testid="expand-list-view-toggle"
|
||||
>
|
||||
<ViewAgendaIcon />
|
||||
</ToggleButton>
|
||||
</Stack>
|
||||
<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"
|
||||
>
|
||||
xlsx
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Stack>
|
||||
{renderCVESummary()}
|
||||
<Stack direction="row">
|
||||
<div className={classes.dropdownArrowBox} onClick={handleExpandCVESearch}>
|
||||
{!openExcludeSearch ? (
|
||||
<KeyboardArrowRight className={classes.dropdownText} />
|
||||
) : (
|
||||
<KeyboardArrowDown className={classes.dropdownText} />
|
||||
)}
|
||||
</div>
|
||||
<Stack className={classes.test} direction="column" spacing="0.25em">
|
||||
<Stack className={classes.search}>
|
||||
<InputBase
|
||||
placeholder={'Search'}
|
||||
classes={{ root: classes.searchInputBase, input: classes.input }}
|
||||
onChange={debouncedChangeHandler}
|
||||
/>
|
||||
<div className={classes.searchIcon}>
|
||||
<SearchIcon />
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={openExcludeSearch} timeout="auto" unmountOnExit>
|
||||
<Stack className={classes.search}>
|
||||
<InputBase
|
||||
placeholder={'Exclude'}
|
||||
classes={{ root: classes.searchInputBase, input: classes.input }}
|
||||
onChange={debouncedExcludeFilterChangeHandler}
|
||||
/>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack direction="column" spacing={selectedViewMore ? '1rem' : '0.5rem'}>
|
||||
{renderCVEs()}
|
||||
{renderListBottom()}
|
||||
</Stack>
|
||||
{renderCVEs()}
|
||||
{renderListBottom()}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -1,9 +1,13 @@
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router';
|
||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||
|
||||
// utility
|
||||
import { api, endpoints } from '../../api';
|
||||
import { host } from '../../host';
|
||||
import { mapToImage } from '../../utilities/objectModels';
|
||||
import filterConstants from 'utilities/filterConstants';
|
||||
import { isEmpty, head, uniqBy } from 'lodash';
|
||||
|
||||
// components
|
||||
import {
|
||||
Card,
|
||||
@ -19,23 +23,21 @@ import {
|
||||
Typography,
|
||||
InputLabel
|
||||
} from '@mui/material';
|
||||
import TagDetailsMetadata from './TagDetailsMetadata';
|
||||
import VulnerabilitiesDetails from './Tabs/VulnerabilitiesDetails';
|
||||
import HistoryLayers from './Tabs/HistoryLayers';
|
||||
import DependsOn from './Tabs/DependsOn';
|
||||
import IsDependentOn from './Tabs/IsDependentOn';
|
||||
import Loading from '../Shared/Loading';
|
||||
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
import ReferredBy from './Tabs/ReferredBy';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import { host } from '../../host';
|
||||
|
||||
// placeholder images
|
||||
import repocube1 from '../../assets/repocube-1.png';
|
||||
import repocube2 from '../../assets/repocube-2.png';
|
||||
import repocube3 from '../../assets/repocube-3.png';
|
||||
import repocube4 from '../../assets/repocube-4.png';
|
||||
import TagDetailsMetadata from './TagDetailsMetadata';
|
||||
import VulnerabilitiesDetails from './Tabs/VulnerabilitiesDetails';
|
||||
import HistoryLayers from './Tabs/HistoryLayers';
|
||||
import DependsOn from './Tabs/DependsOn';
|
||||
import IsDependentOn from './Tabs/IsDependentOn';
|
||||
import { isEmpty, head } from 'lodash';
|
||||
import Loading from '../Shared/Loading';
|
||||
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
import ReferredBy from './Tabs/ReferredBy';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
pageWrapper: {
|
||||
@ -59,7 +61,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',
|
||||
@ -190,7 +191,7 @@ function TagDetails() {
|
||||
}, [reponame, tag]);
|
||||
|
||||
const getPlatform = () => {
|
||||
return selectedManifest.platform ? selectedManifest.platform : '--/--';
|
||||
return selectedManifest?.platform ? selectedManifest.platform : '--/--';
|
||||
};
|
||||
|
||||
const handleTabChange = (event, newValue) => {
|
||||
@ -205,18 +206,52 @@ function TagDetails() {
|
||||
const renderTabContent = () => {
|
||||
switch (selectedTab) {
|
||||
case 'DependsOn':
|
||||
return <DependsOn name={imageDetailData.name} digest={selectedManifest.digest} />;
|
||||
return <DependsOn name={imageDetailData?.name} digest={selectedManifest?.digest} />;
|
||||
case 'IsDependentOn':
|
||||
return <IsDependentOn name={imageDetailData.name} digest={selectedManifest.digest} />;
|
||||
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} />;
|
||||
const allReferrers = uniqBy(
|
||||
[...(selectedManifest?.referrers || []), ...(imageDetailData?.referrers || [])],
|
||||
'digest'
|
||||
);
|
||||
|
||||
return <ReferredBy referrers={allReferrers} />;
|
||||
default:
|
||||
return <HistoryLayers name={imageDetailData.name} history={selectedManifest.history} />;
|
||||
return <HistoryLayers name={imageDetailData?.name} history={selectedManifest?.history || []} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSignatureChips = () => {
|
||||
const cosign = imageDetailData?.signatureInfo
|
||||
?.map((s) => s.tool)
|
||||
?.includes(filterConstants.signatureToolConstants.COSIGN)
|
||||
? imageDetailData?.signatureInfo?.filter((si) => si.tool == filterConstants.signatureToolConstants.COSIGN)
|
||||
: null;
|
||||
const notation = imageDetailData?.signatureInfo
|
||||
?.map((s) => s.tool)
|
||||
?.includes(filterConstants.signatureToolConstants.NOTATION)
|
||||
? imageDetailData?.signatureInfo?.filter((si) => si.tool == filterConstants.signatureToolConstants.NOTATION)
|
||||
: null;
|
||||
const sigArray = [];
|
||||
if (cosign) sigArray.push(cosign);
|
||||
if (notation) sigArray.push(notation);
|
||||
if (sigArray.length === 0) return <SignatureIconCheck />;
|
||||
return sigArray.map((sig, index) => (
|
||||
<div className="hide-on-mobile" key={`${imageDetailData?.name || ''}sig${index}`}>
|
||||
<SignatureIconCheck signatureInfo={sig} />
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
@ -227,10 +262,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}
|
||||
>
|
||||
@ -249,15 +284,16 @@ function TagDetails() {
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={1}>
|
||||
<Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={2}>
|
||||
<VulnerabilityIconCheck
|
||||
vulnerabilitySeverity={imageDetailData.vulnerabiltySeverity}
|
||||
count={imageDetailData.vulnerabilityCount}
|
||||
vulnerabilitySeverity={imageDetailData?.vulnerabiltySeverity}
|
||||
count={imageDetailData?.vulnerabilityCount}
|
||||
/>
|
||||
<SignatureIconCheck isSigned={imageDetailData.isSigned} />
|
||||
{getSignatureChips()}
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ width: { xs: '100%', md: 'auto' } }}>
|
||||
</Stack>
|
||||
{imageDetailData?.manifests && imageDetailData.manifests.length > 0 && (
|
||||
<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) && (
|
||||
@ -267,19 +303,19 @@ function TagDetails() {
|
||||
onChange={handleOSArchChange}
|
||||
MenuProps={{ disableScrollLock: true }}
|
||||
>
|
||||
{imageDetailData.manifests.map((el) => (
|
||||
{imageDetailData?.manifests?.map((el) => (
|
||||
<MenuItem key={el.digest} value={el}>
|
||||
{`${el.platform?.Os}/${el.platform?.Arch}`}
|
||||
{`${el.platform?.Os || '----'}/${el.platform?.Arch || '----'}`}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</FormControl>
|
||||
<Typography gutterBottom className={classes.digest}>
|
||||
Digest: {selectedManifest?.digest}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Typography gutterBottom className={classes.digest}>
|
||||
Digest: {selectedManifest?.digest}
|
||||
</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
@ -317,7 +353,12 @@ function TagDetails() {
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Card className={classes.cardRoot}>
|
||||
<CardContent className={classes.tabCardContent}>{renderTabContent()}</CardContent>
|
||||
<CardContent
|
||||
key={`card_content_manifest_key_${selectedManifest?.digest}`}
|
||||
className={classes.tabCardContent}
|
||||
>
|
||||
{renderTabContent()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4} className={classes.metadata}>
|
||||
|
@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Card, CardContent, Grid, Typography, Tooltip } from '@mui/material';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import transform from '../../utilities/transform';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
|
||||
import transform from '../../utilities/transform';
|
||||
|
||||
import { Card, CardContent, Grid, Typography, Tooltip } from '@mui/material';
|
||||
import PullCommandButton from 'components/Shared/PullCommandButton';
|
||||
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
card: {
|
||||
display: 'flex',
|
||||
|
163
src/components/User/ApiKeys/ApiKeyCard.jsx
Normal file
163
src/components/User/ApiKeys/ApiKeyCard.jsx
Normal file
@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { Card, CardContent, Typography, Grid, Divider, Stack, Collapse, Button } from '@mui/material';
|
||||
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
|
||||
import ApiKeyRevokeDialog from './ApiKeyRevokeDialog';
|
||||
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
card: {
|
||||
marginBottom: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E0E5EB',
|
||||
borderRadius: '0.75rem',
|
||||
alignSelf: 'stretch',
|
||||
flexGrow: 0,
|
||||
order: 0,
|
||||
width: '100%'
|
||||
},
|
||||
content: {
|
||||
textAlign: 'left',
|
||||
color: '#52637A',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: '1rem',
|
||||
backgroundColor: '#FFFFFF',
|
||||
'&:hover': {
|
||||
backgroundColor: '#FFFFFF'
|
||||
},
|
||||
'&:last-child': {
|
||||
paddingBottom: '1rem'
|
||||
}
|
||||
},
|
||||
label: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: '400',
|
||||
paddingRight: '0.5rem',
|
||||
paddingBottom: '0.5rem',
|
||||
paddingTop: '0.5rem',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
cursor: 'pointer'
|
||||
},
|
||||
expirationDate: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: '400',
|
||||
paddingBottom: '0.5rem',
|
||||
paddingTop: '0.5rem',
|
||||
textAlign: 'right'
|
||||
},
|
||||
revokeButton: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'right'
|
||||
},
|
||||
dropdownText: {
|
||||
color: '#1479FF',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center'
|
||||
},
|
||||
dropdownButton: {
|
||||
color: '#1479FF',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer'
|
||||
},
|
||||
dropdownContentBox: {
|
||||
boxSizing: 'border-box',
|
||||
color: '#52637A',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '400',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: '#F7F7F7',
|
||||
borderRadius: '0.9rem',
|
||||
overflowWrap: 'break-word'
|
||||
},
|
||||
keyCardDivider: {
|
||||
margin: '1rem 0'
|
||||
}
|
||||
}));
|
||||
|
||||
function ApiKeyCard(props) {
|
||||
const classes = useStyles();
|
||||
const { apiKey, onRevoke } = props;
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const [apiKeyRevokeOpen, setApiKeyRevokeOpen] = useState(false);
|
||||
|
||||
const getExpirationDisplay = () => {
|
||||
const expDateTime = DateTime.fromISO(apiKey.expirationDate);
|
||||
return `Expires on ${expDateTime.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)}`;
|
||||
};
|
||||
|
||||
const handleApiKeyRevokeDialogOpen = () => {
|
||||
setApiKeyRevokeOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined" className={classes.card}>
|
||||
<CardContent className={classes.content}>
|
||||
<Grid container alignItems="center" justifyContent="space-between">
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body1" className={classes.label}>
|
||||
{apiKey.label}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="body1" className={classes.expirationDate}>
|
||||
{getExpirationDisplay()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={2} className={classes.revokeButton}>
|
||||
<Button color="error" variant="contained" onClick={handleApiKeyRevokeDialogOpen}>
|
||||
Revoke
|
||||
</Button>
|
||||
</Grid>
|
||||
{!isNil(apiKey.apiKey) && (
|
||||
<>
|
||||
<Grid item xs={12}>
|
||||
<Divider className={classes.keyCardDivider} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack direction="row" onClick={() => setOpenDropdown((prevOpenState) => !prevOpenState)}>
|
||||
{!openDropdown ? (
|
||||
<KeyboardArrowRight className={classes.dropdownText} />
|
||||
) : (
|
||||
<KeyboardArrowDown className={classes.dropdownText} />
|
||||
)}
|
||||
<Typography className={classes.dropdownButton}>KEY</Typography>
|
||||
</Stack>
|
||||
<Collapse in={openDropdown} timeout="auto" unmountOnExit sx={{ marginTop: '1rem' }}>
|
||||
<Stack direction="column" spacing="1.2rem">
|
||||
<Typography variant="body1" align="left" className={classes.dropdownContentBox}>
|
||||
{apiKey.apiKey}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
<ApiKeyRevokeDialog
|
||||
open={apiKeyRevokeOpen}
|
||||
setOpen={setApiKeyRevokeOpen}
|
||||
apiKey={apiKey}
|
||||
onConfirm={onRevoke}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeyCard;
|
57
src/components/User/ApiKeys/ApiKeyConfirmDialog.jsx
Normal file
57
src/components/User/ApiKeys/ApiKeyConfirmDialog.jsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle, DialogActions, Button, Typography, Grid } from '@mui/material';
|
||||
|
||||
import { makeStyles } from '@mui/styles';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
gridWrapper: {
|
||||
paddingTop: '2rem',
|
||||
paddingBottom: '2rem'
|
||||
},
|
||||
apiKeyDisplay: {
|
||||
boxSizing: 'border-box',
|
||||
color: '#52637A',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '400',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: '#F7F7F7',
|
||||
borderRadius: '0.9rem',
|
||||
overflowWrap: 'break-word'
|
||||
}
|
||||
}));
|
||||
|
||||
function ApiKeyConfirmDialog(props) {
|
||||
const { open, setOpen, apiKey } = props;
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>Api Key "{apiKey?.label}" Created</DialogTitle>
|
||||
<DialogContent className={classes.apiKeyForm}>
|
||||
<Grid container className={classes.gridWrapper}>
|
||||
<Grid item xs={12}>
|
||||
<Typography>Please copy the api key, you will not be able to see it once the page is refreshed</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body1" align="center" className={classes.apiKeyDisplay}>
|
||||
{apiKey?.apiKey}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="outlined" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeyConfirmDialog;
|
172
src/components/User/ApiKeys/ApiKeyDialog.jsx
Normal file
172
src/components/User/ApiKeys/ApiKeyDialog.jsx
Normal file
@ -0,0 +1,172 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { isNil, isNumber } from 'lodash';
|
||||
import { DateTime } from 'luxon';
|
||||
import { api, endpoints } from 'api';
|
||||
import { host } from 'host';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
TextField,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Typography,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import { DatePicker } from '@mui/x-date-pickers';
|
||||
|
||||
import { makeStyles } from '@mui/styles';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
gridWrapper: {
|
||||
paddingTop: '2rem',
|
||||
paddingBottom: '2rem'
|
||||
},
|
||||
apiKeyLabel: {
|
||||
paddingBottom: '1rem'
|
||||
},
|
||||
expirationDateContainer: {
|
||||
width: '100%'
|
||||
},
|
||||
expirationDateInput: {
|
||||
width: '100%'
|
||||
},
|
||||
expirationDateDisplay: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}
|
||||
}));
|
||||
|
||||
function ApiKeyDialog(props) {
|
||||
const { open, setOpen, onConfirm } = props;
|
||||
|
||||
const [apiKeyLabel, setApiKeyLabel] = useState();
|
||||
const [expirationDateOffset, setExpirationDateOffset] = useState(30);
|
||||
const [selectedExpirationDate, setSelectedExpirationDate] = useState();
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
api
|
||||
.post(`${host()}${endpoints.apiKeys}`, {
|
||||
label: apiKeyLabel,
|
||||
expirationDate: getExpirationDatetime().toISO()
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data) {
|
||||
onConfirm(response.data);
|
||||
setOpen(false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLabelChange = (e) => {
|
||||
const { value } = e.target;
|
||||
setApiKeyLabel(value);
|
||||
};
|
||||
|
||||
const handleExpirationDateChange = (e) => {
|
||||
const { value } = e.target;
|
||||
setExpirationDateOffset(value);
|
||||
};
|
||||
|
||||
const handleDatePickerChange = (newValue) => {
|
||||
setSelectedExpirationDate(newValue);
|
||||
};
|
||||
|
||||
const getExpirationDatetime = () => {
|
||||
if (isNumber(expirationDateOffset)) {
|
||||
return DateTime.now().plus({ days: expirationDateOffset }).endOf('day');
|
||||
} else if (expirationDateOffset === 'custom') {
|
||||
return DateTime.fromISO(selectedExpirationDate);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getExpirationDisplay = () => {
|
||||
const expDateTime = getExpirationDatetime();
|
||||
return `Expires on ${expDateTime.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>Create Api Key</DialogTitle>
|
||||
<DialogContent className={classes.apiKeyForm}>
|
||||
<Grid container className={classes.gridWrapper}>
|
||||
<Grid item container className={classes.apiKeyLabel} xs={12}>
|
||||
<TextField
|
||||
autoFocus
|
||||
required
|
||||
id="apikeylabel"
|
||||
label="Label"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onChange={handleLabelChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container item xs={12}>
|
||||
<Grid item xs={5}>
|
||||
<FormControl className={classes.expirationDateContainer} size="small" required>
|
||||
<InputLabel disableAnimation>Expiration date</InputLabel>
|
||||
<Select
|
||||
labelId="expirationDate"
|
||||
id="expirationDate"
|
||||
label="Expiration time"
|
||||
onChange={handleExpirationDateChange}
|
||||
value={expirationDateOffset}
|
||||
className={classes.expirationDateInput}
|
||||
>
|
||||
<MenuItem value={7}>7 days</MenuItem>
|
||||
<MenuItem value={30}>30 days</MenuItem>
|
||||
<MenuItem value={60}>60 days</MenuItem>
|
||||
<MenuItem value={90}>90 days</MenuItem>
|
||||
<MenuItem value="custom">custom</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item className={classes.expirationDateDisplay} xs={7}>
|
||||
{expirationDateOffset === 'custom' ? (
|
||||
<DatePicker
|
||||
valueType="date"
|
||||
slotProps={{ textField: { size: 'small' } }}
|
||||
onChange={handleDatePickerChange}
|
||||
/>
|
||||
) : (
|
||||
<Typography>{getExpirationDisplay()}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={handleSubmit}
|
||||
disabled={expirationDateOffset === 'custom' && isNil(selectedExpirationDate)}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeyDialog;
|
71
src/components/User/ApiKeys/ApiKeyRevokeDialog.jsx
Normal file
71
src/components/User/ApiKeys/ApiKeyRevokeDialog.jsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
|
||||
import { api, endpoints } from 'api';
|
||||
import { host } from 'host';
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle, DialogActions, Button, Typography, Grid } from '@mui/material';
|
||||
|
||||
import { makeStyles } from '@mui/styles';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
gridWrapper: {
|
||||
paddingTop: '2rem',
|
||||
paddingBottom: '2rem'
|
||||
},
|
||||
apiKeyDisplay: {
|
||||
boxSizing: 'border-box',
|
||||
color: '#52637A',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '400',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: '#F7F7F7',
|
||||
borderRadius: '0.9rem',
|
||||
overflowWrap: 'break-word'
|
||||
}
|
||||
}));
|
||||
|
||||
function ApiKeyRevokeDialog(props) {
|
||||
const { open, setOpen, apiKey, onConfirm } = props;
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
api
|
||||
.delete(`${host()}${endpoints.apiKeys}`, { id: apiKey.uuid })
|
||||
.then((response) => {
|
||||
onConfirm(response?.status, apiKey);
|
||||
setOpen(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>Revoke "{apiKey?.label}" key</DialogTitle>
|
||||
<DialogContent className={classes.apiKeyForm}>
|
||||
<Grid container className={classes.gridWrapper}>
|
||||
<Grid item xs={12}>
|
||||
<Typography>Are you sure you want to revoke this api key?</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button variant="contained" color="error" onClick={handleSubmit}>
|
||||
Revoke
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeyRevokeDialog;
|
146
src/components/User/ApiKeys/ApiKeys.jsx
Normal file
146
src/components/User/ApiKeys/ApiKeys.jsx
Normal file
@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { isEmpty, isNil } from 'lodash';
|
||||
import { api, endpoints } from 'api';
|
||||
import { host } from '../../../host';
|
||||
|
||||
import { Grid, Stack, Card, CardContent, Typography, Button } from '@mui/material';
|
||||
import Loading from '../../Shared/Loading';
|
||||
import ApiKeyDialog from './ApiKeyDialog';
|
||||
import ApiKeyConfirmDialog from './ApiKeyConfirmDialog';
|
||||
import ApiKeyCard from './ApiKeyCard';
|
||||
|
||||
import { makeStyles } from '@mui/styles';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
pageWrapper: {
|
||||
backgroundColor: 'transparent',
|
||||
height: '100%'
|
||||
},
|
||||
header: {
|
||||
[theme.breakpoints.down('md')]: {
|
||||
padding: '0'
|
||||
}
|
||||
},
|
||||
cardRoot: {
|
||||
boxShadow: 'none!important'
|
||||
},
|
||||
pageTitle: {
|
||||
fontWeight: '600',
|
||||
fontSize: '1.5rem',
|
||||
color: theme.palette.secondary.main,
|
||||
textAlign: 'left'
|
||||
},
|
||||
apikeysContainer: {
|
||||
marginTop: '1.5rem',
|
||||
height: '100%',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
padding: '0'
|
||||
}
|
||||
},
|
||||
apikeysContent: {
|
||||
padding: '1.5rem'
|
||||
}
|
||||
}));
|
||||
|
||||
function ApiKeys() {
|
||||
const abortController = useMemo(() => new AbortController(), []);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [newApiKey, setNewApiKey] = useState();
|
||||
const classes = useStyles();
|
||||
|
||||
// ApiKey dialog props
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [apiKeyConfirmationOpen, setApiKeyConfirmationOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
api
|
||||
.get(`${host()}${endpoints.apiKeys}`)
|
||||
.then((response) => {
|
||||
if (response.data && response.data.apiKeys) {
|
||||
setApiKeys(response.data.apiKeys);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNil(newApiKey) && !apiKeyConfirmationOpen) {
|
||||
setApiKeyConfirmationOpen(true);
|
||||
}
|
||||
}, [newApiKey]);
|
||||
|
||||
const handleApiKeyDialogOpen = () => {
|
||||
setApiKeyDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleApiKeyCreateConfirm = (apiKey) => {
|
||||
setNewApiKey(apiKey);
|
||||
setApiKeys((prevState) => [...prevState, apiKey]);
|
||||
};
|
||||
|
||||
const handleApiKeyRevokeConfirm = (status, apiKey) => {
|
||||
if (status === 200) setApiKeys((prevState) => prevState.filter((ak) => ak.uuid != apiKey.uuid));
|
||||
};
|
||||
|
||||
const renderApiKeys = () => {
|
||||
return apiKeys.map((apiKey) => (
|
||||
<ApiKeyCard key={apiKey.uuid} apiKey={apiKey} onRevoke={handleApiKeyRevokeConfirm} />
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<Grid container className={classes.pageWrapper}>
|
||||
<Grid item xs={12} md={12}>
|
||||
<Card className={classes.cardRoot}>
|
||||
<CardContent>
|
||||
<Grid container className={classes.header}>
|
||||
<Grid item xs={12}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="h4" className={classes.pageTitle}>
|
||||
Manage your API Keys
|
||||
</Typography>
|
||||
<Button variant="contained" color="success" onClick={handleApiKeyDialogOpen}>
|
||||
Create new API key
|
||||
</Button>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
{!isLoading && !isEmpty(apiKeys) && (
|
||||
<Grid item xs={12} className={classes.apikeysContainer}>
|
||||
<Card className={classes.cardRoot}>
|
||||
<CardContent className={classes.apikeysContent}>
|
||||
<Stack direction="column" spacing={1}>
|
||||
{renderApiKeys()}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
<ApiKeyDialog open={apiKeyDialogOpen} setOpen={setApiKeyDialogOpen} onConfirm={handleApiKeyCreateConfirm} />
|
||||
{!isNil(newApiKey) && (
|
||||
<ApiKeyConfirmDialog open={apiKeyConfirmationOpen} setOpen={setApiKeyConfirmationOpen} apiKey={newApiKey} />
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeys;
|
16
src/index.js
16
src/index.js
@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
import { createTheme, ThemeProvider, StyledEngineProvider, adaptV4Theme } from '@mui/material/styles';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||
import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
const theme = createTheme(
|
||||
adaptV4Theme({
|
||||
@ -32,15 +34,19 @@ theme.typography.h4 = {
|
||||
}
|
||||
};
|
||||
|
||||
ReactDOM.render(
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<StyledEngineProvider injectFirst>
|
||||
<ThemeProvider theme={theme}>
|
||||
<App />
|
||||
<LocalizationProvider dateAdapter={AdapterLuxon}>
|
||||
<App />
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
|
@ -14,7 +14,6 @@ const useStyles = makeStyles(() => ({
|
||||
minWidth: '60%'
|
||||
},
|
||||
gridWrapper: {
|
||||
// backgroundColor: "#fff",
|
||||
border: '0.0625em #f2f2f2 dashed'
|
||||
},
|
||||
pageWrapper: {
|
||||
|
57
src/pages/UserManagementPage.jsx
Normal file
57
src/pages/UserManagementPage.jsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { getLoggedInUser } from 'utilities/authUtilities.js';
|
||||
|
||||
import { Container, Grid, Stack } from '@mui/material';
|
||||
|
||||
import Header from '../components/Header/Header.jsx';
|
||||
import ApiKeys from '../components/User/ApiKeys/ApiKeys.jsx';
|
||||
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
container: {
|
||||
paddingTop: 30,
|
||||
paddingBottom: 5,
|
||||
height: '100%',
|
||||
minWidth: '60%'
|
||||
},
|
||||
gridWrapper: {
|
||||
border: '0.0625rem #f2f2f2 dashed'
|
||||
},
|
||||
pageWrapper: {
|
||||
height: '100%'
|
||||
},
|
||||
tile: {
|
||||
width: '100%',
|
||||
padding: 5
|
||||
}
|
||||
}));
|
||||
|
||||
function UserManagementPage() {
|
||||
const classes = useStyles();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty(getLoggedInUser())) {
|
||||
navigate('/home');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack className={classes.pageWrapper} direction="column" data-testid="explore-container">
|
||||
<Header />
|
||||
<Container className={classes.container}>
|
||||
<Grid container className={classes.gridWrapper}>
|
||||
<Grid item className={classes.tile}>
|
||||
<ApiKeys />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserManagementPage;
|
@ -3,3 +3,7 @@
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { TextEncoder } from 'node:util';
|
||||
|
||||
global.TextEncoder = TextEncoder;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { Navigate, Outlet } from 'react-router';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
|
@ -41,10 +41,15 @@ const isAuthenticationEnabled = () => {
|
||||
return Object.keys(authMethods).length > 0;
|
||||
};
|
||||
|
||||
const isApiKeyEnabled = () => {
|
||||
const authConfig = JSON.parse(localStorage.getItem('authConfig')) || {};
|
||||
return authConfig?.apikey;
|
||||
};
|
||||
|
||||
const getLoggedInUser = () => {
|
||||
const userCookie = getCookie('user');
|
||||
if (!userCookie) return null;
|
||||
return userCookie;
|
||||
};
|
||||
|
||||
export { isAuthenticated, isAuthenticationEnabled, getLoggedInUser, logoutUser };
|
||||
export { isAuthenticated, isAuthenticationEnabled, isApiKeyEnabled, getLoggedInUser, logoutUser };
|
||||
|
@ -6,6 +6,10 @@ const osFilters = [
|
||||
{
|
||||
label: 'linux',
|
||||
value: 'linux'
|
||||
},
|
||||
{
|
||||
label: 'freebsd',
|
||||
value: 'freebsd'
|
||||
}
|
||||
];
|
||||
|
||||
@ -19,6 +23,11 @@ const imageFilters = [
|
||||
label: 'Bookmarks',
|
||||
value: 'IsBookmarked',
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
label: 'Starred Repositories',
|
||||
value: 'IsStarred',
|
||||
type: 'boolean'
|
||||
}
|
||||
];
|
||||
|
||||
@ -57,9 +66,24 @@ const archFilters = [
|
||||
label: 'amd64',
|
||||
value: 'amd64',
|
||||
tooltip: '64-bit x86'
|
||||
},
|
||||
{
|
||||
label: 'loong64',
|
||||
value: 'loong64',
|
||||
tooltip: '64-bit LoongArch'
|
||||
},
|
||||
{
|
||||
label: 'riscv64',
|
||||
value: 'riscv64',
|
||||
tooltip: '64-bit RISC-V'
|
||||
}
|
||||
];
|
||||
|
||||
const filterConstants = { osFilters, imageFilters, archFilters };
|
||||
const signatureToolConstants = {
|
||||
COSIGN: 'cosign',
|
||||
NOTATION: 'notation'
|
||||
};
|
||||
|
||||
const filterConstants = { osFilters, imageFilters, archFilters, signatureToolConstants };
|
||||
|
||||
export default filterConstants;
|
||||
|
@ -5,6 +5,7 @@ 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,
|
||||
@ -14,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
|
||||
};
|
||||
@ -32,11 +34,13 @@ const mapToRepoFromRepoInfo = (responseRepoInfo) => {
|
||||
title: responseRepoInfo.Summary?.NewestImage?.Title,
|
||||
source: responseRepoInfo.Summary?.NewestImage?.Source,
|
||||
downloads: responseRepoInfo.Summary?.NewestImage?.DownloadCount,
|
||||
stars: responseRepoInfo.Summary?.NewestImage?.StarCount,
|
||||
overview: responseRepoInfo.Summary?.NewestImage?.Documentation,
|
||||
license: responseRepoInfo.Summary?.NewestImage?.Licenses,
|
||||
vulnerabilitySeverity: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.MaxSeverity,
|
||||
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
|
||||
@ -51,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,
|
||||
@ -63,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}`
|
||||
};
|
||||
@ -76,9 +83,11 @@ const mapToManifest = (responseManifest) => {
|
||||
size: responseManifest.Size,
|
||||
platform: responseManifest.Platform,
|
||||
downloadCount: responseManifest.DownloadCount,
|
||||
starCount: responseManifest.StarCount,
|
||||
layers: responseManifest.Layers,
|
||||
history: responseManifest.History,
|
||||
vulnerabilities: responseManifest.Vulnerabilities
|
||||
vulnerabilities: responseManifest.Vulnerabilities,
|
||||
referrers: responseManifest.Referrers
|
||||
};
|
||||
};
|
||||
|
||||
@ -88,12 +97,52 @@ const mapCVEInfo = (cveInfo) => {
|
||||
id: cve.Id,
|
||||
severity: cve.Severity,
|
||||
title: cve.Title,
|
||||
description: cve.Description
|
||||
description: cve.Description,
|
||||
reference: cve.Reference,
|
||||
packageList: cve.PackageList?.map((pkg) => ({
|
||||
packageName: pkg.Name,
|
||||
packagePath: pkg.PackagePath,
|
||||
packageInstalledVersion: pkg.InstalledVersion,
|
||||
packageFixedVersion: pkg.FixedVersion
|
||||
}))
|
||||
};
|
||||
});
|
||||
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,
|
||||
packagePath: packageInfo.PackagePath,
|
||||
packageInstalledVersion: packageInfo.InstalledVersion,
|
||||
packageFixedVersion: packageInfo.FixedVersion
|
||||
};
|
||||
});
|
||||
});
|
||||
return cveList;
|
||||
};
|
||||
|
||||
const mapSignatureInfo = (signatureInfo) => {
|
||||
return signatureInfo
|
||||
? {
|
||||
tool: signatureInfo.Tool,
|
||||
isTrusted: signatureInfo.IsTrusted,
|
||||
author: signatureInfo.Author
|
||||
}
|
||||
: {
|
||||
tool: 'Unknown',
|
||||
isTrusted: false,
|
||||
author: 'Unknown'
|
||||
};
|
||||
};
|
||||
|
||||
const mapReferrer = (referrer) => ({
|
||||
mediaType: referrer.MediaType,
|
||||
artifactType: referrer.ArtifactType,
|
||||
@ -102,4 +151,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 };
|
||||
|
@ -4,6 +4,7 @@ const HOME_PAGE_SIZE = 10;
|
||||
const HOME_POPULAR_PAGE_SIZE = 3;
|
||||
const HOME_RECENT_PAGE_SIZE = 2;
|
||||
const HOME_BOOKMARKS_PAGE_SIZE = 2;
|
||||
const HOME_STARS_PAGE_SIZE = 2;
|
||||
const CVE_FIXEDIN_PAGE_SIZE = 5;
|
||||
|
||||
export {
|
||||
@ -13,5 +14,6 @@ export {
|
||||
CVE_FIXEDIN_PAGE_SIZE,
|
||||
HOME_POPULAR_PAGE_SIZE,
|
||||
HOME_RECENT_PAGE_SIZE,
|
||||
HOME_BOOKMARKS_PAGE_SIZE
|
||||
HOME_BOOKMARKS_PAGE_SIZE,
|
||||
HOME_STARS_PAGE_SIZE
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { isEmpty } from 'lodash';
|
||||
import React from 'react';
|
||||
import {
|
||||
NoneVulnerabilityIcon,
|
||||
@ -12,14 +13,26 @@ import {
|
||||
CriticalVulnerabilityChip,
|
||||
UnverifiedSignatureIcon,
|
||||
VerifiedSignatureIcon,
|
||||
UnverifiedSignatureChip,
|
||||
VerifiedSignatureChip,
|
||||
UnknownVulnerabilityIcon,
|
||||
UnknownVulnerabilityChip,
|
||||
FailedScanIcon,
|
||||
FailedScanChip
|
||||
FailedScanChip,
|
||||
NotTrustedSignatureIcon
|
||||
} from './vulnerabilityAndSignatureComponents';
|
||||
|
||||
const getStrongestSignature = (signatureInfo) => {
|
||||
if (isEmpty(signatureInfo)) return null;
|
||||
const trusted = signatureInfo.find((si) => si.isTrusted);
|
||||
if (!isEmpty(trusted)) return trusted;
|
||||
return signatureInfo[0];
|
||||
};
|
||||
|
||||
const getAllAuthorsOfSignatures = (signatureInfo) => {
|
||||
if (isEmpty(signatureInfo)) return '';
|
||||
const signatureAuthors = signatureInfo.filter((si) => si.isTrusted).map((si) => si.author);
|
||||
return signatureAuthors.join(',');
|
||||
};
|
||||
|
||||
const VulnerabilityIconCheck = ({ vulnerabilitySeverity }) => {
|
||||
let result;
|
||||
let vulnerabilityStringTitle = '';
|
||||
@ -84,20 +97,17 @@ const VulnerabilityChipCheck = ({ vulnerabilitySeverity }) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const SignatureIconCheck = ({ isSigned }) => {
|
||||
if (isSigned) {
|
||||
return <VerifiedSignatureIcon />;
|
||||
} else {
|
||||
return <UnverifiedSignatureIcon />;
|
||||
}
|
||||
const SignatureIconCheck = ({ signatureInfo }) => {
|
||||
const strongestSignature = getStrongestSignature(signatureInfo);
|
||||
if (strongestSignature === null) return <UnverifiedSignatureIcon signatureInfo={signatureInfo} />;
|
||||
if (strongestSignature.isTrusted) return <VerifiedSignatureIcon signatureInfo={signatureInfo} />;
|
||||
return <NotTrustedSignatureIcon signatureInfo={signatureInfo} />;
|
||||
};
|
||||
|
||||
const SignatureChipCheck = ({ isSigned }) => {
|
||||
if (isSigned) {
|
||||
return <VerifiedSignatureChip />;
|
||||
} else {
|
||||
return <UnverifiedSignatureChip />;
|
||||
}
|
||||
export {
|
||||
VulnerabilityIconCheck,
|
||||
VulnerabilityChipCheck,
|
||||
SignatureIconCheck,
|
||||
getStrongestSignature,
|
||||
getAllAuthorsOfSignatures
|
||||
};
|
||||
|
||||
export { VulnerabilityIconCheck, VulnerabilityChipCheck, SignatureIconCheck, SignatureChipCheck };
|
||||
|
@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Chip, Tooltip } from '@mui/material';
|
||||
import { Chip, Tooltip, Badge } 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';
|
||||
import filterConstants from 'utilities/filterConstants';
|
||||
|
||||
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" />,
|
||||
@ -13,17 +15,21 @@ const OutlinedBugIcon = createSvgIcon(
|
||||
'OutlinedBug'
|
||||
);
|
||||
const UnverifiedShieldIcon = createSvgIcon(
|
||||
<path d="M12.4837 2C13.6167 2 19.5627 4.041 20.3487 4.828C21.0047 5.484 20.9947 6.014 20.9487 8.557C20.9307 9.575 20.9057 10.962 20.9057 12.879C20.9057 19.761 13.0357 22.223 12.7007 22.324C12.6297 22.346 12.5567 22.356 12.4837 22.356C12.4107 22.356 12.3377 22.346 12.2667 22.324C11.9317 22.223 4.06165 19.761 4.06165 12.879C4.06165 10.959 4.03665 9.572 4.01865 8.554C4.01044 8.10043 4.00337 7.71095 4.00104 7.37341L4.00073 6.9925C4.00922 5.74112 4.1264 5.32 4.61765 4.828C5.40465 4.041 11.3507 2 12.4837 2ZM12.4837 3.5C11.6357 3.5 6.28465 5.384 5.66765 5.899C5.54931 6.018 5.50535 6.19514 5.49972 6.89808L5.49926 7.16877C5.50045 7.51182 5.50742 7.95335 5.51765 8.526C5.53665 9.552 5.56165 10.947 5.56165 12.879C5.56165 18.08 11.2837 20.389 12.4827 20.814C13.6807 20.387 19.4057 18.065 19.4057 12.879C19.4057 10.949 19.4307 9.555 19.4487 8.529C19.4592 7.95581 19.4663 7.51389 19.4674 7.17033L19.4668 6.89918C19.4605 6.19482 19.4138 6.01519 19.2877 5.889C18.6817 5.384 13.3317 3.5 12.4837 3.5ZM11.1346 9.5372L12.4837 10.887L13.8328 9.5372C14.1258 9.2442 14.5998 9.2442 14.8928 9.5372C15.1858 9.8302 15.1858 10.3042 14.8928 10.5972L13.5437 11.947L14.8926 13.2952C15.1856 13.5882 15.1856 14.0622 14.8926 14.3552C14.7466 14.5022 14.5546 14.5752 14.3626 14.5752C14.1706 14.5752 13.9786 14.5022 13.8326 14.3552L12.4837 13.007L11.1348 14.3552C10.9888 14.5022 10.7968 14.5752 10.6048 14.5752C10.4128 14.5752 10.2208 14.5022 10.0748 14.3552C9.78175 14.0622 9.78175 13.5882 10.0748 13.2952L11.4237 11.947L10.0746 10.5972C9.78155 10.3042 9.78155 9.8302 10.0746 9.5372C10.3676 9.2442 10.8416 9.2442 11.1346 9.5372Z" />,
|
||||
<path d="M9,0,0,4v6c0,5.55,3.84,10.74,9,12,5.16-1.26,9-6.45,9-12V4Zm7,10a10.47,10.47,0,0,1-7,9.93A10.47,10.47,0,0,1,2,10V5.3L9,2.19,16,5.3ZM7,7.74l1.22,1.4c.32.36.58.7.86,1.06H9.1c.28-.39.56-.72.84-1.07l1.2-1.39h1.68L9.91,10.89l3,3.37H11.14L9.89,12.79c-.33-.38-.62-.74-.92-1.13h0c-.28.39-.58.74-.9,1.13L6.8,14.26H5.09l3-3.33L5.23,7.74Z" />,
|
||||
'UnverifiedShield'
|
||||
);
|
||||
const VerifiedShieldIcon = createSvgIcon(
|
||||
<path d="M12.4836 2C13.6166 2 19.5616 4.041 20.3486 4.828C21.0046 5.484 20.9946 6.014 20.9486 8.554C20.9306 9.572 20.9056 10.959 20.9056 12.879C20.9056 19.761 13.0356 22.223 12.7006 22.324C12.6296 22.346 12.5566 22.356 12.4836 22.356C12.4106 22.356 12.3376 22.346 12.2666 22.324C11.9316 22.223 4.06162 19.761 4.06162 12.879C4.06162 10.962 4.03662 9.575 4.01862 8.557C4.01041 8.10289 4.00334 7.71298 4.00102 7.37507L4.00073 6.99377C4.00931 5.74113 4.12687 5.32 4.61962 4.828C5.40462 4.041 11.3496 2 12.4836 2ZM12.4836 3.5C11.6356 3.5 6.28562 5.384 5.66862 5.899C5.48662 6.082 5.47962 6.4 5.51862 8.529C5.53662 9.555 5.56162 10.949 5.56162 12.879C5.56162 18.08 11.2836 20.389 12.4826 20.814C13.6806 20.387 19.4056 18.065 19.4056 12.879C19.4056 10.947 19.4306 9.552 19.4496 8.526C19.4876 6.399 19.4806 6.081 19.2876 5.889C18.6826 5.384 13.3316 3.5 12.4836 3.5ZM16.2051 9.3395C16.4981 9.6325 16.4981 10.1075 16.2051 10.4005L12.3071 14.2995C12.1951 14.4123 12.0505 14.4854 11.8952 14.5102L11.7771 14.5195C11.5781 14.5195 11.3871 14.4405 11.2461 14.2995L9.35412 12.4055C9.06212 12.1125 9.06212 11.6365 9.35512 11.3445C9.64712 11.0515 10.1231 11.0515 10.4161 11.3445L11.7771 12.7075L15.1451 9.3395C15.4381 9.0465 15.9121 9.0465 16.2051 9.3395Z" />,
|
||||
const CVerifiedShieldIcon = createSvgIcon(
|
||||
<path d="M11.8,13.64a1.85,1.85,0,0,1,0,.25.9.9,0,0,1,0,.18.33.33,0,0,1,0,.12.47.47,0,0,1-.09.12,1.25,1.25,0,0,1-.25.18c-.13.07-.28.13-.45.2a4.13,4.13,0,0,1-.61.16,4.35,4.35,0,0,1-.74.06,3.93,3.93,0,0,1-1.41-.24A2.91,2.91,0,0,1,7.1,14a3.32,3.32,0,0,1-.67-1.2A5.2,5.2,0,0,1,6.2,11.1a5.37,5.37,0,0,1,.25-1.72,3.85,3.85,0,0,1,.72-1.26,3,3,0,0,1,1.12-.77,3.63,3.63,0,0,1,1.42-.26,3,3,0,0,1,.61,0,2.66,2.66,0,0,1,.54.14,2.34,2.34,0,0,1,.45.19,1.84,1.84,0,0,1,.28.19l.11.13s0,.09.05.14,0,.12,0,.19a2.35,2.35,0,0,1,0,.28,2.63,2.63,0,0,1,0,.3.88.88,0,0,1,0,.2.52.52,0,0,1-.07.11.17.17,0,0,1-.1,0,.38.38,0,0,1-.22-.1c-.09-.07-.21-.14-.35-.23a3.08,3.08,0,0,0-.51-.23,2.41,2.41,0,0,0-.7-.1A1.67,1.67,0,0,0,9,8.57a1.58,1.58,0,0,0-.6.52A2.54,2.54,0,0,0,8,9.92,4.15,4.15,0,0,0,7.86,11,4.31,4.31,0,0,0,8,12.17a2.19,2.19,0,0,0,.39.81,1.55,1.55,0,0,0,.61.47,2,2,0,0,0,.82.16,2.17,2.17,0,0,0,.71-.1A2.45,2.45,0,0,0,11,13.3l.35-.21a.38.38,0,0,1,.21-.1.17.17,0,0,1,.1,0,.17.17,0,0,1,.06.09c0,.05,0,.11,0,.2A3,3,0,0,1,11.8,13.64ZM9,0,0,4v6c0,5.55,3.84,10.74,9,12,5.16-1.26,9-6.45,9-12V4Zm7,10a10.47,10.47,0,0,1-7,9.93A10.47,10.47,0,0,1,2,10V5.3L9,2.19,16,5.3Z" />,
|
||||
'VerifiedShield'
|
||||
);
|
||||
const NVerifiedShieldIcon = createSvgIcon(
|
||||
<path d="M12.13,14.25a.6.6,0,0,1-.05.24.45.45,0,0,1-.13.17.39.39,0,0,1-.19.1.52.52,0,0,1-.21,0h-.66a1.79,1.79,0,0,1-.36,0,.72.72,0,0,1-.27-.15,1.06,1.06,0,0,1-.24-.3c-.08-.12-.17-.28-.27-.47L7.87,10.29c-.11-.21-.22-.44-.34-.68s-.21-.48-.3-.71h0l0,.84c0,.28,0,.56,0,.86v4a.17.17,0,0,1,0,.1.19.19,0,0,1-.11.08.81.81,0,0,1-.21.05l-.35,0-.34,0A.81.81,0,0,1,6,14.75a.19.19,0,0,1-.11-.08.17.17,0,0,1,0-.1V7.75A.51.51,0,0,1,6,7.34a.56.56,0,0,1,.39-.14h.83a1.82,1.82,0,0,1,.37,0,.84.84,0,0,1,.27.13,1.06,1.06,0,0,1,.23.24A3.9,3.9,0,0,1,8.35,8l1.47,2.78.26.49.24.49.23.47c.07.16.15.32.22.47h0c0-.27,0-.56,0-.85s0-.58,0-.85V7.43a.17.17,0,0,1,0-.1.29.29,0,0,1,.12-.09.9.9,0,0,1,.22,0h.68a.72.72,0,0,1,.2,0s.09,0,.11.09a.17.17,0,0,1,0,.1ZM9,0,0,4v6c0,5.55,3.84,10.74,9,12,5.16-1.26,9-6.45,9-12V4Zm7,10a10.47,10.47,0,0,1-7,9.93A10.47,10.47,0,0,1,2,10V5.3L9,2.19,16,5.3Z" />,
|
||||
'VerifiedShield'
|
||||
);
|
||||
|
||||
const NoneVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
return (
|
||||
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
|
||||
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
|
||||
<OutlinedBugIcon
|
||||
sx={{
|
||||
color: '#43A047!important',
|
||||
@ -40,7 +46,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 +81,7 @@ const FailedScanIcon = () => {
|
||||
};
|
||||
const LowVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
|
||||
return (
|
||||
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
|
||||
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
|
||||
<OutlinedBugIcon
|
||||
sx={{
|
||||
color: '#FB8C00',
|
||||
@ -92,7 +98,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 +115,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 +132,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 +150,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 +161,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 +175,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 +183,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 +194,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 +205,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,22 +216,20 @@ 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 signatureInfo={signatureInfo} />} placement="top">
|
||||
<UnverifiedShieldIcon
|
||||
viewBox="0 0 18 22"
|
||||
sx={{
|
||||
color: '#E53935',
|
||||
padding: '0.2rem',
|
||||
@ -257,22 +243,119 @@ const UnverifiedSignatureIcon = () => {
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
const VerifiedSignatureIcon = () => {
|
||||
|
||||
const NotTrustedSignatureIcon = ({ signatureInfo }) => {
|
||||
return (
|
||||
<Tooltip title="Verified Signature" placement="top">
|
||||
<VerifiedShieldIcon
|
||||
viewBox="0 0 24 24"
|
||||
sx={{
|
||||
color: '#43A047',
|
||||
alignSelf: 'center',
|
||||
padding: '0.2rem',
|
||||
background: '#E8F5E9',
|
||||
borderRadius: '1rem',
|
||||
height: '1.5rem',
|
||||
width: '1.6rem'
|
||||
}}
|
||||
data-testid="verified-icon"
|
||||
/>
|
||||
<Tooltip title={<SignatureTooltip signatureInfo={signatureInfo} />} placement="top">
|
||||
{(signatureInfo[0]?.tool && signatureInfo[0].tool == filterConstants.signatureToolConstants.NOTATION && (
|
||||
<Badge
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
overlap="circular"
|
||||
color="warning"
|
||||
badgeContent={signatureInfo.length}
|
||||
>
|
||||
<NVerifiedShieldIcon
|
||||
viewBox="0 0 18 22"
|
||||
sx={{
|
||||
backgroundColor: '#FCE2B8!important',
|
||||
color: '#FB8C00',
|
||||
alignSelf: 'center',
|
||||
padding: '0.2rem',
|
||||
background: '#E8F5E9',
|
||||
borderRadius: '1rem',
|
||||
height: '1.5rem',
|
||||
width: '1.6rem'
|
||||
}}
|
||||
data-testid="untrusted-icon"
|
||||
/>
|
||||
</Badge>
|
||||
)) ||
|
||||
(signatureInfo[0]?.tool && signatureInfo[0].tool == filterConstants.signatureToolConstants.COSIGN && (
|
||||
<Badge
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
overlap="circular"
|
||||
color="warning"
|
||||
badgeContent={signatureInfo.length}
|
||||
>
|
||||
<CVerifiedShieldIcon
|
||||
viewBox="0 0 18 22"
|
||||
sx={{
|
||||
backgroundColor: '#FCE2B8!important',
|
||||
color: '#FB8C00',
|
||||
alignSelf: 'center',
|
||||
padding: '0.2rem',
|
||||
background: '#E8F5E9',
|
||||
borderRadius: '1rem',
|
||||
height: '1.5rem',
|
||||
width: '1.6rem'
|
||||
}}
|
||||
data-testid="untrusted-icon"
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const VerifiedSignatureIcon = ({ signatureInfo }) => {
|
||||
return (
|
||||
<Tooltip title={<SignatureTooltip signatureInfo={signatureInfo} />} placement="top">
|
||||
{(signatureInfo[0]?.tool && signatureInfo[0].tool == filterConstants.signatureToolConstants.NOTATION && (
|
||||
<Badge
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
overlap="circular"
|
||||
color="success"
|
||||
badgeContent={signatureInfo.length}
|
||||
>
|
||||
<NVerifiedShieldIcon
|
||||
viewBox="0 0 18 22"
|
||||
sx={{
|
||||
color: '#43A047',
|
||||
alignSelf: 'center',
|
||||
padding: '0.2rem',
|
||||
background: '#E8F5E9',
|
||||
borderRadius: '1rem',
|
||||
height: '1.5rem',
|
||||
width: '1.6rem'
|
||||
}}
|
||||
data-testid="verified-icon"
|
||||
/>
|
||||
</Badge>
|
||||
)) ||
|
||||
(signatureInfo[0]?.tool && signatureInfo[0].tool == filterConstants.signatureToolConstants.COSIGN && (
|
||||
<Badge
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
overlap="circular"
|
||||
color="success"
|
||||
badgeContent={signatureInfo.length}
|
||||
>
|
||||
<CVerifiedShieldIcon
|
||||
viewBox="0 0 18 22"
|
||||
sx={{
|
||||
color: '#43A047',
|
||||
alignSelf: 'center',
|
||||
padding: '0.2rem',
|
||||
background: '#E8F5E9',
|
||||
borderRadius: '1rem',
|
||||
height: '1.5rem',
|
||||
width: '1.6rem'
|
||||
}}
|
||||
data-testid="verified-icon"
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@ -290,19 +373,6 @@ const UnverifiedSignatureChip = () => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
const VerifiedSignatureChip = () => {
|
||||
return (
|
||||
<Chip
|
||||
label="Verified Signature"
|
||||
sx={{ backgroundColor: '#E8F5E9', color: '#388E3C', fontSize: '0.8125rem' }}
|
||||
variant="filled"
|
||||
onDelete={() => {
|
||||
return;
|
||||
}}
|
||||
deleteIcon={<VerifiedShieldIcon sx={{ color: '#388E3C!important' }} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
NoneVulnerabilityIcon,
|
||||
@ -318,9 +388,9 @@ export {
|
||||
HighVulnerabilityChip,
|
||||
CriticalVulnerabilityChip,
|
||||
UnverifiedSignatureIcon,
|
||||
NotTrustedSignatureIcon,
|
||||
VerifiedSignatureIcon,
|
||||
UnverifiedSignatureChip,
|
||||
VerifiedSignatureChip,
|
||||
FailedScanIcon,
|
||||
FailedScanChip
|
||||
};
|
||||
|
@ -7,7 +7,7 @@ import { hosts, endpoints, sortCriteria } from './values/test-constants';
|
||||
test.describe('explore page test', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('token', '-');
|
||||
window.localStorage.setItem('authConfig', '{}');
|
||||
});
|
||||
});
|
||||
|
||||
@ -76,8 +76,14 @@ test.describe('explore page test', () => {
|
||||
|
||||
await expect(exploreFirst).toBeVisible({ timeout: 250000 });
|
||||
|
||||
const windowsFilter = page.getByRole('checkbox', { name: 'windows' });
|
||||
await linuxFilter.uncheck();
|
||||
await page.getByRole('checkbox', { name: 'windows' }).check();
|
||||
await windowsFilter.check();
|
||||
await expect(exploreFirst).not.toBeVisible({ timeout: 250000 });
|
||||
|
||||
const freebsdFilter = page.getByRole('checkbox', { name: 'freebsd' });
|
||||
await windowsFilter.uncheck();
|
||||
await freebsdFilter.check();
|
||||
await expect(exploreFirst).not.toBeVisible({ timeout: 250000 });
|
||||
});
|
||||
});
|
||||
|
@ -5,7 +5,7 @@ import { hosts, endpoints, sortCriteria } from './values/test-constants';
|
||||
test.describe('homepage test', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('token', '-');
|
||||
window.localStorage.setItem('authConfig', '{}');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { getRepoListOrderedAlpha } from './utils/test-data-parser';
|
||||
test.describe('navbar test', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('token', '-');
|
||||
window.localStorage.setItem('authConfig', '{}');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -8,7 +8,7 @@ const testRepo = getMultiTagRepo();
|
||||
test.describe('Repository page test', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('token', '-');
|
||||
window.localStorage.setItem('authConfig', '{}');
|
||||
});
|
||||
|
||||
await page.goto(`${hosts.ui}/image/${testRepo.repo}`);
|
||||
|
@ -222,8 +222,8 @@ fi
|
||||
|
||||
trivy_out_file=trivy-${image}-${tag}.json
|
||||
if [ ! -z "${multiarch}" ]; then
|
||||
trivy image --scanners vuln --format json --input ${local_image_ref_trivy} -o ${trivy_out_file}
|
||||
jq -n --argfile trivy_file ${trivy_out_file} '.trivy=$trivy_file.Results' > ${trivy_out_file}.tmp
|
||||
trivy image --scanners vuln --db-repository ghcr.io/project-zot/trivy-db --format json --input ${local_image_ref_trivy} -o ${trivy_out_file}
|
||||
jq -n --slurpfile trivy_file ${trivy_out_file} '.trivy=$trivy_file[0].Results' > ${trivy_out_file}.tmp
|
||||
mv ${trivy_out_file}.tmp ${trivy_out_file}
|
||||
else
|
||||
echo '{"trivy":[]}' > ${trivy_out_file}
|
||||
|
@ -5,7 +5,7 @@ import { hosts, pageSizes } from './values/test-constants';
|
||||
test.describe('Tag page test', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('token', '-');
|
||||
window.localStorage.setItem('authConfig', '{}');
|
||||
});
|
||||
});
|
||||
|
||||
@ -29,7 +29,7 @@ test.describe('Tag page test', () => {
|
||||
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 });
|
||||
await expect(await page.getByText('Tag').count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Tag page with vulnerabilities', async ({ page }) => {
|
||||
@ -37,7 +37,8 @@ test.describe('Tag page test', () => {
|
||||
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);
|
||||
await expect(page.getByText(/CVE-/).nth(0)).toBeVisible({ timeout: 100000 });
|
||||
await expect(await page.getByText(/CVE-/).count()).toBeGreaterThan(0);
|
||||
await expect(await page.getByText(/CVE-/).count()).toBeLessThanOrEqual(pageSizes.EXPLORE);
|
||||
});
|
||||
});
|
||||
|
@ -149,7 +149,7 @@ const getRepoListOrderedAlpha = () => {
|
||||
// };
|
||||
|
||||
const getRepoCardNameForLocator = (repo) => {
|
||||
return `${repo?.repo} ${repo?.tags[0]?.description?.slice(0, 10)}`;
|
||||
return new RegExp(`${repo?.repo} \\d${repo?.tags[0]?.description?.slice(0, 10)}`);
|
||||
};
|
||||
|
||||
export {
|
||||
|
@ -17,15 +17,15 @@ const pageSizes = {
|
||||
};
|
||||
|
||||
const endpoints = {
|
||||
repoList: `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage:%20{limit:15%20offset:0}){Results%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20%20Licenses%20Logo%20Title%20Source%20IsSigned%20Documentation%20Vendor%20Labels}%20DownloadCount}}}`,
|
||||
repoList: `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage:%20{limit:15%20offset:0}){Results%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20%20Licenses%20Logo%20Title%20Source%20IsSigned%20Documentation%20Vendor%20Labels}%20StarCount%20DownloadCount}}}`,
|
||||
detailedRepoInfo: (name) =>
|
||||
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:%22${name}%22){Images%20{Manifests%20{Digest%20Platform%20{Os%20Arch}%20Size}%20Vulnerabilities%20{MaxSeverity%20Count}%20Tag%20LastUpdated%20Vendor%20}%20Summary%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20Vendors%20NewestImage%20{RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20Manifests%20{Digest}%20Tag%20Title%20Documentation%20DownloadCount%20Source%20Description%20Licenses}}}}`,
|
||||
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:%22${name}%22){Images%20{Manifests%20{Digest%20Platform%20{Os%20Arch}%20Size}%20Vulnerabilities%20{MaxSeverity%20Count}%20Tag%20LastUpdated%20Vendor%20IsDeletable%20}%20Summary%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20Vendors%20NewestImage%20{RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20Manifests%20{Digest}%20Tag%20Title%20Documentation%20DownloadCount%20Source%20Description%20Licenses}}}}`,
|
||||
globalSearch: (searchTerm, sortCriteria, pageNumber = 1, pageSize = 10) =>
|
||||
`/v2/_zot/ext/search?query={GlobalSearch(query:%22${searchTerm}%22,%20requestedPage:%20{limit:${pageSize}%20offset:${
|
||||
10 * (pageNumber - 1)
|
||||
}%20sortBy:%20${sortCriteria}}%20)%20{Page%20{TotalCount%20ItemCount}%20Repos%20{Name%20LastUpdated%20Size%20Platforms%20{%20Os%20Arch%20}%20IsStarred%20IsBookmarked%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20IsSigned%20Licenses%20Vendor%20Labels%20}%20DownloadCount}}}`,
|
||||
}%20sortBy:%20${sortCriteria}}%20)%20{Page%20{TotalCount%20ItemCount}%20Repos%20{Name%20LastUpdated%20Size%20Platforms%20{%20Os%20Arch%20}%20IsStarred%20IsBookmarked%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20IsSigned%20SignatureInfo%20{%20Tool%20IsTrusted%20Author%20}%20Licenses%20Vendor%20Labels%20}%20StarCount%20DownloadCount}}}`,
|
||||
image: (name) =>
|
||||
`/v2/_zot/ext/search?query={Image(image:%20%22${name}%22){RepoName%20IsSigned%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}}`
|
||||
`/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}%20Referrers%20{MediaType%20ArtifactType%20Size%20Digest%20Annotations{Key%20Value}}}%20Vendor%20Licenses%20}}`
|
||||
};
|
||||
|
||||
export { hosts, endpoints, sortCriteria, pageSizes };
|
||||
|
Reference in New Issue
Block a user