diff --git a/.eslintrc.cjs b/.eslintrc.cjs index f4d20088f..82b5933a7 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -108,13 +108,24 @@ module.exports = { 'import/no-cycle': 'off', 'import/prefer-default-export': 'off', 'no-restricted-exports': 'off', - 'import/named': 'off' + 'import/named': 'off', }, overrides: [ { files: ['*.ts', '*.tsx'], rules: { 'no-undef': 'off', + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'react-toastify', + message: 'Please use "ToastManager.showToast" instead.', + } + ], + }, + ], }, }, ], diff --git a/package-lock.json b/package-lock.json index 26859bd98..05f415658 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "0.3.0-patch-1", + "version": "0.3.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "0.3.0-patch-1", + "version": "0.3.18", "license": "ISC", "dependencies": { "@types/react-dates": "^21.8.6", "ansi_up": "^5.2.1", + "dayjs": "^1.11.13", "fast-json-patch": "^3.1.1", "jsonpath-plus": "^9.0.0", "react-dates": "^21.8.0", @@ -49,9 +50,9 @@ "prettier": "^3.1.1", "react-ga4": "^1.4.1", "react-mde": "^11.5.0", - "react-toastify": "^8.2.0", + "react-toastify": "9.1.3", "typescript": "5.5.4", - "vite": "5.4.2", + "vite": "5.4.6", "vite-plugin-dts": "4.0.3", "vite-plugin-lib-inject-css": "2.1.1", "vite-plugin-svgr": "^2.4.0", @@ -68,6 +69,7 @@ "react-dom": "^17.0.2", "react-draggable": "^4.4.5", "react-ga4": "^1.4.1", + "react-keybind": "^0.9.4", "react-mde": "^11.5.0", "react-router": "^5.3.0", "react-router-dom": "^5.3.0", @@ -4171,6 +4173,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -7598,10 +7606,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -8190,9 +8199,10 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -8238,9 +8248,9 @@ } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -8259,8 +8269,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -8481,6 +8491,12 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, + "node_modules/react-keybind": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/react-keybind/-/react-keybind-0.9.4.tgz", + "integrity": "sha512-JVlXJ4ONQFQtEDZqXpc5NZ3oLEJtj7lKCPMLsbAO6FfkXJ21VHKyDtiLUIMio3d3oSC8QfxDOQtUEeVrMW6HfQ==", + "peer": true + }, "node_modules/react-mde": { "version": "11.5.0", "resolved": "https://registry.npmjs.org/react-mde/-/react-mde-11.5.0.tgz", @@ -8632,10 +8648,11 @@ } }, "node_modules/react-toastify": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-8.2.0.tgz", - "integrity": "sha512-Pg2Ju7NngAamarFvLwqrFomJ57u/Ay6i6zfLurt/qPynWkAkOthu6vxfqYpJCyNhHRhR4hu7+bySSeWWJu6PAg==", + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.3.tgz", + "integrity": "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==", "dev": true, + "license": "MIT", "dependencies": { "clsx": "^1.1.1" }, @@ -9197,9 +9214,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -9857,14 +9875,14 @@ "peer": true }, "node_modules/vite": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", - "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", + "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.41", + "postcss": "^8.4.43", "rollup": "^4.20.0" }, "bin": { diff --git a/package.json b/package.json index 53d2fc1bf..ee16c07e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "0.3.0-patch-1", + "version": "0.3.18", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", @@ -64,16 +64,15 @@ "prettier": "^3.1.1", "react-ga4": "^1.4.1", "react-mde": "^11.5.0", - "react-toastify": "^8.2.0", + "react-toastify": "9.1.3", "typescript": "5.5.4", - "vite": "5.4.2", + "vite": "5.4.6", "vite-plugin-dts": "4.0.3", "vite-plugin-lib-inject-css": "2.1.1", "vite-plugin-svgr": "^2.4.0", "vite-tsconfig-paths": "5.0.1" }, "peerDependencies": { - "react-select": "5.8.0", "@rjsf/core": "^5.13.3", "@rjsf/utils": "^5.13.3", "@rjsf/validator-ajv8": "^5.13.3", @@ -84,18 +83,21 @@ "react-dom": "^17.0.2", "react-draggable": "^4.4.5", "react-ga4": "^1.4.1", + "react-keybind": "^0.9.4", "react-mde": "^11.5.0", "react-router": "^5.3.0", "react-router-dom": "^5.3.0", + "react-select": "5.8.0", "rxjs": "^7.8.1", "yaml": "^2.4.1" }, "dependencies": { "@types/react-dates": "^21.8.6", "ansi_up": "^5.2.1", + "dayjs": "^1.11.13", "fast-json-patch": "^3.1.1", - "react-dates": "^21.8.0", "jsonpath-plus": "^9.0.0", + "react-dates": "^21.8.0", "react-monaco-editor": "^0.54.0", "sass": "^1.69.7", "tslib": "2.7.0" @@ -109,7 +111,7 @@ "monaco-editor": "0.44.0" }, "vite-plugin-svgr": { - "vite": "5.4.2" + "vite": "5.4.6" } } } diff --git a/src/Assets/Icon/ic-bug.svg b/src/Assets/Icon/ic-bug.svg new file mode 100644 index 000000000..dc73c8773 --- /dev/null +++ b/src/Assets/Icon/ic-bug.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/src/Assets/Icon/ic-caret-left-small.svg b/src/Assets/Icon/ic-caret-left-small.svg new file mode 100644 index 000000000..f0a808359 --- /dev/null +++ b/src/Assets/Icon/ic-caret-left-small.svg @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/src/Assets/Icon/ic-close.svg b/src/Assets/Icon/ic-close.svg index 83c066999..474e6d461 100644 --- a/src/Assets/Icon/ic-close.svg +++ b/src/Assets/Icon/ic-close.svg @@ -16,10 +16,10 @@ - + - + diff --git a/src/Assets/Icon/ic-collapse-all.svg b/src/Assets/Icon/ic-collapse-all.svg new file mode 100644 index 000000000..8fd9ef2d0 --- /dev/null +++ b/src/Assets/Icon/ic-collapse-all.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/Assets/Icon/ic-error.svg b/src/Assets/Icon/ic-error.svg new file mode 100644 index 000000000..f577501d7 --- /dev/null +++ b/src/Assets/Icon/ic-error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Assets/Icon/ic-expand-all.svg b/src/Assets/Icon/ic-expand-all.svg new file mode 100644 index 000000000..8d287e60e --- /dev/null +++ b/src/Assets/Icon/ic-expand-all.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/Assets/Icon/ic-expand.svg b/src/Assets/Icon/ic-expand.svg index ff3571eaf..1b1cc5bf3 100644 --- a/src/Assets/Icon/ic-expand.svg +++ b/src/Assets/Icon/ic-expand.svg @@ -15,5 +15,5 @@ --> - - \ No newline at end of file + + diff --git a/src/Assets/Icon/ic-info-filled.svg b/src/Assets/Icon/ic-info-filled.svg index 1da9359a6..f65ce117c 100644 --- a/src/Assets/Icon/ic-info-filled.svg +++ b/src/Assets/Icon/ic-info-filled.svg @@ -14,10 +14,7 @@ - limitations under the License. --> - - - - - - + + + diff --git a/src/Assets/Icon/ic-locked.svg b/src/Assets/Icon/ic-locked.svg new file mode 100644 index 000000000..6aa0323de --- /dev/null +++ b/src/Assets/Icon/ic-locked.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Assets/Icon/ic-success.svg b/src/Assets/Icon/ic-success.svg new file mode 100644 index 000000000..a3c2e3a26 --- /dev/null +++ b/src/Assets/Icon/ic-success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Assets/Icon/ic-vulnerability-not-found.svg b/src/Assets/Icon/ic-vulnerability-not-found.svg new file mode 100644 index 000000000..000bb7be6 --- /dev/null +++ b/src/Assets/Icon/ic-vulnerability-not-found.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Common/AppStatus/AppStatus.tsx b/src/Common/AppStatus/AppStatus.tsx index 0ad297d28..b9654779f 100644 --- a/src/Common/AppStatus/AppStatus.tsx +++ b/src/Common/AppStatus/AppStatus.tsx @@ -15,6 +15,7 @@ */ import Tippy from '@tippyjs/react' +import { ReactComponent as ICErrorCross } from '@Icons/ic-error-cross.svg' import { ReactComponent as InfoIcon } from '../../Assets/Icon/ic-info-outlined.svg' import { StatusConstants, YET_TO_RUN } from './constants' import { AppStatusType } from './types' @@ -52,7 +53,11 @@ export default function AppStatus({ const renderIcon = () => { if (iconClass) { - return + return iconClass === 'failed' || iconClass === 'error' ? ( + + ) : ( + + ) } if (isVirtualEnv) { return ( diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 682116c48..ebb957440 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -15,6 +15,7 @@ */ import { RegistryTypeDetailType } from './Types' +import { getContainerRegistryIcon } from './utils' export const FALLBACK_REQUEST_TIMEOUT = 60000 export const Host = window?.__ORCHESTRATOR_ROOT__ ?? '/orchestrator' @@ -207,6 +208,7 @@ export const REGISTRY_TYPE_MAP: Record = { defaultValue: '', placeholder: '', }, + startIcon: getContainerRegistryIcon('ecr'), }, 'docker-hub': { value: 'docker-hub', @@ -230,6 +232,7 @@ export const REGISTRY_TYPE_MAP: Record = { defaultValue: '', placeholder: '', }, + startIcon: getContainerRegistryIcon('docker-hub'), }, acr: { value: 'acr', @@ -254,6 +257,7 @@ export const REGISTRY_TYPE_MAP: Record = { defaultValue: '', placeholder: '', }, + startIcon: getContainerRegistryIcon('acr'), }, 'artifact-registry': { value: 'artifact-registry', @@ -277,6 +281,7 @@ export const REGISTRY_TYPE_MAP: Record = { defaultValue: '', placeholder: 'Paste json file content here', }, + startIcon: getContainerRegistryIcon('artifact-registry'), }, gcr: { value: 'gcr', @@ -300,6 +305,7 @@ export const REGISTRY_TYPE_MAP: Record = { defaultValue: '', placeholder: 'Paste json file content here', }, + startIcon: getContainerRegistryIcon('gcr'), }, quay: { value: 'quay', @@ -323,6 +329,7 @@ export const REGISTRY_TYPE_MAP: Record = { defaultValue: '', placeholder: '', }, + startIcon: getContainerRegistryIcon('quay'), }, other: { value: 'other', @@ -346,6 +353,7 @@ export const REGISTRY_TYPE_MAP: Record = { defaultValue: '', placeholder: '', }, + startIcon: getContainerRegistryIcon('other'), }, } @@ -540,3 +548,6 @@ export const VULNERABILITIES_SORT_PRIORITY = { low: 4, unknown: 5, } + +// TODO: might not work need to verify +export const IS_PLATFORM_MAC_OS = window.navigator.userAgent.toUpperCase().includes('MAC') diff --git a/src/Common/CustomInput/CustomInput.tsx b/src/Common/CustomInput/CustomInput.tsx index d1722a7cc..00c71316f 100644 --- a/src/Common/CustomInput/CustomInput.tsx +++ b/src/Common/CustomInput/CustomInput.tsx @@ -37,7 +37,7 @@ export const CustomInput = ({ rootClassName = '', autoComplete = 'off', helperText = '', - handleOnBlur, + onBlur, readOnly = false, noTrim = false, onKeyPress, @@ -66,15 +66,15 @@ export const CustomInput = ({ return error } - const onBlur = (event) => { + const handleOnBlur = (event) => { // NOTE: This is to prevent the input from being trimmed when the user do not want to trim the input if (!noTrim) { event.stopPropagation() event.target.value = event.target.value?.trim() onChange(event) } - if (typeof handleOnBlur === 'function') { - handleOnBlur(event) + if (typeof onBlur === 'function') { + onBlur(event) } } @@ -127,7 +127,7 @@ export const CustomInput = ({ e.persist() onChange(e) }} - onBlur={onBlur} + onBlur={handleOnBlur} onFocus={onFocus} placeholder={placeholder} value={value} diff --git a/src/Common/CustomInput/Types.ts b/src/Common/CustomInput/Types.ts index 5f6193b09..e310bf1c1 100644 --- a/src/Common/CustomInput/Types.ts +++ b/src/Common/CustomInput/Types.ts @@ -34,7 +34,7 @@ export interface CustomInputProps { rootClassName?: string error?: string[] | string helperText?: ReactNode - handleOnBlur?: (e) => void + onBlur?: (e) => void readOnly?: boolean noTrim?: boolean onKeyPress?: (e) => void diff --git a/src/Common/CustomTagSelector/TagDetails.tsx b/src/Common/CustomTagSelector/TagDetails.tsx index 94016e337..8b221668e 100644 --- a/src/Common/CustomTagSelector/TagDetails.tsx +++ b/src/Common/CustomTagSelector/TagDetails.tsx @@ -60,7 +60,7 @@ export const TagDetails = ({ {!hidePropagateTag && (
diff --git a/src/Common/DeleteComponentModal/DeleteComponent.tsx b/src/Common/DeleteComponentModal/DeleteComponent.tsx index d139138cf..f3f22d931 100644 --- a/src/Common/DeleteComponentModal/DeleteComponent.tsx +++ b/src/Common/DeleteComponentModal/DeleteComponent.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ -import React, { useState } from 'react' +import { useState } from 'react' import { useHistory } from 'react-router-dom' -import { toast } from 'react-toastify' import info from '../../Assets/Icon/ic-info-filled.svg' import { ConfirmationDialog, DeleteDialog } from '../Dialogs' import { ServerErrors } from '../ServerError' import { DeleteComponentProps } from './types' +import { ToastManager, ToastVariantType } from '@Shared/Services' +import { showError } from '@Common/Helper' const DeleteComponent = ({ setDeleting, @@ -36,14 +37,19 @@ const DeleteComponent = ({ configuration = '', closeCustomComponent, }: DeleteComponentProps) => { + const [isDeleting, setIsDeleting] = useState(false) const [showCannotDeleteDialogModal, setCannotDeleteDialogModal] = useState(false) const { push } = useHistory() async function handleDelete() { - setDeleting(true) + setDeleting?.(true) + setIsDeleting(true) try { await deleteComponent(payload) - toast.success('Successfully deleted') + ToastManager.showToast({ + variant: ToastVariantType.success, + description: 'Successfully deleted', + }) toggleConfirmation(false) if (redirectTo) { push(url) @@ -56,9 +62,12 @@ const DeleteComponent = ({ } catch (serverError) { if (serverError instanceof ServerErrors && serverError.code === 500) { setCannotDeleteDialogModal(true) + } else { + showError(serverError) } } finally { - setDeleting(false) + setDeleting?.(false) + setIsDeleting(false) } } @@ -89,6 +98,7 @@ const DeleteComponent = ({ delete={handleDelete} closeDelete={() => toggleConfirmation(false)} dataTestId="delete-dialog" + apiCallInProgress={isDeleting} >

Are you sure you want to delete this {configuration || component}?

diff --git a/src/Common/DeleteComponentModal/types.ts b/src/Common/DeleteComponentModal/types.ts index 6ec69083e..7a1fceef4 100644 --- a/src/Common/DeleteComponentModal/types.ts +++ b/src/Common/DeleteComponentModal/types.ts @@ -15,7 +15,10 @@ */ export interface DeleteComponentProps { - setDeleting: (boolean) => void + /** + * @deprecated - Delete component internally handles loading for the `Delete Button`. + */ + setDeleting?: (boolean) => void toggleConfirmation: any deleteComponent: (any) => Promise title: string diff --git a/src/Common/Drawer/Drawer.tsx b/src/Common/Drawer/Drawer.tsx index 8e7a56b6c..b047a5298 100644 --- a/src/Common/Drawer/Drawer.tsx +++ b/src/Common/Drawer/Drawer.tsx @@ -41,6 +41,7 @@ export const Drawer = ({ maxWidth, parentClassName, onEscape, + onClose, }: drawerInterface) => { const drawerRef = useRef(null) useEffect(() => { @@ -61,7 +62,7 @@ export const Drawer = ({ style['--height'] = height } return ( - + diff --git a/src/Common/GenericDescription/GenericDescription.tsx b/src/Common/GenericDescription/GenericDescription.tsx index 0c2e727a3..75c7f9399 100644 --- a/src/Common/GenericDescription/GenericDescription.tsx +++ b/src/Common/GenericDescription/GenericDescription.tsx @@ -18,7 +18,6 @@ import { useState, useRef, useEffect } from 'react' import Tippy from '@tippyjs/react' import ReactMde from 'react-mde' import 'react-mde/lib/styles/css/react-mde-all.css' -import { toast } from 'react-toastify' import moment from 'moment' import Markdown from '../Markdown/MarkDown' import { DATE_TIME_FORMATS, deepEqual, showError } from '..' @@ -31,7 +30,7 @@ import { MARKDOWN_EDITOR_COMMAND_TITLE, MARKDOWN_EDITOR_COMMAND_ICON_TIPPY_CONTENT, } from '../Markdown/constant' -import { ButtonWithLoader } from '../../Shared' +import { ButtonWithLoader, ToastManager, ToastVariantType } from '../../Shared' import { ReactComponent as HeaderIcon } from '../../Assets/Icon/ic-header.svg' import { ReactComponent as BoldIcon } from '../../Assets/Icon/ic-bold.svg' import { ReactComponent as ItalicIcon } from '../../Assets/Icon/ic-italic.svg' @@ -73,7 +72,10 @@ const GenericDescription = ({ const validateDescriptionText = (description: string): boolean => { let isValid = true if (description.length === 0) { - toast.error(DESCRIPTION_EMPTY_ERROR_MSG) + ToastManager.showToast({ + variant: ToastVariantType.error, + description: DESCRIPTION_EMPTY_ERROR_MSG, + }) isValid = false } return isValid diff --git a/src/Common/Helper.tsx b/src/Common/Helper.tsx index 86e3207a3..46dc671b0 100644 --- a/src/Common/Helper.tsx +++ b/src/Common/Helper.tsx @@ -21,26 +21,27 @@ import { components } from 'react-select' import * as Sentry from '@sentry/browser' import moment from 'moment' import { useLocation } from 'react-router-dom' -import { toast } from 'react-toastify' import YAML from 'yaml' -import { ERROR_EMPTY_SCREEN, SortingOrder, EXCLUDED_FALSY_VALUES, DISCORD_LINK, ZERO_TIME_STRING } from './Constants' +import { deepEquals } from '@rjsf/utils' +import { + ERROR_EMPTY_SCREEN, + SortingOrder, + EXCLUDED_FALSY_VALUES, + DISCORD_LINK, + ZERO_TIME_STRING, + TOAST_ACCESS_DENIED, +} from './Constants' import { ServerErrors } from './ServerError' -import { toastAccessDenied } from './ToastBody' import { AsyncOptions, AsyncState, UseSearchString } from './Types' -import { scrollableInterface, DATE_TIME_FORMAT_STRING } from '../Shared' +import { + scrollableInterface, + DATE_TIME_FORMAT_STRING, + ToastManager, + ToastVariantType, + versionComparatorBySortOrder, +} from '../Shared' import { ReactComponent as ArrowDown } from '../Assets/Icon/ic-chevron-down.svg' -toast.configure({ - autoClose: 3000, - hideProgressBar: true, - pauseOnHover: true, - pauseOnFocusLoss: true, - closeOnClick: false, - newestOnTop: true, - toastClassName: 'devtron-toast', - bodyClassName: 'devtron-toast__body', -}) - export function showError(serverError, showToastOnUnknownError = true, hideAccessError = false) { if (serverError instanceof ServerErrors && Array.isArray(serverError.errors)) { serverError.errors.map(({ userMessage, internalMessage }) => { @@ -49,10 +50,16 @@ export function showError(serverError, showToastOnUnknownError = true, hideAcces (userMessage === ERROR_EMPTY_SCREEN.UNAUTHORIZED || userMessage === ERROR_EMPTY_SCREEN.FORBIDDEN) ) { if (!hideAccessError) { - toastAccessDenied() + ToastManager.showToast({ + variant: ToastVariantType.notAuthorized, + description: TOAST_ACCESS_DENIED.SUBTITLE, + }) } } else { - toast.error(userMessage || internalMessage) + ToastManager.showToast({ + variant: ToastVariantType.error, + description: userMessage || internalMessage, + }) } }) } else { @@ -62,9 +69,15 @@ export function showError(serverError, showToastOnUnknownError = true, hideAcces if (showToastOnUnknownError) { if (serverError.message) { - toast.error(serverError.message) + ToastManager.showToast({ + variant: ToastVariantType.error, + description: serverError.message, + }) } else { - toast.error('Some Error Occurred') + ToastManager.showToast({ + variant: ToastVariantType.error, + description: 'Some Error Occurred', + }) } } } @@ -193,110 +206,6 @@ export function getCookie(sKey) { ) } -export function useForm(stateSchema, validationSchema = {}, callback) { - const [state, setState] = useState(stateSchema) - const [disable, setDisable] = useState(true) - const [isDirty, setIsDirty] = useState(false) - - // Disable button in initial render. - useEffect(() => { - setDisable(true) - }, []) - - // For every changed in our state this will be fired - // To be able to disable the button - useEffect(() => { - if (isDirty) { - setDisable(validateState(state)) - } - }, [state, isDirty]) - - // Used to disable submit button if there's an error in state - // or the required field in state has no value. - // Wrapped in useCallback to cached the function to avoid intensive memory leaked - // in every re-render in component - const validateState = useCallback( - (state) => { - // check errors in all fields - const hasErrorInState = Object.keys(validationSchema).some((key) => { - const isInputFieldRequired = validationSchema[key].required - const stateValue = state[key].value // state value - const stateError = state[key].error // state error - return (isInputFieldRequired && !stateValue) || stateError - }) - return hasErrorInState - }, - [state, validationSchema], - ) - - function validateField(name, value): string | string[] { - if (validationSchema[name].required) { - if (!value) { - return 'This is a required field.' - } - } - - function _validateSingleValidator(validator, value) { - if (value && !validator.regex.test(value)) { - return false - } - return true - } - - // single validator - const _validator = validationSchema[name].validator - if (_validator && typeof _validator === 'object') { - if (!_validateSingleValidator(_validator, value)) { - return _validator.error - } - } - - // multiple validators - const _validators = validationSchema[name].validators - if (_validators && typeof _validators === 'object' && Array.isArray(_validators)) { - const errors = [] - _validators.forEach((_validator) => { - if (!_validateSingleValidator(_validator, value)) { - errors.push(_validator.error) - } - }) - if (errors.length > 0) { - return errors - } - } - - return '' - } - - const handleOnChange = useCallback( - (event) => { - setIsDirty(true) - - const { name, value } = event.target - const error = validateField(name, value) - setState((prevState) => ({ - ...prevState, - [name]: { value, error }, - })) - }, - [validationSchema], - ) - - const handleOnSubmit = (event) => { - event.preventDefault() - const newState = Object.keys(validationSchema).reduce((agg, curr) => { - agg[curr] = { ...state[curr], error: validateField(curr, state[curr].value) } - return agg - }, state) - if (!validateState(newState)) { - callback(state) - } else { - setState({ ...newState }) - } - } - return { state, disable, handleOnChange, handleOnSubmit } -} - export function handleUTCTime(ts: string, isRelativeTime = false) { let timestamp = '' try { @@ -465,7 +374,10 @@ export function copyToClipboard(str, callback = noop) { callback() }) .catch(() => { - toast.error('Failed to copy to clipboard') + ToastManager.showToast({ + variant: ToastVariantType.error, + description: 'Failed to copy to clipboard', + }) }) } else { unsecureCopyToClipboard(str, callback) @@ -618,6 +530,7 @@ export const getFilteredChartVersions = (charts, selectedChartType) => // Filter chart versions based on selected chart type charts .filter((item) => item?.chartType === selectedChartType.value) + .sort((a, b) => versionComparatorBySortOrder(a?.chartVersion, b?.chartVersion)) .map((item) => ({ value: item?.chartVersion, label: item?.chartVersion, @@ -816,28 +729,7 @@ export const compareObjectLength = (objA: any, objB: any): boolean => { * Return deep copy of the object */ export function deepEqual(configA: any, configB: any): boolean { - try { - if (configA === configB) { - return true - } - if ((configA && !configB) || (!configA && configB) || !compareObjectLength(configA, configB)) { - return false - } - let isEqual = true - for (const idx in configA) { - if (!isEqual) { - break - } else if (typeof configA[idx] === 'object' && typeof configB[idx] === 'object') { - isEqual = deepEqual(configA[idx], configB[idx]) - } else if (configA[idx] !== configB[idx]) { - isEqual = false - } - } - return isEqual - } catch (err) { - showError(err) - return true - } + return deepEquals(configA, configB) } export function shallowEqual(objA, objB) { diff --git a/src/Common/ImageTags.tsx b/src/Common/ImageTags.tsx index 3610d5654..479bd31ce 100644 --- a/src/Common/ImageTags.tsx +++ b/src/Common/ImageTags.tsx @@ -15,7 +15,6 @@ */ import { useEffect, useRef, useState } from 'react' -import { toast } from 'react-toastify' import Tippy from '@tippyjs/react' import { ReactComponent as Add } from '../Assets/Icon/ic-add.svg' import { ReactComponent as Close } from '../Assets/Icon/ic-cross.svg' @@ -30,7 +29,7 @@ import { ImageButtonType, ImageTaggingContainerType, ReleaseTag } from './ImageT import { showError, stopPropagation } from './Helper' import { setImageTags } from './Common.service' import { Progressing } from './Progressing' -import { InfoIconTippy } from '../Shared' +import { InfoIconTippy, ToastManager, ToastVariantType } from '../Shared' export const ImageTagsContainer = ({ // Setting it to zero in case of external pipeline @@ -257,7 +256,10 @@ export const ImageTagsContainer = ({ .catch((err) => { // Fix toast message if (err.errors?.[0]?.userMessage?.appReleaseTags?.length) { - toast.error(err.errors?.[0]?.internalMessage) + ToastManager.showToast({ + variant: ToastVariantType.error, + description: err.errors?.[0]?.internalMessage, + }) errorStateHandling(err.errors) } else { showError(err) @@ -395,7 +397,7 @@ export const ImageTagsContainer = ({
{showTagsWarning && (
- + Tags cannot be edited/removed later
)} diff --git a/src/Common/RJSF/Form.tsx b/src/Common/RJSF/Form.tsx index a92ab7929..b6446b758 100644 --- a/src/Common/RJSF/Form.tsx +++ b/src/Common/RJSF/Form.tsx @@ -39,6 +39,7 @@ export const RJSFForm = (props: FormProps) => ( ...templates, ...props.templates, }} + formContext={props.formData} widgets={{ ...widgets, ...props.widgets }} translateString={translateString} /> diff --git a/src/Common/RJSF/templates/ObjectFieldTemplate.tsx b/src/Common/RJSF/templates/ObjectFieldTemplate.tsx index c7d16c43b..9bfca2287 100644 --- a/src/Common/RJSF/templates/ObjectFieldTemplate.tsx +++ b/src/Common/RJSF/templates/ObjectFieldTemplate.tsx @@ -14,12 +14,14 @@ * limitations under the License. */ -import { ObjectFieldTemplateProps, canExpand, titleId } from '@rjsf/utils' +import { ObjectFieldTemplateProps, canExpand, titleId, deepEquals } from '@rjsf/utils' import { JSONPath } from 'jsonpath-plus' +import { convertJSONPointerToJSONPath } from '@Common/Helper' import { FieldRowWithLabel } from '../common/FieldRow' import { TitleField } from './TitleField' import { AddButton } from './ButtonTemplates' import { RJSFFormSchema } from '../types' +import { parseSchemaHiddenType } from '../utils' const Field = ({ disabled, @@ -33,6 +35,7 @@ const Field = ({ schema, title, uiSchema, + formContext, }: ObjectFieldTemplateProps) => { const hasAdditionalProperties = !!schema.additionalProperties @@ -54,8 +57,19 @@ const Field = ({ return true } try { - const value = JSONPath({ path: hiddenSchemaProp.match, json: formData })?.[0] - const isHidden = value === undefined || hiddenSchemaProp.condition === value + const hiddenSchema = parseSchemaHiddenType(hiddenSchemaProp) + if (!hiddenSchema.path) { + throw new Error('Empty path property of hidden descriptor field') + } + if (!hiddenSchema.path.match(/^\/\w+(\/\w+)*$/g)) { + throw new Error('Provided path is not a valid JSON pointer') + } + // NOTE: formContext is the formData passed to RJSFForm + const value = JSONPath({ + path: convertJSONPointerToJSONPath(hiddenSchema.path), + json: formContext, + })?.[0] + const isHidden = value === undefined || deepEquals(hiddenSchema.value, value) return !isHidden } catch { return true diff --git a/src/Common/RJSF/types.ts b/src/Common/RJSF/types.ts index e32337f9e..f3f14598d 100644 --- a/src/Common/RJSF/types.ts +++ b/src/Common/RJSF/types.ts @@ -20,14 +20,22 @@ import { StrictRJSFSchema } from '@rjsf/utils' export type FormProps = Omit, 'validator'> -interface Hidden { - condition: boolean - match: string +export interface MetaHiddenType { + value: any + path: string } +export type HiddenType = + | MetaHiddenType + | { + condition: any + value: string + } + | string + export interface RJSFFormSchema extends StrictRJSFSchema { properties: { [key: string]: RJSFFormSchema } - hidden: Hidden + hidden: HiddenType } diff --git a/src/Common/RJSF/utils.tsx b/src/Common/RJSF/utils.tsx index ef95d398c..e4b08be5b 100644 --- a/src/Common/RJSF/utils.tsx +++ b/src/Common/RJSF/utils.tsx @@ -15,6 +15,7 @@ */ import { TranslatableString, englishStringTranslator } from '@rjsf/utils' +import { HiddenType, MetaHiddenType } from './types' /** * Override for the TranslatableString from RJSF @@ -135,3 +136,59 @@ export const getInferredTypeFromValueType = (value) => { return 'null' } } + +const conformPathToPointers = (path: string): string => { + if (!path) { + return '' + } + const trimmedPath = path.trim() + const isSlashSeparatedPathMissingBeginSlash = trimmedPath.match(/^\w+(\/\w+)*$/g) + if (isSlashSeparatedPathMissingBeginSlash) { + return `/${trimmedPath}` + } + const isDotSeparatedPath = trimmedPath.match(/^\w+(\.\w+)*$/g) + if (isDotSeparatedPath) { + // NOTE: replacing dots with forward slash (/) + return `/${trimmedPath.replaceAll(/\./g, '/')}` + } + return trimmedPath +} + +const emptyMetaHiddenTypeInstance: MetaHiddenType = { + value: false, + path: '', +} + +export const parseSchemaHiddenType = (hiddenSchema: HiddenType): MetaHiddenType => { + if (!hiddenSchema) { + return null + } + const clone = structuredClone(hiddenSchema) + if (typeof clone === 'string') { + return { + value: true, + path: conformPathToPointers(clone), + } + } + if (typeof clone !== 'object') { + return structuredClone(emptyMetaHiddenTypeInstance) + } + if ( + Object.hasOwn(clone, 'condition') && + 'condition' in clone && + Object.hasOwn(clone, 'value') && + 'value' in clone + ) { + return { + value: clone.condition, + path: conformPathToPointers(clone.value), + } + } + if (Object.hasOwn(clone, 'value') && 'value' in clone && Object.hasOwn(clone, 'path') && 'path' in clone) { + return { + value: clone.value, + path: conformPathToPointers(clone.path), + } + } + return structuredClone(emptyMetaHiddenTypeInstance) +} diff --git a/src/Common/Security/ScanVulnerabilitiesTable.tsx b/src/Common/Security/ScanVulnerabilitiesTable.tsx deleted file mode 100644 index 8af06d4ee..000000000 --- a/src/Common/Security/ScanVulnerabilitiesTable.tsx +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import DOMPurify from 'dompurify' -import { ScanVulnerabilitiesTableProps, VulnerabilityType } from '../Types' -import './scanVulnerabilities.css' -import { SortableTableHeaderCell } from '@Common/SortableTableHeaderCell' -import { useMemo } from 'react' -import { numberComparatorBySortOrder, stringComparatorBySortOrder } from '@Shared/Helpers' -import { VulnerabilitiesTableSortKeys } from './types' -import { VULNERABILITIES_SORT_PRIORITY } from '@Common/Constants' -import { useStateFilters } from '@Common/Hooks' - -// To be replaced with Scan V2 Modal Table -export default function ScanVulnerabilitiesTable({ - vulnerabilities, - hidePolicy, - shouldStick, -}: ScanVulnerabilitiesTableProps) { - const { sortBy, sortOrder, handleSorting } = useStateFilters({ - initialSortKey: VulnerabilitiesTableSortKeys.SEVERITY, - }) - - const sortedVulnerabilities = useMemo( - () => - vulnerabilities.sort((a, b) => { - if (sortBy === VulnerabilitiesTableSortKeys.PACKAGE) { - return stringComparatorBySortOrder(a.package, b.package, sortOrder) - } - - return numberComparatorBySortOrder( - VULNERABILITIES_SORT_PRIORITY[a.severity], - VULNERABILITIES_SORT_PRIORITY[b.severity], - sortOrder, - ) - }), - [sortBy, sortOrder, vulnerabilities], - ) - - const triggerSeveritySorting = () => handleSorting(VulnerabilitiesTableSortKeys.SEVERITY) - const triggerPackageSorting = () => handleSorting(VulnerabilitiesTableSortKeys.PACKAGE) - - const renderRow = (vulnerability: VulnerabilityType) => ( - { - window.open(`https://cve.mitre.org/cgi-bin/cvename.cgi?name=${vulnerability.name}`, '_blank') - }} - > - - - {vulnerability.name} - - - - - {vulnerability.severity} - - - {vulnerability.package} - {/* QUERY: Do we need to add DOMPurify at any other key for this table as well? */} - -

- - {vulnerability.fixedVersion} - {!hidePolicy && ( - - {vulnerability.policy?.toLowerCase()} - - )} - - ) - - return ( - - - - - - - - - {!hidePolicy && } - - {sortedVulnerabilities.map((vulnerability) => renderRow(vulnerability))} - -
CVE - - - - Current VersionFixed In VersionPolicy
- ) -} diff --git a/src/Common/Security/scanVulnerabilities.css b/src/Common/Security/scanVulnerabilities.css deleted file mode 100644 index 3d00a2df0..000000000 --- a/src/Common/Security/scanVulnerabilities.css +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -.modal-body--scan-details { - position: fixed; - top: 0; - right: 0; - background: var(--N000); - width: 800px; -} - -.security-tab__table { - width: 100%; -} - -.security-tab__table-header { - border-bottom: solid 1px var(--N200); -} - -.security-cell-header { - font-size: 12px; - font-weight: 600; - line-height: 1.5; - letter-spacing: normal; - text-transform: uppercase; - color: var(--N500); -} - -.dc__security-tab__table-row { - font-size: 13px; - font-weight: normal; - line-height: 1.54; - letter-spacing: normal; - color: var(--N900); - box-shadow: inset 0 -1px 0 0 #f5f5f5; -} - -.security-tab__cell-cve, -.security-tab__cell-severity, -.security-tab__cell-package, -.security-tab__cell-current-ver, -.security-tab__cell-fixed-ver, -.security-tab__cell-policy { - padding: 10px; - max-width: 120px; - word-wrap: break-word; -} - -.security-tab__cell-cve { - padding-left: 0px; - text-align: left; - width: 20%; -} - -.security-tab__cell-policy { - padding-right: 0px; -} - -.security-tab__cell-policy--whitelist, -.security-tab__cell-policy--whitelisted { - color: var(--G500); - text-transform: capitalize; -} - -.security-tab__cell-policy--block { - color: var(--R500); - text-transform: capitalize; -} - -.scanned-object__results { - margin: 0 20px; -} diff --git a/src/Common/Security/types.tsx b/src/Common/Security/types.tsx deleted file mode 100644 index 8ee13fc2d..000000000 --- a/src/Common/Security/types.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export enum VulnerabilitiesTableSortKeys { - SEVERITY = 'severity', - PACKAGE = 'package', -} diff --git a/src/Common/ToastBody.tsx b/src/Common/ToastBody.tsx deleted file mode 100644 index a1135be23..000000000 --- a/src/Common/ToastBody.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react' -import { toast } from 'react-toastify' -import { TOAST_ACCESS_DENIED } from './Constants' - -export class ToastBody extends React.Component<{ - title: string - subtitle?: string -}> { - render() { - return ( -

-
{this.props.title}
- {this.props.subtitle &&
{this.props.subtitle}
} -
- ) - } -} - -export class ToastBody3 extends React.Component<{ - text: string - onClick: (...args) => void - buttonText: string -}> { - render() { - return ( -
- {this.props.text} - -
- ) - } -} - -export class ToastBodyWithButton extends React.Component<{ - title: string - subtitle?: string - onClick: (...args) => void - buttonText: string -}> { - render() { - return ( -
-
{this.props.title}
- {this.props.subtitle &&
{this.props.subtitle}
} - -
- ) - } -} - -export const toastAccessDenied = (title?: string, subtitle?: string) => - toast.info( - , - { - className: 'devtron-toast unauthorized', - }, - ) diff --git a/src/Common/Tooltip/ShortcutKeyComboTooltipContent.tsx b/src/Common/Tooltip/ShortcutKeyComboTooltipContent.tsx new file mode 100644 index 000000000..f0d595940 --- /dev/null +++ b/src/Common/Tooltip/ShortcutKeyComboTooltipContent.tsx @@ -0,0 +1,18 @@ +import { KEYBOARD_KEYS_MAP, TooltipProps } from './types' + +const ShortcutKeyComboTooltipContent = ({ text, combo }: TooltipProps['shortcutKeyCombo']) => ( +
+ {text} + {!!combo?.length && ( +
+ {combo.map((key) => ( + + {KEYBOARD_KEYS_MAP[key]} + + ))} +
+ )} +
+) + +export default ShortcutKeyComboTooltipContent diff --git a/src/Common/Tooltip/Tooltip.tsx b/src/Common/Tooltip/Tooltip.tsx index 12ede89c7..78071d8cd 100644 --- a/src/Common/Tooltip/Tooltip.tsx +++ b/src/Common/Tooltip/Tooltip.tsx @@ -1,37 +1,48 @@ -import { useCallback, useState, cloneElement } from 'react' +import { useState, cloneElement } from 'react' import TippyJS from '@tippyjs/react' import { TooltipProps } from './types' +import ShortcutKeyComboTooltipContent from './ShortcutKeyComboTooltipContent' +import './styles.scss' const Tooltip = ({ + shortcutKeyCombo, alwaysShowTippyOnHover, - // NOTE: if alwaysShowTippyOnHover is being passed by user don't apply truncation logic at all - showOnTruncate = alwaysShowTippyOnHover === undefined, + // NOTE: if alwaysShowTippyOnHover or shortcutKeyCombo are being passed by user don't apply truncation logic at all + showOnTruncate = alwaysShowTippyOnHover === undefined && shortcutKeyCombo === undefined, wordBreak = true, children: child, ...rest }: TooltipProps) => { const [isTextTruncated, setIsTextTruncated] = useState(false) - const refCallback = useCallback((node: HTMLDivElement) => { - if (node) { - // NOTE: for line-clamp we need to check scrollHeight against clientHeight since orientation - // is set to vertical through -webkit-box-orient prop that is needed for line-clamp to work - // see: https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-line-clamp - setIsTextTruncated(node.scrollWidth > node.clientWidth || node.scrollHeight > node.clientHeight) + const handleMouseEnterEvent: React.MouseEventHandler = (event) => { + const { currentTarget: node } = event + const isTextOverflowing = node.scrollWidth > node.clientWidth || node.scrollHeight > node.clientHeight + if (isTextOverflowing && !isTextTruncated) { + setIsTextTruncated(true) + } else if (!isTextOverflowing && isTextTruncated) { + setIsTextTruncated(false) } - }, []) + } - return (!isTextTruncated || !showOnTruncate) && !alwaysShowTippyOnHover ? ( - cloneElement(child, { ...child.props, ref: refCallback }) - ) : ( + const showTooltipWhenShortcutKeyComboProvided = + !!shortcutKeyCombo && (alwaysShowTippyOnHover === undefined || alwaysShowTippyOnHover) + const showTooltipOnTruncate = showOnTruncate && isTextTruncated + + return showTooltipOnTruncate || showTooltipWhenShortcutKeyComboProvided || alwaysShowTippyOnHover ? ( } : {})} + className={`${shortcutKeyCombo ? 'shortcut-keys__tippy' : 'default-tt'} ${wordBreak ? 'dc__word-break-all' : ''} dc__mxw-200 ${rest.className ?? ''}`} > - {cloneElement(child, { ...child.props, ref: refCallback })} + {cloneElement(child, { ...child.props, onMouseEnter: handleMouseEnterEvent })} + ) : ( + cloneElement(child, { ...child.props, onMouseEnter: handleMouseEnterEvent }) ) } diff --git a/src/Common/Tooltip/constants.tsx b/src/Common/Tooltip/constants.tsx new file mode 100644 index 000000000..1d303027a --- /dev/null +++ b/src/Common/Tooltip/constants.tsx @@ -0,0 +1,3 @@ +export const TOOLTIP_CONTENTS = { + INVALID_INPUT: 'Valid input is required for all mandatory fields.', +} diff --git a/src/Common/Tooltip/index.ts b/src/Common/Tooltip/index.ts index ef0c1eef3..ec6468590 100644 --- a/src/Common/Tooltip/index.ts +++ b/src/Common/Tooltip/index.ts @@ -1 +1,3 @@ export { default as Tooltip } from './Tooltip' +export type { SupportedKeyboardKeysType } from './types' +export { TOOLTIP_CONTENTS } from './constants' diff --git a/src/Common/Tooltip/styles.scss b/src/Common/Tooltip/styles.scss new file mode 100644 index 000000000..8a9727876 --- /dev/null +++ b/src/Common/Tooltip/styles.scss @@ -0,0 +1,19 @@ +.tippy-box.shortcut-keys__tippy { + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.20); + background: var(--N900); + + & > .tippy-content { + padding: 0; + } + + & .shortcut-keys__chip { + border-radius: 4px; + border: 0.5px solid rgba(255, 255, 255, 0.20); + background: var(--N800); + box-shadow: 0px 2px 0px 0px rgba(0, 0, 0, 0.20); + padding: 0 2px; + min-width: 16px; + max-width: 250px; + } +} diff --git a/src/Common/Tooltip/types.ts b/src/Common/Tooltip/types.ts index 0e4a7013b..ea04cfd7d 100644 --- a/src/Common/Tooltip/types.ts +++ b/src/Common/Tooltip/types.ts @@ -1,5 +1,15 @@ +import { IS_PLATFORM_MAC_OS } from '@Common/Constants' import { TippyProps } from '@tippyjs/react' +export const KEYBOARD_KEYS_MAP = { + Control: IS_PLATFORM_MAC_OS ? '⌘' : 'Ctrl', + Shift: '⇧', + F: 'F', + E: 'E', +} as const + +export type SupportedKeyboardKeysType = keyof typeof KEYBOARD_KEYS_MAP + type BaseTooltipProps = | { /** @@ -12,6 +22,12 @@ type BaseTooltipProps = * @default false */ alwaysShowTippyOnHover?: never + /** + * If true, use the common styling for shortcuts + * @default undefined + */ + shortcutKeyCombo?: never + content: TippyProps['content'] } | { /** @@ -21,9 +37,36 @@ type BaseTooltipProps = showOnTruncate?: never /** * If true, wrap with tippy irrespective of other options + * @default true + */ + alwaysShowTippyOnHover: boolean + /** + * If true, use the common styling for shortcuts + * @default undefined + */ + shortcutKeyCombo?: never + content: TippyProps['content'] + } + | { + /** + * If true, show tippy on truncate + * @default false + */ + showOnTruncate?: never + /** + * If showOnTruncate is defined this prop doesn't work * @default false */ alwaysShowTippyOnHover?: boolean + /** + * If true, use the common styling for shortcuts + * @default undefined + */ + shortcutKeyCombo: { + text: string + combo: SupportedKeyboardKeysType[] + } + content?: never } export type TooltipProps = BaseTooltipProps & diff --git a/src/Common/Types.ts b/src/Common/Types.ts index a86257e24..690b9ac2d 100644 --- a/src/Common/Types.ts +++ b/src/Common/Types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { ReactNode, CSSProperties } from 'react' +import React, { ReactNode, CSSProperties, ReactElement } from 'react' import { Placement } from 'tippy.js' import { UserGroupDTO } from '@Pages/GlobalConfigurations' import { ImageComment, ReleaseTag } from './ImageTags.Types' @@ -639,12 +639,6 @@ export interface VulnerabilityType { url?: string } -export interface ScanVulnerabilitiesTableProps { - vulnerabilities: VulnerabilityType[] - hidePolicy?: boolean - shouldStick?: boolean -} - export interface MaterialInfo { revision: string modifiedTime: string | Date @@ -726,6 +720,7 @@ export interface RegistryTypeDetailType { registryURL: InputDetailType id: InputDetailType password: InputDetailType + startIcon: ReactElement } export interface UseSearchString { diff --git a/src/Common/index.ts b/src/Common/index.ts index c76ca1425..fcc817351 100644 --- a/src/Common/index.ts +++ b/src/Common/index.ts @@ -18,7 +18,6 @@ export * from './Constants' export * from './ServerError' export * from './Types' export * from './Api' -export * from './ToastBody' export { default as Reload } from './Reload' export { default as ErrorScreenManager } from './ErrorScreenManager' export { default as ErrorScreenNotAuthorized } from './ErrorScreenNotAuthorized' @@ -45,7 +44,6 @@ export { default as GenericFilterEmptyState } from './EmptyState/GenericFilterEm export * from './SearchBar' export * from './SortableTableHeaderCell' export { default as Toggle } from './Toggle/Toggle' -export { default as ScanVulnerabilitiesTable } from './Security/ScanVulnerabilitiesTable' export { default as StyledRadioGroup } from './RadioGroup/RadioGroup' export * from './CIPipeline.Types' export * from './Policy.Types' diff --git a/src/Common/utils.tsx b/src/Common/utils.tsx new file mode 100644 index 000000000..fdcd281df --- /dev/null +++ b/src/Common/utils.tsx @@ -0,0 +1,3 @@ +export const getContainerRegistryIcon = (registryValue: string): JSX.Element => ( +
+) diff --git a/src/Pages/GlobalConfigurations/BuildInfra/utils.tsx b/src/Pages/GlobalConfigurations/BuildInfra/utils.tsx index d747f0f61..b345c71e4 100644 --- a/src/Pages/GlobalConfigurations/BuildInfra/utils.tsx +++ b/src/Pages/GlobalConfigurations/BuildInfra/utils.tsx @@ -15,7 +15,6 @@ */ import { FormEvent, useEffect, useState } from 'react' -import { toast } from 'react-toastify' import { showError, useAsync } from '../../../Common' import { getBuildInfraProfileByName, createBuildInfraProfile, updateBuildInfraProfile } from './services' import { @@ -43,6 +42,8 @@ import { validateRequiredPositiveNumber, getCommonSelectStyle, validateRequiredPositiveInteger, + ToastVariantType, + ToastManager, } from '../../../Shared' export const validateRequestLimit = ({ @@ -419,7 +420,10 @@ export const useBuildInfraForm = ({ ).length > 0 if (hasErrors) { - toast.error(BUILD_INFRA_TEXT.INVALID_FORM_MESSAGE) + ToastManager.showToast({ + variant: ToastVariantType.error, + description: BUILD_INFRA_TEXT.INVALID_FORM_MESSAGE, + }) return } @@ -431,7 +435,10 @@ export const useBuildInfraForm = ({ await createBuildInfraProfile({ profileInput }) } setLoadingActionRequest(false) - toast.success(BUILD_INFRA_TEXT.getSubmitSuccessMessage(profileInput.name, editProfile)) + ToastManager.showToast({ + variant: ToastVariantType.success, + description: BUILD_INFRA_TEXT.getSubmitSuccessMessage(profileInput.name, editProfile), + }) if (handleSuccessRedirection) { handleSuccessRedirection() diff --git a/src/Pages/GlobalConfigurations/DeploymentCharts/utils.ts b/src/Pages/GlobalConfigurations/DeploymentCharts/utils.ts index b4c53781c..a585b2199 100644 --- a/src/Pages/GlobalConfigurations/DeploymentCharts/utils.ts +++ b/src/Pages/GlobalConfigurations/DeploymentCharts/utils.ts @@ -1,5 +1,4 @@ import { versionComparatorBySortOrder } from '@Shared/Helpers' -import { SortingOrder } from '@Common/Constants' import { DeploymentChartListDTO, DeploymentChartType, DEVTRON_DEPLOYMENT_CHART_NAMES } from './types' import fallbackGuiSchema from './basicViewSchema.json' @@ -31,7 +30,7 @@ export const convertDeploymentChartListToChartType = (data: DeploymentChartListD {} as Record, ) const result = Object.values(chartMap).map((element) => { - element.versions.sort((a, b) => versionComparatorBySortOrder(a, b, 'version', SortingOrder.DESC)) + element.versions.sort((a, b) => versionComparatorBySortOrder(a.version, b.version)) return element }) return result diff --git a/src/Shared/Components/BulkSelection/BulkSelectionProvider.tsx b/src/Shared/Components/BulkSelection/BulkSelectionProvider.tsx index 924b28bc3..38b563b05 100644 --- a/src/Shared/Components/BulkSelection/BulkSelectionProvider.tsx +++ b/src/Shared/Components/BulkSelection/BulkSelectionProvider.tsx @@ -15,7 +15,7 @@ */ import { createContext, useContext, useMemo, useState } from 'react' -import { toast } from 'react-toastify' +import { ToastManager, ToastVariantType } from '@Shared/Services' import { BULK_SELECTION_CONTEXT_ERROR, CLEAR_SELECTIONS_WARNING, @@ -92,7 +92,10 @@ export const BulkSelectionProvider = ({ break case BulkSelectionEvents.CLEAR_IDENTIFIERS_AFTER_ACROSS_SELECTION: { - toast.info(CLEAR_SELECTIONS_WARNING) + ToastManager.showToast({ + variant: ToastVariantType.info, + description: CLEAR_SELECTIONS_WARNING, + }) setIdentifiersAfterClear(identifiers, selectedIds) break } @@ -128,7 +131,10 @@ export const BulkSelectionProvider = ({ case BulkSelectionEvents.SELECT_ALL_ON_PAGE: { if (selectedIdentifiers[SELECT_ALL_ACROSS_PAGES_LOCATOR]) { - toast.info(CLEAR_SELECTIONS_WARNING) + ToastManager.showToast({ + variant: ToastVariantType.info, + description: CLEAR_SELECTIONS_WARNING, + }) } setIdentifiersAfterPageSelection(identifiers) diff --git a/src/Shared/Components/Button/Button.component.tsx b/src/Shared/Components/Button/Button.component.tsx index 4fba29715..e67b4272b 100644 --- a/src/Shared/Components/Button/Button.component.tsx +++ b/src/Shared/Components/Button/Button.component.tsx @@ -2,10 +2,10 @@ import { ButtonHTMLAttributes, PropsWithChildren } from 'react' import { Link, LinkProps } from 'react-router-dom' import { Progressing } from '@Common/Progressing' import { Tooltip } from '@Common/Tooltip' +import { TooltipProps } from '@Common/Tooltip/types' import { ComponentSizeType } from '@Shared/constants' import { ButtonComponentType, ButtonProps, ButtonStyleType, ButtonVariantType } from './types' -import { BUTTON_SIZE_TO_ICON_CLASS_NAME_MAP, BUTTON_SIZE_TO_LOADER_SIZE_MAP } from './constants' -import { getButtonDerivedClass } from './utils' +import { getButtonDerivedClass, getButtonIconClassName, getButtonLoaderSize } from './utils' import './button.scss' const ButtonElement = ({ @@ -27,9 +27,12 @@ const ButtonElement = ({ | 'tooltipProps' | 'dataTestId' | 'isLoading' + | 'ariaLabel' + | 'showAriaLabelInTippy' > & { className: string 'data-testid': ButtonProps['dataTestId'] + 'aria-label': ButtonProps['ariaLabel'] } >) => { if (component === ButtonComponentType.link) { @@ -113,6 +116,11 @@ const ButtonElement = ({ * ```tsx * + + ) ) - ) -} + }, +) export const Scroller = ({ scrollToTop, scrollToBottom, style }: ScrollerType): JSX.Element => (
- + - - + + - +
) diff --git a/src/Shared/Components/CICDHistory/LogsRenderer.scss b/src/Shared/Components/CICDHistory/LogsRenderer.scss index af6f7f0fc..11a5d1164 100644 --- a/src/Shared/Components/CICDHistory/LogsRenderer.scss +++ b/src/Shared/Components/CICDHistory/LogsRenderer.scss @@ -23,7 +23,7 @@ path:nth-child(1) { fill: none !important; } - + path:nth-child(2) { fill: var(--N400); } @@ -56,6 +56,10 @@ &__filters-border-bottom { border-bottom: 1px solid #2c3354; + + & > :not(:first-child) { + border-left: 1px solid #2C3354; + } } } } diff --git a/src/Shared/Components/CICDHistory/LogsRenderer.tsx b/src/Shared/Components/CICDHistory/LogsRenderer.tsx index e7ba2368d..a941ee18d 100644 --- a/src/Shared/Components/CICDHistory/LogsRenderer.tsx +++ b/src/Shared/Components/CICDHistory/LogsRenderer.tsx @@ -15,11 +15,14 @@ */ import { useParams } from 'react-router-dom' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import AnsiUp from 'ansi_up' import DOMPurify from 'dompurify' import { ANSI_UP_REGEX, ComponentSizeType } from '@Shared/constants' import { escapeRegExp } from '@Shared/Helpers' +import { ReactComponent as ICExpandAll } from '@Icons/ic-expand-all.svg' +import { ReactComponent as ICCollapseAll } from '@Icons/ic-collapse-all.svg' +import { withShortcut, IWithShortcut } from 'react-keybind' import { Progressing, Host, @@ -28,7 +31,7 @@ import { ROUTES, SearchBar, useUrlFilters, - stopPropagation, + Tooltip, } from '../../../Common' import LogStageAccordion from './LogStageAccordion' import { @@ -167,12 +170,13 @@ const useCIEventSource = (url: string, maxLength?: number): [string[], EventSour return [dataVal, eventSourceRef.current, logsNotAvailableError] } -export const LogsRenderer = ({ +const LogsRenderer = ({ triggerDetails, isBlobStorageConfigured, parentType, fullScreenView, -}: LogsRendererType): JSX.Element => { + shortcut, +}: LogsRendererType & IWithShortcut) => { const { pipelineId, envId, appId } = useParams() const logsURL = parentType === HistoryComponentType.CI @@ -186,6 +190,9 @@ export const LogsRenderer = ({ const [logsList, setLogsList] = useState([]) const { searchKey, handleSearch } = useUrlFilters() + const areAllStagesExpanded = useMemo(() => stageList.every((item) => item.isOpen), [stageList]) + const shortcutTippyText = areAllStagesExpanded ? 'Collapse all stages' : 'Expand all stages' + const areStagesAvailable = (window._env_.FEATURE_STEP_WISE_LOGS_ENABLE && streamDataList[0]?.startsWith(LOGS_STAGE_IDENTIFIER)) || false @@ -379,6 +386,28 @@ export const LogsRenderer = ({ // And for other cases we would use handleSearchEnter }, [streamDataList, areEventsProgressing]) + const handleToggleOpenAllStages = useCallback(() => { + setStageList((prev) => + prev.map((stage) => ({ + ...stage, + isOpen: !areAllStagesExpanded, + })), + ) + }, [areAllStagesExpanded]) + + useEffect(() => { + shortcut.registerShortcut( + handleToggleOpenAllStages, + ['e'], + 'ExpandCollapseLogStages', + 'Expand/Collapse all log stages', + ) + + return () => { + shortcut.unregisterShortcut(['e']) + } + }, [handleToggleOpenAllStages]) + const handleSearchEnter = (searchText: string) => { handleSearch(searchText) const newStageList = getStageListFromStreamData(searchText) @@ -413,11 +442,7 @@ export const LogsRenderer = ({ backgroundColor: '#0C1021', }} > -
+
+ + +
@@ -487,4 +533,4 @@ export const LogsRenderer = ({ : renderLogs() } -export default LogsRenderer +export default withShortcut(LogsRenderer) diff --git a/src/Shared/Components/CICDHistory/StatusFilterButtonComponent.scss b/src/Shared/Components/CICDHistory/StatusFilterButtonComponent.scss new file mode 100644 index 000000000..e5d16205e --- /dev/null +++ b/src/Shared/Components/CICDHistory/StatusFilterButtonComponent.scss @@ -0,0 +1,21 @@ +.gui-yaml-switch.status-filter-button { + &.radio-group { + .radio__item-label { + padding: 2px 8px; + display: flex; + align-items: center; + gap: 6px; + } + } + + &.with-menu-button { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + .radio:last-child > .radio__item-label { + border-right: none; + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + } + } +} diff --git a/src/Shared/Components/CICDHistory/StatusFilterButtonComponent.tsx b/src/Shared/Components/CICDHistory/StatusFilterButtonComponent.tsx index a8f7b6240..0ece827ea 100644 --- a/src/Shared/Components/CICDHistory/StatusFilterButtonComponent.tsx +++ b/src/Shared/Components/CICDHistory/StatusFilterButtonComponent.tsx @@ -16,19 +16,17 @@ /* eslint-disable eqeqeq */ import { useEffect, useState } from 'react' -import { StyledRadioGroup as RadioGroup } from '../../../Common' +import { ReactComponent as ICCaretDown } from '@Icons/ic-caret-down.svg' +import { PopupMenu, StyledRadioGroup as RadioGroup } from '../../../Common' import { NodeStatus, StatusFilterButtonType } from './types' import { IndexStore } from '../../Store' -interface TabState { - status: string - count: number - isSelected: boolean -} +import './StatusFilterButtonComponent.scss' export const StatusFilterButtonComponent = ({ nodes, handleFilterClick }: StatusFilterButtonType) => { const [selectedTab, setSelectedTab] = useState('all') + const maxInlineFilterCount = 4 let allNodeCount: number = 0 let healthyNodeCount: number = 0 let progressingNodeCount: number = 0 @@ -50,7 +48,7 @@ export const StatusFilterButtonComponent = ({ nodes, handleFilterClick }: Status allNodeCount += 1 }) - const filters = [ + const filterOptions = [ { status: 'all', count: allNodeCount, isSelected: selectedTab == 'all' }, { status: NodeStatus.Missing, count: missingNodeCount, isSelected: NodeStatus.Missing == selectedTab }, { status: NodeStatus.Degraded, count: failedNodeCount, isSelected: NodeStatus.Degraded == selectedTab }, @@ -61,6 +59,13 @@ export const StatusFilterButtonComponent = ({ nodes, handleFilterClick }: Status }, { status: NodeStatus.Healthy, count: healthyNodeCount, isSelected: NodeStatus.Healthy == selectedTab }, ] + const validFilterOptions = filterOptions.filter(({ count }) => count > 0) + const displayedInlineFilters = validFilterOptions.slice( + 0, + Math.min(maxInlineFilterCount, validFilterOptions.length), + ) + const overflowFilters = + validFilterOptions.length > maxInlineFilterCount ? validFilterOptions.slice(maxInlineFilterCount) : null useEffect(() => { if ( @@ -81,30 +86,70 @@ export const StatusFilterButtonComponent = ({ nodes, handleFilterClick }: Status setSelectedTab(event.target.value) } + const handleMenuOptionClick = (status: string) => () => setSelectedTab(status) + + const renderOverflowFilters = () => + overflowFilters ? ( + + + + + + {overflowFilters.map((filter) => ( + + ))} + + + ) : null + return ( - - {filters.length && - filters.map( - (filter: TabState, index: number) => - filter.count > 0 && ( - - {index !== 0 && ( - - )} - {filter.status} - ({filter.count}) - - ), - )} - + <> + + {displayedInlineFilters.map((filter, index) => ( + + {index !== 0 ? ( + <> + + {filter.count} + + ) : ( + {`${filter.status} (${filter.count})`} + )} + + ))} + + {renderOverflowFilters()} + ) } diff --git a/src/Shared/Components/CICDHistory/TriggerOutput.tsx b/src/Shared/Components/CICDHistory/TriggerOutput.tsx index 3b82e87c9..5ca27d5c7 100644 --- a/src/Shared/Components/CICDHistory/TriggerOutput.tsx +++ b/src/Shared/Components/CICDHistory/TriggerOutput.tsx @@ -17,7 +17,6 @@ import { Redirect, Route, Switch, useLocation, useParams, useRouteMatch, Link, NavLink } from 'react-router-dom' import React, { useEffect, useMemo, useState } from 'react' import moment from 'moment' -import { toast } from 'react-toastify' import { ShowMoreText } from '@Shared/Components/ShowMoreText' import { getHandleOpenURL } from '@Shared/Helpers' import { ImageChipCell } from '@Shared/Components/ImageChipCell' @@ -26,6 +25,7 @@ import { ReactComponent as ICLines } from '@Icons/ic-lines.svg' import { ReactComponent as ICPulsateStatus } from '@Icons/ic-pulsate-status.svg' import { ReactComponent as ICArrowRight } from '@Icons/ic-arrow-right.svg' import { getDeploymentStageTitle } from '@Pages/App' +import { ToastManager, ToastVariantType } from '@Shared/Services' import { ConfirmationDialog, DATE_TIME_FORMATS, @@ -64,7 +64,7 @@ import { getTagDetails, getTriggerDetails, cancelCiTrigger, cancelPrePostCdTrigg import { DEFAULT_ENV, TIMEOUT_VALUE, WORKER_POD_BASE_URL } from './constants' import { GitTriggers } from '../../types' import warn from '../../../Assets/Icon/ic-warning.svg' -import { LogsRenderer } from './LogsRenderer' +import LogsRenderer from './LogsRenderer' import DeploymentDetailSteps from './DeploymentDetailSteps' import { DeploymentHistoryDetailedView, DeploymentHistoryConfigList } from './DeploymentHistoryDiff' import { GitChanges, Scroller } from './History.components' @@ -164,7 +164,10 @@ const ProgressingStatus = React.memo(({ status, stage, type }: ProgressingStatus setAborting(true) try { await abort(abortError.status) - toast.success('Build Aborted') + ToastManager.showToast({ + variant: ToastVariantType.success, + description: 'Build Aborted', + }) setAbortConfirmation(false) setAbortError({ status: false, diff --git a/src/Shared/Components/CICDHistory/cicdHistory.scss b/src/Shared/Components/CICDHistory/cicdHistory.scss index a834f2fe9..2080de484 100644 --- a/src/Shared/Components/CICDHistory/cicdHistory.scss +++ b/src/Shared/Components/CICDHistory/cicdHistory.scss @@ -37,20 +37,6 @@ position: absolute; bottom: 12px; right: 12px; - transition: top 0.3s; - padding: 6px; - width: 32px; - height: 32px; - border-radius: 4px; - box-shadow: - 0 0 4px 0 rgba(0, 10, 20, 0.5), - 0 0 4px 0 rgba(0, 10, 20, 0.5); - background-color: #2c3354; - opacity: 0.8; - - &:hover { - opacity: 1; - } } .ci-details__history { @@ -110,10 +96,6 @@ transition: height 0.3s; } } - - .zoom { - transition: top 0.3s; - } } } @@ -294,3 +276,30 @@ width: min(100%, 800px); } } + +.log-resize-button { + transition: opacity 0.2s ease-in-out; + padding: 6px; + width: 32px; + height: 32px; + border-radius: 4px; + box-shadow: + 0 0 4px 0 rgba(0, 10, 20, 0.5), + 0 0 4px 0 rgba(0, 10, 20, 0.5); + background-color: #2c3354; + opacity: 0.8; + z-index: 11; + + & > svg { + transition: transform 0.2s ease-in-out; + transform: scale(1); + } + + &:hover { + opacity: 1; + + & > svg { + transform: scale(1.1); + } + } +} diff --git a/src/Shared/Components/CICDHistory/index.tsx b/src/Shared/Components/CICDHistory/index.tsx index 55f027204..0940f1876 100644 --- a/src/Shared/Components/CICDHistory/index.tsx +++ b/src/Shared/Components/CICDHistory/index.tsx @@ -26,7 +26,7 @@ export * from './service' export * from './History.components' export * from './utils' export * from './TriggerOutput' -export * from './LogsRenderer' +export { default as LogsRenderer } from './LogsRenderer' export * from './DeploymentHistoryDiff' export * from './CiPipelineSourceConfig' export * from './StatusFilterButtonComponent' diff --git a/src/Shared/Components/CICDHistory/types.tsx b/src/Shared/Components/CICDHistory/types.tsx index 9203727dd..8ff4f62c4 100644 --- a/src/Shared/Components/CICDHistory/types.tsx +++ b/src/Shared/Components/CICDHistory/types.tsx @@ -27,6 +27,7 @@ import { PaginationProps, useScrollable, SortingOrder, + SupportedKeyboardKeysType, } from '../../../Common' import { DeploymentStageType } from '../../constants' import { AggregationKeys, GitTriggers, Node, NodeType, ResourceKindType, ResourceVersionType } from '../../types' @@ -46,6 +47,16 @@ export enum FetchIdDataStatus { } export interface LogResizeButtonType { + /** + * If given, that shortcut combo will be bound to the button + * @default null + */ + shortcutCombo?: SupportedKeyboardKeysType[] + /** + * If true, only show the button when location.pathname contains '/logs' + * @default true + */ + showOnlyWhenPathIncludesLogs?: boolean fullScreenView: boolean setFullScreenView: React.Dispatch> } diff --git a/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx b/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx index 8b2569c3e..fb936b36b 100644 --- a/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx +++ b/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx @@ -1,7 +1,6 @@ import React, { Fragment } from 'react' import { NavLink, useLocation } from 'react-router-dom' import Tippy, { TippyProps } from '@tippyjs/react' - import { ConditionalWrap } from '@Common/Helper' import { ReactComponent as ICExpand } from '@Icons/ic-expand.svg' diff --git a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.component.tsx b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.component.tsx index d0a7f3621..7c48506d8 100644 --- a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.component.tsx +++ b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.component.tsx @@ -1,6 +1,6 @@ import { DeploymentConfigDiffNavigation } from './DeploymentConfigDiffNavigation' import { DeploymentConfigDiffMain } from './DeploymentConfigDiffMain' -import { DeploymentConfigDiffProps } from './types' +import { DeploymentConfigDiffProps } from './DeploymentConfigDiff.types' import './DeploymentConfigDiff.scss' export const DeploymentConfigDiff = ({ @@ -11,6 +11,7 @@ export const DeploymentConfigDiff = ({ goBackURL, navHeading, navHelpText, + tabConfig, ...resProps }: DeploymentConfigDiffProps) => (
@@ -21,6 +22,7 @@ export const DeploymentConfigDiff = ({ goBackURL={goBackURL} navHeading={navHeading} navHelpText={navHelpText} + tabConfig={tabConfig} />
diff --git a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.scss b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.scss index 360502211..9e0859448 100644 --- a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.scss +++ b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.scss @@ -21,6 +21,13 @@ height: calc(100vh - 122px); overflow: auto; + &__heading { + display: grid; + // -15px to accommodate the right side bar of the code-editor + grid-template-columns: calc(50% - 15px) calc(50% - 15px); + grid-template-rows: auto; + } + &__comparison { margin: 16px 0 0; } @@ -29,4 +36,14 @@ & .react-monaco-editor-container { min-height: 100px; } + + &__tab-list { + label { + flex-grow: 1 + } + + .radio__item-label { + justify-content: center; + } + } } diff --git a/src/Shared/Components/DeploymentConfigDiff/types.ts b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.types.ts similarity index 70% rename from src/Shared/Components/DeploymentConfigDiff/types.ts rename to src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.types.ts index 0f121d704..9064785a4 100644 --- a/src/Shared/Components/DeploymentConfigDiff/types.ts +++ b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.types.ts @@ -1,6 +1,13 @@ import { SortingOrder } from '@Common/Constants' -import { ConfigMapSecretDataConfigDatumDTO, DeploymentTemplateDTO } from '@Shared/Services' +import { + AppEnvDeploymentConfigDTO, + ConfigMapSecretDataConfigDatumDTO, + DeploymentTemplateDTO, + EnvResourceType, + ManifestTemplateDTO, +} from '@Shared/Services' + import { DeploymentHistoryDetail } from '../CICDHistory' import { CollapsibleListConfig, CollapsibleListItem } from '../CollapsibleList' import { SelectPickerProps } from '../SelectPicker' @@ -45,6 +52,11 @@ export interface DeploymentConfigDiffNavigationCollapsibleItem export interface DeploymentConfigDiffProps { isLoading?: boolean + errorConfig?: { + error: boolean + code: number + reload: () => void + } configList: DeploymentConfigListItem[] headerText?: string scrollIntoViewId?: string @@ -62,18 +74,29 @@ export interface DeploymentConfigDiffProps { goBackURL?: string navHeading: string navHelpText?: string + tabConfig?: { + tabs: string[] + activeTab: string + onClick: (tab: string) => void + } } export interface DeploymentConfigDiffNavigationProps extends Pick< DeploymentConfigDiffProps, - 'isLoading' | 'navList' | 'collapsibleNavList' | 'goBackURL' | 'navHeading' | 'navHelpText' + 'isLoading' | 'navList' | 'collapsibleNavList' | 'goBackURL' | 'navHeading' | 'navHelpText' | 'tabConfig' > {} export interface DeploymentConfigDiffMainProps extends Pick< DeploymentConfigDiffProps, - 'isLoading' | 'headerText' | 'configList' | 'scrollIntoViewId' | 'selectorsConfig' | 'sortingConfig' + | 'isLoading' + | 'errorConfig' + | 'headerText' + | 'configList' + | 'scrollIntoViewId' + | 'selectorsConfig' + | 'sortingConfig' > {} export interface DeploymentConfigDiffAccordionProps extends Pick { @@ -88,3 +111,18 @@ export interface DeploymentConfigDiffAccordionProps extends Pick = DeploymentTemplate extends true ? DeploymentTemplateDTO : ConfigMapSecretDataConfigDatumDTO + +export type AppEnvDeploymentConfigListParams = (IsManifestView extends true + ? { + currentList: ManifestTemplateDTO + compareList: ManifestTemplateDTO + sortOrder?: never + } + : { + currentList: AppEnvDeploymentConfigDTO + compareList: AppEnvDeploymentConfigDTO + sortOrder?: SortingOrder + }) & { + getNavItemHref: (resourceType: EnvResourceType, resourceName: string) => string + isManifestView?: IsManifestView +} diff --git a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.utils.tsx b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.utils.tsx index 34c208e79..a58b154e7 100644 --- a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.utils.tsx +++ b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiff.utils.tsx @@ -5,16 +5,21 @@ import { stringComparatorBySortOrder, yamlComparatorBySortOrder } from '@Shared/ import { DEPLOYMENT_HISTORY_CONFIGURATION_LIST_MAP } from '@Shared/constants' import { YAMLStringify } from '@Common/Helper' import { SortingOrder } from '@Common/Constants' -import { DeploymentConfigDiffProps, DiffHeadingDataType, prepareHistoryData } from '@Shared/Components' +import { + AppEnvDeploymentConfigListParams, + DeploymentConfigDiffProps, + DiffHeadingDataType, + prepareHistoryData, +} from '@Shared/Components' import { - AppEnvDeploymentConfigDTO, ConfigMapSecretDataConfigDatumDTO, ConfigMapSecretDataDTO, ConfigResourceType, DeploymentTemplateDTO, DraftState, EnvResourceType, + ManifestTemplateDTO, TemplateListDTO, TemplateListType, } from '../../Services/app.types' @@ -292,6 +297,20 @@ const getDeploymentTemplateDiffViewData = (data: DeploymentTemplateDTO | null, s return diffViewData } +const getManifestDiffViewData = (data: ManifestTemplateDTO) => { + const codeEditorValue = { + displayName: 'data', + value: data.data, + } + + const diffViewData = prepareHistoryData( + { codeEditorValue }, + DEPLOYMENT_HISTORY_CONFIGURATION_LIST_MAP.DEPLOYMENT_TEMPLATE.VALUE, + ) + + return diffViewData +} + const getDiffHeading = ( data: DiffHeadingDataType, deploymentTemplate?: DeploymentTemplate, @@ -391,105 +410,153 @@ const getConfigMapSecretData = ( /** * Generates a list of deployment configurations for application environments and identifies changes between the current and compare lists. * - * @param currentList - The current deployment configuration list. - * @param compareList - The deployment configuration list to compare against. - * @param getNavItemHref - A function to generate navigation item URLs based on the resource type and resource name. - * @param sortOrder - (Optional) The order in which to sort the deployment templates. + * @param params - An object containing the following properties: + * @param params.currentList - The current deployment configuration list. + * @param params.compareList - The deployment configuration list to compare against. + * @param params.getNavItemHref - A function to generate navigation item URLs based on the resource type and resource name. + * @param params.isManifestView - A boolean that, when true, modifies the output for a manifest view. + * @param params.sortOrder - (Optional) The order in which to sort the deployment templates. * * @returns An object containing the combined deployment configuration list, a collapsible navigation list, and a navigation list. */ -export const getAppEnvDeploymentConfigList = ( - currentList: AppEnvDeploymentConfigDTO, - compareList: AppEnvDeploymentConfigDTO, - getNavItemHref: (resourceType: EnvResourceType, resourceName: string) => string, - sortOrder?: SortingOrder, -): { +export const getAppEnvDeploymentConfigList = ({ + currentList, + compareList, + getNavItemHref, + isManifestView, + sortOrder, +}: AppEnvDeploymentConfigListParams): { configList: DeploymentConfigDiffProps['configList'] navList: DeploymentConfigDiffProps['navList'] collapsibleNavList: DeploymentConfigDiffProps['collapsibleNavList'] } => { - const currentDeploymentData = getDeploymentTemplateDiffViewData(currentList.deploymentTemplate, sortOrder) - const compareDeploymentData = getDeploymentTemplateDiffViewData(compareList.deploymentTemplate, sortOrder) + if (!isManifestView) { + const _currentList = currentList as AppEnvDeploymentConfigListParams['currentList'] + const _compareList = compareList as AppEnvDeploymentConfigListParams['compareList'] + const currentDeploymentData = getDeploymentTemplateDiffViewData(_currentList.deploymentTemplate, sortOrder) + const compareDeploymentData = getDeploymentTemplateDiffViewData(_compareList.deploymentTemplate, sortOrder) + + const deploymentTemplateData = { + id: EnvResourceType.DeploymentTemplate, + title: 'Deployment Template', + primaryConfig: { + heading: getDiffHeading(_compareList.deploymentTemplate, true), + list: compareDeploymentData, + }, + secondaryConfig: { + heading: getDiffHeading(_currentList.deploymentTemplate, true), + list: currentDeploymentData, + }, + hasDiff: currentDeploymentData.codeEditorValue.value !== compareDeploymentData.codeEditorValue.value, + isDeploymentTemplate: true, + } + + const cmData = getConfigMapSecretData( + _currentList.configMapData, + _compareList.configMapData, + ConfigResourceType.ConfigMap, + _currentList.isAppAdmin, + _compareList.isAppAdmin, + ) + + const secretData = getConfigMapSecretData( + _currentList.secretsData, + _compareList.secretsData, + ConfigResourceType.Secret, + _currentList.isAppAdmin, + _compareList.isAppAdmin, + ) + + const configList: DeploymentConfigDiffProps['configList'] = [deploymentTemplateData, ...cmData, ...secretData] + + const navList: DeploymentConfigDiffProps['navList'] = [ + { + title: deploymentTemplateData.title, + hasDiff: deploymentTemplateData.hasDiff, + href: getNavItemHref(EnvResourceType.DeploymentTemplate, null), + onClick: () => { + const element = document.querySelector(`#${deploymentTemplateData.id}`) + element?.scrollIntoView({ block: 'start' }) + }, + }, + ] + + const collapsibleNavList: DeploymentConfigDiffProps['collapsibleNavList'] = [ + { + header: 'ConfigMaps', + id: EnvResourceType.ConfigMap, + items: cmData.map(({ name, hasDiff, id }) => ({ + title: name, + hasDiff, + href: getNavItemHref(EnvResourceType.ConfigMap, name), + onClick: () => { + const element = document.querySelector(`#${id}`) + element?.scrollIntoView({ block: 'start' }) + }, + })), + noItemsText: 'No configmaps', + }, + { + header: 'Secrets', + id: EnvResourceType.Secret, + items: secretData.map(({ name, hasDiff, id }) => ({ + title: name, + hasDiff, + href: getNavItemHref(EnvResourceType.Secret, name), + onClick: () => { + const element = document.querySelector(`#${id}`) + element?.scrollIntoView({ block: 'start' }) + }, + })), + noItemsText: 'No secrets', + }, + ] - const deploymentTemplateData = { - id: EnvResourceType.DeploymentTemplate, - title: 'Deployment Template', + return { + configList, + collapsibleNavList, + navList, + } + } + + const _currentList = currentList as AppEnvDeploymentConfigListParams['currentList'] + const _compareList = compareList as AppEnvDeploymentConfigListParams['compareList'] + + const currentManifestData = getManifestDiffViewData(_currentList) + const compareManifestData = getManifestDiffViewData(_compareList) + + const manifestData = { + id: EnvResourceType.Manifest, + title: 'Manifest Output', primaryConfig: { - heading: getDiffHeading(compareList.deploymentTemplate, true), - list: compareDeploymentData, + heading: Generated Manifest, + list: compareManifestData, }, secondaryConfig: { - heading: getDiffHeading(currentList.deploymentTemplate, true), - list: currentDeploymentData, + heading: Generated Manifest, + list: currentManifestData, }, - hasDiff: currentDeploymentData.codeEditorValue.value !== compareDeploymentData.codeEditorValue.value, + hasDiff: currentManifestData.codeEditorValue.value !== compareManifestData.codeEditorValue.value, isDeploymentTemplate: true, } - const cmData = getConfigMapSecretData( - currentList.configMapData, - compareList.configMapData, - ConfigResourceType.ConfigMap, - currentList.isAppAdmin, - compareList.isAppAdmin, - ) - - const secretData = getConfigMapSecretData( - currentList.secretsData, - compareList.secretsData, - ConfigResourceType.Secret, - currentList.isAppAdmin, - compareList.isAppAdmin, - ) - - const configList: DeploymentConfigDiffProps['configList'] = [deploymentTemplateData, ...cmData, ...secretData] + const configList: DeploymentConfigDiffProps['configList'] = [manifestData] const navList: DeploymentConfigDiffProps['navList'] = [ { - title: deploymentTemplateData.title, - hasDiff: deploymentTemplateData.hasDiff, - href: getNavItemHref(EnvResourceType.DeploymentTemplate, null), + title: manifestData.title, + hasDiff: manifestData.hasDiff, + href: getNavItemHref(EnvResourceType.Manifest, null), onClick: () => { - const element = document.querySelector(`#${deploymentTemplateData.id}`) + const element = document.querySelector(`#${manifestData.id}`) element?.scrollIntoView({ block: 'start' }) }, }, ] - const collapsibleNavList: DeploymentConfigDiffProps['collapsibleNavList'] = [ - { - header: 'ConfigMaps', - id: EnvResourceType.ConfigMap, - items: cmData.map(({ name, hasDiff, id }) => ({ - title: name, - hasDiff, - href: getNavItemHref(EnvResourceType.ConfigMap, name), - onClick: () => { - const element = document.querySelector(`#${id}`) - element?.scrollIntoView({ block: 'start' }) - }, - })), - noItemsText: 'No configmaps', - }, - { - header: 'Secrets', - id: EnvResourceType.Secret, - items: secretData.map(({ name, hasDiff, id }) => ({ - title: name, - hasDiff, - href: getNavItemHref(EnvResourceType.Secret, name), - onClick: () => { - const element = document.querySelector(`#${id}`) - element?.scrollIntoView({ block: 'start' }) - }, - })), - noItemsText: 'No secrets', - }, - ] - return { configList, - collapsibleNavList, + collapsibleNavList: [], navList, } } diff --git a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffAccordion.tsx b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffAccordion.tsx index 3ce89d801..64b720431 100644 --- a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffAccordion.tsx +++ b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffAccordion.tsx @@ -3,7 +3,7 @@ import { forwardRef } from 'react' import { ReactComponent as ICCaretDown } from '@Icons/ic-caret-down.svg' import { Collapse } from '../Collapse' -import { DeploymentConfigDiffAccordionProps } from './types' +import { DeploymentConfigDiffAccordionProps } from './DeploymentConfigDiff.types' export const DeploymentConfigDiffAccordion = forwardRef( ( diff --git a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffMain.tsx b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffMain.tsx index 8c965021c..5ef7c7081 100644 --- a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffMain.tsx +++ b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffMain.tsx @@ -5,14 +5,16 @@ import { ReactComponent as ICSort } from '@Icons/ic-arrow-up-down.svg' import { Progressing } from '@Common/Progressing' import { CodeEditor } from '@Common/CodeEditor' import { MODES, SortingOrder } from '@Common/Constants' +import ErrorScreenManager from '@Common/ErrorScreenManager' import { SelectPicker } from '../SelectPicker' import { DeploymentHistoryDiffView } from '../CICDHistory' import { DeploymentConfigDiffAccordion } from './DeploymentConfigDiffAccordion' -import { DeploymentConfigDiffMainProps, DeploymentConfigDiffSelectPickerProps } from './types' +import { DeploymentConfigDiffMainProps, DeploymentConfigDiffSelectPickerProps } from './DeploymentConfigDiff.types' export const DeploymentConfigDiffMain = ({ isLoading, + errorConfig, headerText = 'Compare With', configList = [], selectorsConfig, @@ -131,7 +133,7 @@ export const DeploymentConfigDiffMain = ({ > {isDeploymentTemplate ? ( <> -
+
{primaryHeading}
{secondaryHeading}
@@ -182,11 +184,13 @@ export const DeploymentConfigDiffMain = ({
- {isLoading ? ( - - ) : ( -
{renderDiffs()}
- )} + {errorConfig?.error && } + {!errorConfig?.error && + (isLoading ? ( + + ) : ( +
{renderDiffs()}
+ ))}
) diff --git a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffNavigation.tsx b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffNavigation.tsx index 4b5411eea..b8f2d8307 100644 --- a/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffNavigation.tsx +++ b/src/Shared/Components/DeploymentConfigDiff/DeploymentConfigDiffNavigation.tsx @@ -1,13 +1,14 @@ -import { useEffect, useState } from 'react' +import { ChangeEvent, useEffect, useState } from 'react' import { Link, NavLink } from 'react-router-dom' import Tippy from '@tippyjs/react' import { ReactComponent as ICClose } from '@Icons/ic-close.svg' import { ReactComponent as ICInfoOutlined } from '@Icons/ic-info-outlined.svg' import { ReactComponent as ICDiffFileUpdated } from '@Icons/ic-diff-file-updated.svg' +import { StyledRadioGroup } from '@Common/index' import { CollapsibleList } from '../CollapsibleList' -import { DeploymentConfigDiffNavigationProps } from './types' +import { DeploymentConfigDiffNavigationProps } from './DeploymentConfigDiff.types' // LOADING SHIMMER const ShimmerText = ({ width }: { width: string }) => ( @@ -23,6 +24,7 @@ export const DeploymentConfigDiffNavigation = ({ goBackURL, navHeading, navHelpText, + tabConfig, }: DeploymentConfigDiffNavigationProps) => { // STATES const [expandedIds, setExpandedIds] = useState>({}) @@ -55,12 +57,20 @@ export const DeploymentConfigDiffNavigation = ({ setExpandedIds((prev) => ({ ...prev, [id]: !prev[id] })) } + /** Handles tab click. */ + const onTabClick = (e: ChangeEvent) => { + const { value } = e.target + if (tabConfig?.activeTab !== value) { + tabConfig?.onClick?.(value) + } + } + // RENDERERS const renderTopContent = () => (
{goBackURL && ( - + @@ -69,6 +79,28 @@ export const DeploymentConfigDiffNavigation = ({
) + const renderTabConfig = () => { + const { tabs, activeTab } = tabConfig + + return ( +
+ + {tabs.map((tab) => ( + + {tab} + + ))} + +
+ ) + } + const renderContent = () => ( <> {navList.map(({ title, href, onClick, hasDiff }) => ( @@ -106,6 +138,7 @@ export const DeploymentConfigDiffNavigation = ({ return (
{renderTopContent()} + {!!tabConfig?.tabs.length && renderTabConfig()}
{isLoading ? renderLoading() : renderContent()}
) diff --git a/src/Shared/Components/DeploymentConfigDiff/index.ts b/src/Shared/Components/DeploymentConfigDiff/index.ts index 12e13e3d1..c84340d18 100644 --- a/src/Shared/Components/DeploymentConfigDiff/index.ts +++ b/src/Shared/Components/DeploymentConfigDiff/index.ts @@ -1,3 +1,3 @@ export * from './DeploymentConfigDiff.component' -export * from './types' +export * from './DeploymentConfigDiff.types' export * from './DeploymentConfigDiff.utils' diff --git a/src/Shared/Components/EditImageFormField/EditImageFormField.tsx b/src/Shared/Components/EditImageFormField/EditImageFormField.tsx index e3cf132a7..310e22316 100644 --- a/src/Shared/Components/EditImageFormField/EditImageFormField.tsx +++ b/src/Shared/Components/EditImageFormField/EditImageFormField.tsx @@ -1,9 +1,9 @@ import { KeyboardEvent, SyntheticEvent, useState } from 'react' -import { toast } from 'react-toastify' import { showError } from '@Common/Helper' import { CustomInput } from '@Common/CustomInput' import { ButtonWithLoader, ImageWithFallback } from '@Shared/Components' import { validateIfImageExist, validateURL } from '@Shared/validations' +import { ToastManager, ToastVariantType } from '@Shared/Services' import { ReactComponent as ICPencil } from '@Icons/ic-pencil.svg' import { EditImageFormFieldProps, FallbackImageProps } from './types' import { @@ -101,12 +101,18 @@ const EditImageFormField = ({ if (!url) { // Not setting the error since can save without image setEmptyPreviewURLErrorMessage(EMPTY_PREVIEW_URL_ERROR_MESSAGE) - toast.error(EMPTY_PREVIEW_URL_ERROR_MESSAGE) + ToastManager.showToast({ + variant: ToastVariantType.error, + description: EMPTY_PREVIEW_URL_ERROR_MESSAGE, + }) return } if (errorMessage) { - toast.error(errorMessage) + ToastManager.showToast({ + variant: ToastVariantType.error, + description: errorMessage, + }) return } diff --git a/src/Shared/Components/FilterButton/FilterButton.tsx b/src/Shared/Components/FilterButton/FilterButton.tsx deleted file mode 100644 index 8594f6c0d..000000000 --- a/src/Shared/Components/FilterButton/FilterButton.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - */ - -import React, { cloneElement, useEffect, useState } from 'react' -import ReactSelect, { MenuListProps, components } from 'react-select' -import { Option } from '@Common/MultiSelectCustomization' -import { OptionType } from '@Common/Types' -import { ReactComponent as ICCaretDown } from '../../../Assets/Icon/ic-caret-down.svg' -import { getFilterStyle } from './utils' -import { FilterButtonPropsType } from './types' - -const ValueContainer = (props: any) => { - const { selectProps, getValue, children } = props - const selectedProjectLen = getValue().length - - return ( - - {!selectProps.inputValue && - (!selectProps.menuIsOpen ? ( -
-
{selectProps.placeholder}
- {selectedProjectLen > 0 && ( -
- {selectedProjectLen} -
- )} -
- ) : ( - {selectProps.placeholder} - ))} - - {cloneElement(children[1])} -
- ) -} - -const FilterSelectMenuList: React.FC = (props) => { - const { - children, - // @ts-ignore NOTE: handleApplyFilter is passed from FilterButton - selectProps: { handleApplyFilter }, - } = props - - return ( - - {children} -
- -
-
- ) -} - -const DropdownIndicator = (props) => ( - - - -) - -// To be replaced with MultiSelectPicker -const FilterButton: React.FC = ({ - placeholder, - appliedFilters, - options, - disabled, - handleApplyChange, - getFormattedFilterLabelValue, - menuAlignFromRight, - controlWidth, -}) => { - const [menuIsOpen, setMenuIsOpen] = useState(false) - const [selectedOptions, setSelectedOptions] = useState( - appliedFilters.map((filter) => ({ value: filter, label: getFormattedFilterLabelValue?.(filter) || filter })), - ) - - useEffect(() => { - setSelectedOptions( - appliedFilters.map((filter) => ({ - value: filter, - label: getFormattedFilterLabelValue?.(filter) || filter, - })), - ) - }, [appliedFilters]) - - const handleMenuOpen = () => { - setMenuIsOpen(true) - } - - const handleMenuClose = () => { - setMenuIsOpen(false) - } - - const handleSelectOnChange: React.ComponentProps['onChange'] = (selected: OptionType[]) => { - setSelectedOptions([...selected]) - } - - const handleApply = () => { - handleApplyChange(Object.values(selectedOptions).map((option) => option.value)) - handleMenuClose() - } - - return ( - - ) -} - -export default FilterButton diff --git a/src/Shared/Components/FilterButton/index.tsx b/src/Shared/Components/FilterButton/index.tsx deleted file mode 100644 index b7ba36505..000000000 --- a/src/Shared/Components/FilterButton/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default as FilterButton } from './FilterButton' diff --git a/src/Shared/Components/FilterButton/types.tsx b/src/Shared/Components/FilterButton/types.tsx deleted file mode 100644 index 3d0d140f1..000000000 --- a/src/Shared/Components/FilterButton/types.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { OptionType } from '@Common/Types' - -export interface FilterButtonPropsType { - placeholder: string - appliedFilters: string[] - options: OptionType[] - handleApplyChange: (selectedOptions: string[]) => void - disabled?: boolean - getFormattedFilterLabelValue?: (identifier: string) => string - menuAlignFromRight?: boolean - controlWidth?: string -} diff --git a/src/Shared/Components/FilterButton/utils.tsx b/src/Shared/Components/FilterButton/utils.tsx deleted file mode 100644 index cdf8991fa..000000000 --- a/src/Shared/Components/FilterButton/utils.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { CommonGroupedDropdownStyles } from '../ReactSelect' - -export const getFilterStyle = (controlWidth?: string, menuAlignFromRight?: boolean) => ({ - ...CommonGroupedDropdownStyles, - menu: (base) => ({ - ...base, - width: '200px', - ...(menuAlignFromRight && { right: 0 }), - }), - control: (base, state) => ({ - ...CommonGroupedDropdownStyles.control(base, state), - maxHeight: '36px', - minHeight: '36px', - fontSize: '12px', - paddingLeft: '8px', - width: controlWidth || '150px', - }), - container: (base) => ({ - ...base, - height: '36px', - width: controlWidth || '150px', - maxHeight: '36px', - }), - option: (base, state) => ({ - ...base, - height: '36px', - padding: '8px 12px', - backgroundColor: state.isFocused ? 'var(--N100)' : 'white', - color: 'var(--N900)', - cursor: 'pointer', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - - ':active': { - backgroundColor: 'var(--N100)', - }, - }), -}) diff --git a/src/Shared/Components/Header/HeaderWithCreateButton/HeaderWithCreateButon.tsx b/src/Shared/Components/Header/HeaderWithCreateButton/HeaderWithCreateButon.tsx index 7cdb1de8d..d9ca3b71b 100644 --- a/src/Shared/Components/Header/HeaderWithCreateButton/HeaderWithCreateButon.tsx +++ b/src/Shared/Components/Header/HeaderWithCreateButton/HeaderWithCreateButon.tsx @@ -46,11 +46,7 @@ export const HeaderWithCreateButton = ({ headerName }: HeaderWithCreateButtonPro } const openCreateDevtronAppModel = () => { - const canOpenModalWithDevtronApps = params.appType - ? params.appType === AppListConstants.AppType.DEVTRON_APPS - : serverMode === SERVER_MODE.FULL - const _appType = canOpenModalWithDevtronApps ? AppListConstants.AppType.DEVTRON_APPS : URLS.APP_LIST_HELM - const _urlPrefix = `${URLS.APP}/${URLS.APP_LIST}/${_appType}` + const _urlPrefix = `${URLS.APP}/${URLS.APP_LIST}/${params.appType ?? AppListConstants.AppType.DEVTRON_APPS}` history.push(`${_urlPrefix}/${AppListConstants.CREATE_DEVTRON_APP_URL}${location.search}`) } diff --git a/src/Shared/Components/ImageCardAccordion/ImageCardAccordion.tsx b/src/Shared/Components/ImageCardAccordion/ImageCardAccordion.tsx index ef783d535..fd44ef75f 100644 --- a/src/Shared/Components/ImageCardAccordion/ImageCardAccordion.tsx +++ b/src/Shared/Components/ImageCardAccordion/ImageCardAccordion.tsx @@ -16,7 +16,7 @@ import { useState } from 'react' import { CDModalTab, CDModalTabType } from '../../../Common' -import { Vulnerabilities } from '../Vulnerabilities' +import { Vulnerabilities } from '../Security/Vulnerabilities' import { AccordionItemProps, ImageCardAccordionProps } from './types' import { ReactComponent as ICChevronDown } from '../../../Assets/Icon/ic-chevron-down.svg' @@ -48,6 +48,8 @@ const ImageCardAccordion = ({ changesCard, isScanned, isScanEnabled, + SecurityModalSidebar, + getSecurityScan, }: ImageCardAccordionProps) => { const [isOpened, setIsOpened] = useState(false) const [activeTab, setActiveTab] = useState(CDModalTab.Changes) @@ -74,6 +76,8 @@ const ImageCardAccordion = ({ applicationId={applicationId} environmentId={environmentId} setVulnerabilityCount={setVulnerabilityCount} + SecurityModalSidebar={SecurityModalSidebar} + getSecurityScan={getSecurityScan} /> ) } @@ -90,7 +94,6 @@ const ImageCardAccordion = ({ setActiveTab={setActiveTab} buttonText="Changes" /> - + getSecurityScan: ( + props: Pick, + ) => Promise> } export interface SecurityDetailsType { diff --git a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx index 5e917a470..0e18762ec 100644 --- a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx +++ b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx @@ -392,7 +392,7 @@ export const KeyValueTable = ({ ) : (
{label} {!!headerComponent && headerComponent} diff --git a/src/Shared/Components/Security/SecurityModal/SecurityModal.tsx b/src/Shared/Components/Security/SecurityModal/SecurityModal.tsx new file mode 100644 index 000000000..d1529a657 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/SecurityModal.tsx @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import React, { useState } from 'react' +import { + ErrorScreenManager, + ClipboardButton, + GenericEmptyState, + ImageType, + Progressing, + stopPropagation, + VisibleModal2, +} from '@Common/index' +import { ReactComponent as ICClose } from '@Icons/ic-close.svg' +import { ReactComponent as ICBack } from '@Icons/ic-caret-left-small.svg' +import { Table, InfoCard } from './components' +import { DEFAULT_SECURITY_MODAL_STATE } from './constants' +import { getTableData, getInfoCardData } from './config' +import { SecurityModalPropsType, SecurityModalStateType, DetailViewDataType } from './types' +import { getEmptyStateValues } from './config/EmptyState' +import './styles.scss' + +/** + * NOTE: the security modal is split into 3 sections - ImageScan, CodeScan & Kubernetes Manifest; + * Each category has 1 or more subCategories from the set (Vulnerability, License, MisConfigurations & ExposedSecrets) + * Since each combination of category & subCategory results in the data being visualized through InfoCard & Table + * the components are declared & called only once and only the data (props) passed to these components differ + * between the different configurations of category & subCategory. Some row elements from some combinations + * of Category & SubCategory can allow users to view that particular data in detail (taking user to detailView) + * So to showcase the detail data, the data is set into detailViewData property of ModalState. + * For further detail please refer the types to understand the Api Response and workflow of the modal component. + * */ +const SecurityModal: React.FC = ({ + Sidebar, + handleModalClose, + isLoading, + error, + responseData, + isResourceScan = false, + isHelmApp = false, + isSecurityScanV2Enabled = false, + isExternalCI = false, + hidePolicy = false, +}) => { + const [state, setState] = useState(DEFAULT_SECURITY_MODAL_STATE) + + const data = responseData ?? null + + const setDetailViewData = (detailViewData: DetailViewDataType) => { + setState((prevState) => ({ + ...prevState, + detailViewData: [...(prevState.detailViewData ?? []), detailViewData], + })) + } + + const handleBackFromDetailView = () => { + setState((prevState) => ({ ...prevState, detailViewData: prevState.detailViewData?.slice(0, -1) ?? null })) + } + + const topLevelDetailViewDataIndex = (state.detailViewData?.length ?? 0) - 1 + const selectedDetailViewData = state.detailViewData?.[topLevelDetailViewDataIndex] ?? null + + const renderHeader = () => ( +
+ Security + +
+ ) + + const renderDetailViewSubHeader = () => ( +
+ +
+ {selectedDetailViewData.titlePrefix}: + + {selectedDetailViewData.title} + + +
+
+ ) + + const renderInfoCardAndTable = () => { + /* NOTE: if no data to process further show emptyState */ + const emptyState = getEmptyStateValues(data, state.category, state.subCategory, selectedDetailViewData) + + if (emptyState) { + return + } + + /* NOTE: if detailView is active show data gathered from that */ + const { headers, rows, defaultSortIndex, hasExpandableRows } = + selectedDetailViewData || + getTableData(data, state.category, state.subCategory, setDetailViewData, hidePolicy) + + const { entities, lastScanTimeString, scanToolId } = + selectedDetailViewData || getInfoCardData(data, state.category, state.subCategory) + + return ( +
+ {!entities?.length ? null : ( + + )} + + + ) + } + + const renderContent = () => { + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+ +
+ ) + } + + return ( + /* NOTE: the height is restricted to (viewport - header) height since we need overflow-scroll */ +
+ {/* NOTE: only show sidebar in AppDetails */} + {isSecurityScanV2Enabled && !isResourceScan && Sidebar && ( + + )} +
+
+ {selectedDetailViewData && renderDetailViewSubHeader()} + {data && renderInfoCardAndTable()} +
+
+ ) + } + + return ( + +
+ {renderHeader()} + {renderContent()} +
+
+ ) +} + +export default SecurityModal diff --git a/src/Shared/Components/Security/SecurityModal/components/IndexedTextDisplay.tsx b/src/Shared/Components/Security/SecurityModal/components/IndexedTextDisplay.tsx new file mode 100644 index 000000000..4008bf8ca --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/components/IndexedTextDisplay.tsx @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import React from 'react' +import { ClipboardButton } from '@Common/index' +import { ReactComponent as ICInfoOutline } from '@Icons/ic-info-outlined.svg' +import { IndexedTextDisplayPropsType } from '../types' + +const EmptyState: React.FC<{ href: string }> = ({ href }) => ( +
+
+ + Code snippet is not available + {href && ( + + Go to file + + )} +
+
+) + +const IndexedTextDisplay: React.FC = ({ title, lines, link }) => ( +
+
+ {link ? ( + + {title} + + ) : ( + {title} + )} + +
+ +
+            {lines?.map((line) => (
+                
+ {line.number} + {line.content} +
+ )) || } +
+
+) + +export default IndexedTextDisplay diff --git a/src/Shared/Components/Security/SecurityModal/components/InfoCard.tsx b/src/Shared/Components/Security/SecurityModal/components/InfoCard.tsx new file mode 100644 index 000000000..b294125b0 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/components/InfoCard.tsx @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import React from 'react' +import dayjs from 'dayjs' +import { ScannedByToolModal } from '@Shared/Components/ScannedByToolModal' +import { SegmentedBarChart } from '@Common/SegmentedBarChart' +import { ReactComponent as ICClock } from '@Icons/ic-clock.svg' +import { ZERO_TIME_STRING, DATE_TIME_FORMATS } from '../../../../../Common/Constants' +import { InfoCardPropsType } from '../types' + +const InfoCard: React.FC = ({ entities, lastScanTimeString, scanToolId }) => ( +
+ + + {(lastScanTimeString || scanToolId) && ( + <> +
+ +
+ {lastScanTimeString && lastScanTimeString !== ZERO_TIME_STRING && ( +
+ + {`Scanned on ${dayjs(lastScanTimeString).format(DATE_TIME_FORMATS.TWELVE_HOURS_FORMAT)}`} +
+ )} + {scanToolId && ( + + )} +
+ + )} +
+) + +export default InfoCard diff --git a/src/Shared/Components/Security/SecurityModal/components/OpenDetailViewButton.tsx b/src/Shared/Components/Security/SecurityModal/components/OpenDetailViewButton.tsx new file mode 100644 index 000000000..e906915d5 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/components/OpenDetailViewButton.tsx @@ -0,0 +1,20 @@ +import { PropsWithChildren } from 'react' +import { OpenDetailViewButtonProps } from '../types' + +const OpenDetailViewButton = ({ + children, + detailViewData, + setDetailViewData, +}: PropsWithChildren) => { + const handleClick = () => { + setDetailViewData(detailViewData) + } + + return ( + + ) +} + +export default OpenDetailViewButton diff --git a/src/Shared/Components/Security/SecurityModal/components/Table.tsx b/src/Shared/Components/Security/SecurityModal/components/Table.tsx new file mode 100644 index 000000000..72ad3fbd9 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/components/Table.tsx @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import React, { useState, useMemo } from 'react' +import { SortableTableHeaderCell } from '@Common/SortableTableHeaderCell' +import { SortingOrder } from '@Common/Constants' +import { ReactComponent as ICExpand } from '@Icons/ic-expand.svg' +import { SortOrderEnum, TablePropsType, TableSortStateType } from '../types' +import { compareStringAndObject } from '../utils' + +const Table: React.FC = ({ headers, rows, defaultSortIndex, hasExpandableRows, headerTopPosition }) => { + /* TODO: should the sort order by default be DESC or should it be DESC only for severity? (product-team) */ + const [sort, setSort] = useState({ + index: defaultSortIndex || 0, + order: headers[defaultSortIndex]?.defaultSortOrder || SortOrderEnum.ASC, + }) + const [rowExpandedStateArray, setRowExpandedStateArray] = useState(Array(rows.length).fill(false)) + + const sortedRows = useMemo( + () => + rows.sort((a, b) => { + const aCellContent = a.cells[sort.index].cellContent + const bCellContent = b.cells[sort.index].cellContent + const compareFunc = headers[sort.index].compareFunc || compareStringAndObject + return sort.order * compareFunc(aCellContent, bCellContent) + }), + [rows, sort], + ) + + const handleSortOrderChange = () => setSort({ ...sort, order: -1 * sort.order }) + + const handleSortIndexChange = (index: number) => () => + setSort({ index, order: headers[index]?.defaultSortOrder || SortOrderEnum.ASC }) + + const handleToggleExpandAllRows = () => + setRowExpandedStateArray(Array(rows.length).fill(!rowExpandedStateArray.every((rowState) => rowState))) + + const getToggleRowExpandHandler = (rowIndex: number) => () => { + rowExpandedStateArray[rowIndex] = !rowExpandedStateArray[rowIndex] + setRowExpandedStateArray([...rowExpandedStateArray]) + } + + return ( +
+
+ {hasExpandableRows && ( + + )} +
+ {headers.map((header, index) => ( +
+ {header.isSortable ? ( + + ) : ( + {header.headerText} + )} +
+ ))} +
+
+ {sortedRows.map((row, rowIndex) => ( +
+
+ {hasExpandableRows && ( + + )} +
+ {row.cells.map((cell, index) => ( +
+ {cell.component || cell.cellContent} +
+ ))} +
+
+ {hasExpandableRows && rowExpandedStateArray[rowIndex] && row.expandableComponent} +
+ ))} +
+ ) +} + +export default Table diff --git a/src/Shared/Components/Security/SecurityModal/components/index.ts b/src/Shared/Components/Security/SecurityModal/components/index.ts new file mode 100644 index 000000000..9d2e82c9b --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +export { default as Table } from './Table' +export { default as InfoCard } from './InfoCard' +export { default as OpenDetailViewButton } from './OpenDetailViewButton' diff --git a/src/Shared/Components/Security/SecurityModal/config/CodeScan.tsx b/src/Shared/Components/Security/SecurityModal/config/CodeScan.tsx new file mode 100644 index 000000000..bae548654 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/config/CodeScan.tsx @@ -0,0 +1,505 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import IndexedTextDisplay from '../components/IndexedTextDisplay' +import { + compareSeverities, + compareSeverity, + getScanCompletedEmptyState, + mapSeveritiesToSegmentedBarChartEntities, + stringifySeverities, +} from '../utils' +import { + MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID, + SCAN_FAILED_EMPTY_STATE, + SCAN_IN_PROGRESS_EMPTY_STATE, + SEVERITY_DEFAULT_SORT_ORDER, +} from '../constants' +import { + ApiResponseResultType, + CATEGORIES, + CodeScan, + CodeScanExposedSecretsListType, + CodeScanMisconfigurationsListType, + DetailViewDataType, + EmptyStateType, + InfoCardPropsType, + OpenDetailViewButtonProps, + SUB_CATEGORIES, + SecurityModalStateType, + StatusType, + TablePropsType, +} from '../types' +import { OpenDetailViewButton } from '../components' + +export const getCodeScanVulnerabilities = (data: CodeScan['vulnerability'], hidePolicy: boolean) => ({ + headers: [ + { headerText: 'cve id', isSortable: false, width: 150 }, + { + headerText: 'severity', + isSortable: true, + width: 100, + compareFunc: compareSeverity, + defaultSortOrder: SEVERITY_DEFAULT_SORT_ORDER, + }, + { headerText: 'package', isSortable: true, width: 143.33 }, + { headerText: 'current version', isSortable: false, width: 143.33 }, + { headerText: 'fixed in version', isSortable: false, width: 143.33 }, + !hidePolicy && { headerText: 'policy', isSortable: false, width: 143.33 }, + ], + rows: !data?.list?.length + ? null + : data.list.map((element, index) => ({ + id: index, + expandableComponent: null, + cells: [ + { + /* FIXME: which ones should be linked and which ones should not? */ + component: ( + + {element.cveId} + + ), + cellContent: element.cveId, + }, + { + component: ( + + {element.severity} + + ), + cellContent: element.severity, + }, + { + component: null, + cellContent: element.package, + }, + { + component: null, + cellContent: element.currentVersion, + }, + { + component: null, + cellContent: element.fixedInVersion, + }, + !hidePolicy && { + component: ( + + {element.permission} + + ), + cellContent: element.permission, + }, + ], + })), + defaultSortIndex: 1, +}) + +export const getCodeScanLicense = (data: CodeScan['license']) => ({ + headers: [ + { headerText: 'classification', isSortable: false, width: 150 }, + { + headerText: 'severity', + isSortable: true, + width: 100, + compareFunc: compareSeverity, + defaultSortOrder: SEVERITY_DEFAULT_SORT_ORDER, + }, + { headerText: 'license', isSortable: false, width: 150 }, + { headerText: 'source', isSortable: true, width: 296 }, + ], + rows: !data?.list?.length + ? null + : data.list.map((element, index) => ({ + id: index, + expandableComponent: null, + cells: [ + { + component: ( + + {element.classification} + + ), + cellContent: element.classification, + }, + { + component: ( + + {element.severity} + + ), + cellContent: element.severity, + }, + { + component: null, + cellContent: element.license, + }, + { + component: ( + + {element.package || element.source} + + ), + cellContent: element.package || element.source, + }, + ], + })), + defaultSortIndex: 1, +}) + +const getMisconfigurationsDetail = ( + element: CodeScanMisconfigurationsListType, + lastScanTimeString: string, + status: StatusType['status'], + scanToolName: StatusType['scanToolName'], +) => ({ + titlePrefix: 'File path', + title: element.filePath, + headers: [ + { headerText: 'title', isSortable: false, width: 472 }, + { + headerText: 'severity', + isSortable: true, + width: 100, + compareFunc: compareSeverity, + defaultSortOrder: SEVERITY_DEFAULT_SORT_ORDER, + }, + { headerText: 'id', isSortable: false, width: 100 }, + ], + rows: !element.list?.length + ? null + : element.list.map((child, index) => ({ + id: index, + expandableComponent: ( +
+ +
+ Resolution + {child.resolution} +
+
+ ), + cells: [ + { + component: ( +
+ {child.title} + {child.message} +
+ ), + cellContent: '', + }, + { + component: ( + + {child.severity} + + ), + cellContent: child.severity, + }, + { + component: ( + + {child.id} + + ), + cellContent: child.id, + }, + ], + })), + defaultSortIndex: 1, + entities: mapSeveritiesToSegmentedBarChartEntities(element.misConfSummary.status), + lastScanTimeString, + scanToolId: MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[scanToolName], + hasExpandableRows: true, + status, +}) + +export const getCodeScanMisconfigurations = ( + data: CodeScan['misConfigurations'], + setDetailViewData: OpenDetailViewButtonProps['setDetailViewData'], + lastScanTimeString: string, + status: StatusType['status'], + scanToolName: StatusType['scanToolName'], +) => ({ + headers: [ + { headerText: 'file path (relative)', isSortable: true, width: 289 }, + { headerText: 'scan summary', isSortable: true, width: 289, compareFunc: compareSeverities }, + { headerText: 'type', isSortable: true, width: 150 }, + ], + rows: !data?.list?.length + ? null + : data.list.map((element, index) => ({ + id: index, + expandableComponent: null, + cells: [ + { + component: ( + + {element.filePath} + + ), + cellContent: element.filePath, + }, + { + component: {stringifySeverities(element.misConfSummary.status)}, + cellContent: element.misConfSummary.status, + }, + { + component: null, + cellContent: element.type, + }, + ], + })), +}) + +const getExposedSecretsDetail = ( + element: CodeScanExposedSecretsListType, + lastScanTimeString: string, + status: StatusType['status'], + scanToolName: StatusType['scanToolName'], +) => ({ + titlePrefix: 'File', + title: element.filePath, + headers: [ + { headerText: 'rule id', isSortable: false, width: 450 }, + { + headerText: 'severity', + isSortable: true, + width: 100, + compareFunc: compareSeverity, + defaultSortOrder: SEVERITY_DEFAULT_SORT_ORDER, + }, + { headerText: 'category', isSortable: true, width: 150 }, + ], + rows: !element.list?.length + ? null + : element.list?.map((child, index) => ({ + id: index, + expandableComponent: ( +
+
+ Title + {child.title} +
+ +
+ ), + cells: [ + { + component: {child.ruleId}, + cellContent: '', + }, + { + component: ( + + {child.severity} + + ), + cellContent: child.severity, + }, + { + component: null, + cellContent: child.category, + }, + ], + })), + defaultSortIndex: 1, + entities: mapSeveritiesToSegmentedBarChartEntities(element.summary.severities), + lastScanTimeString, + scanToolId: MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[scanToolName], + hasExpandableRows: true, + status, +}) + +export const getCodeScanExposedSecrets = ( + data: CodeScan['exposedSecrets'], + setDetailViewData: OpenDetailViewButtonProps['setDetailViewData'], + lastScanTimeString: string, + status: StatusType['status'], + scanToolName: StatusType['scanToolName'], +) => ({ + headers: [ + { headerText: 'file path (relative)', isSortable: true, width: 372 }, + { headerText: 'scan summary', isSortable: true, width: 372, compareFunc: compareSeverities }, + ], + rows: !data?.list?.length + ? null + : data.list.map((element, index) => ({ + id: index, + expandableComponent: null, + cells: [ + { + component: ( + + {element.filePath} + + ), + cellContent: element.filePath, + }, + { + component: {stringifySeverities(element.summary.severities)}, + cellContent: element.summary.severities, + }, + ], + })), +}) + +export const getCodeScanTableData = ( + data: CodeScan, + subCategory: SecurityModalStateType['subCategory'], + setDetailViewData: OpenDetailViewButtonProps['setDetailViewData'], + hidePolicy: boolean, +): TablePropsType => { + switch (subCategory) { + case SUB_CATEGORIES.VULNERABILITIES: + return getCodeScanVulnerabilities(data[subCategory], hidePolicy) + case SUB_CATEGORIES.LICENSE: + return getCodeScanLicense(data[subCategory]) + case SUB_CATEGORIES.MISCONFIGURATIONS: + return getCodeScanMisconfigurations( + data[subCategory], + setDetailViewData, + data.StartedOn, + data.status, + data.scanToolName, + ) + case SUB_CATEGORIES.EXPOSED_SECRETS: + return getCodeScanExposedSecrets( + data[subCategory], + setDetailViewData, + data.StartedOn, + data.status, + data.scanToolName, + ) + default: + return null + } +} + +export const getCodeScanInfoCardData = ( + data: CodeScan, + subCategory: SecurityModalStateType['subCategory'], +): InfoCardPropsType => { + switch (subCategory) { + case SUB_CATEGORIES.VULNERABILITIES: + return { + entities: mapSeveritiesToSegmentedBarChartEntities(data[subCategory]?.summary.severities), + lastScanTimeString: data.StartedOn, + scanToolId: MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[data.scanToolName], + } + case SUB_CATEGORIES.LICENSE: + return { + entities: mapSeveritiesToSegmentedBarChartEntities(data[subCategory]?.summary.severities), + lastScanTimeString: data.StartedOn, + scanToolId: MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[data.scanToolName], + } + case SUB_CATEGORIES.MISCONFIGURATIONS: + return { + entities: mapSeveritiesToSegmentedBarChartEntities(data[subCategory]?.misConfSummary.status), + lastScanTimeString: data.StartedOn, + scanToolId: MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[data.scanToolName], + } + case SUB_CATEGORIES.EXPOSED_SECRETS: + return { + entities: mapSeveritiesToSegmentedBarChartEntities(data[subCategory]?.summary.severities), + lastScanTimeString: data.StartedOn, + scanToolId: MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[data.scanToolName], + } + default: + return null + } +} + +const getCompletedEmptyState = ( + data: CodeScan, + subCategory: SecurityModalStateType['subCategory'], + detailViewData: DetailViewDataType, +) => { + /* NOTE: show empty state only when we don't have any data to show */ + if ((data[subCategory]?.list && !detailViewData) || detailViewData?.rows) { + return null + } + + const detailViewTitleText = detailViewData ? `${detailViewData.titlePrefix}: ${detailViewData.title}` : '' + const subTitleText = detailViewTitleText || 'code scan' + const scanToolId = MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[data.scanToolName] + + switch (subCategory) { + case SUB_CATEGORIES.VULNERABILITIES: + return { + ...getScanCompletedEmptyState(scanToolId), + subTitle: `No security vulnerability found in ${subTitleText}`, + } + case SUB_CATEGORIES.LICENSE: + return { + ...getScanCompletedEmptyState(scanToolId), + subTitle: `No license risks found in ${subTitleText}`, + } + case SUB_CATEGORIES.MISCONFIGURATIONS: + return { + ...getScanCompletedEmptyState(scanToolId), + subTitle: `No misconfigurations found in ${subTitleText}`, + } + case SUB_CATEGORIES.EXPOSED_SECRETS: + return { + ...getScanCompletedEmptyState(scanToolId), + subTitle: `No exposed secrets found in ${subTitleText}`, + } + default: + return null + } +} + +export const getCodeScanEmptyState = ( + data: ApiResponseResultType, + subCategory: SecurityModalStateType['subCategory'], + detailViewData: DetailViewDataType, +): EmptyStateType => { + switch (data[CATEGORIES.CODE_SCAN].status) { + case 'Failed': + return SCAN_FAILED_EMPTY_STATE + case 'Completed': + return getCompletedEmptyState(data[CATEGORIES.CODE_SCAN], subCategory, detailViewData) + case 'Progressing': + case 'Running': + default: /* FIXME: backend is sending empty status after re-deployment */ + return SCAN_IN_PROGRESS_EMPTY_STATE + } +} diff --git a/src/Shared/Components/Security/SecurityModal/config/EmptyState.tsx b/src/Shared/Components/Security/SecurityModal/config/EmptyState.tsx new file mode 100644 index 000000000..e4ed43b7f --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/config/EmptyState.tsx @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import { getCodeScanEmptyState } from './CodeScan' +import { getImageScanEmptyState } from './ImageScan' +import { getKubernetesManifestEmptyState } from './KubernetesManifest' +import { ApiResponseResultType, CATEGORIES, DetailViewDataType, SecurityModalStateType } from '../types' + +export const getEmptyStateValues = ( + data: ApiResponseResultType, + category: SecurityModalStateType['category'], + subCategory: SecurityModalStateType['subCategory'], + detailViewData: DetailViewDataType, +) => { + switch (category) { + case CATEGORIES.CODE_SCAN: + return getCodeScanEmptyState(data, subCategory, detailViewData) + case CATEGORIES.IMAGE_SCAN: + return getImageScanEmptyState(data, subCategory, detailViewData) + case CATEGORIES.KUBERNETES_MANIFEST: + return getKubernetesManifestEmptyState(data, subCategory, detailViewData) + default: + return null + } +} diff --git a/src/Shared/Components/Security/SecurityModal/config/ImageScan.tsx b/src/Shared/Components/Security/SecurityModal/config/ImageScan.tsx new file mode 100644 index 000000000..f02c6707c --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/config/ImageScan.tsx @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import dayjs from 'dayjs' +import { Progressing } from '@Common/Progressing' +import { ReactComponent as ICError } from '@Icons/ic-error-cross.svg' +import { ReactComponent as ICSuccess } from '@Icons/ic-success.svg' +import { ZERO_TIME_STRING, DATE_TIME_FORMATS } from '../../../../../Common/Constants' +import { + DetailViewDataType, + ImageScanVulnerabilityListType, + ImageScan, + SUB_CATEGORIES, + SecurityModalStateType, + TablePropsType, + StatusType, + InfoCardPropsType, + ImageScanLicenseListType, + EmptyStateType, + ApiResponseResultType, + CATEGORIES, + OpenDetailViewButtonProps, +} from '../types' +import { + compareSeverities, + compareSeverity, + getScanCompletedEmptyState, + groupByTarget, + mapSeveritiesToSegmentedBarChartEntities, + stringifySeverities, +} from '../utils' +import { + MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID, + SCAN_FAILED_EMPTY_STATE, + SCAN_IN_PROGRESS_EMPTY_STATE, + SEVERITY_DEFAULT_SORT_ORDER, +} from '../constants' +import { getCodeScanVulnerabilities } from './CodeScan' +import { OpenDetailViewButton } from '../components' + +const getVulnerabilitiesDetailBaseData = (element: ImageScanVulnerabilityListType) => ({ + titlePrefix: 'Image', + title: element.image, + entities: mapSeveritiesToSegmentedBarChartEntities(element.summary.severities), + lastScanTimeString: element.StartedOn, + scanToolId: MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[element.scanToolName], + status: element.status, +}) + +const getGroupedVulnerabilitiesDetailData = ( + element: ImageScanVulnerabilityListType, + setDetailViewData: OpenDetailViewButtonProps['setDetailViewData'], + hidePolicy: boolean, +) => { + const list = !element?.list?.length ? null : groupByTarget(element.list) + + return { + ...getVulnerabilitiesDetailBaseData(element), + headers: [ + { headerText: 'source', isSortable: false, width: 428 }, + { + headerText: 'vulnerability', + isSortable: true, + width: 300, + compareFunc: compareSeverities, + }, + ], + rows: !list?.length + ? null + : list.map((child, index) => ({ + id: index, + expandableComponent: null, + cells: [ + { + component: ( + + {child.source} + + ), + cellContent: child.source, + }, + { + component: {stringifySeverities(child.summary.severities)}, + cellContent: child.summary.severities, + }, + ], + })), + } +} + +const getVulnerabilitiesDetailData = ( + element: ImageScanVulnerabilityListType, + setDetailViewData: OpenDetailViewButtonProps['setDetailViewData'], + hidePolicy: boolean, +) => { + const shouldGroupByTarget = element.list.every((item) => !!item.target) + if (!shouldGroupByTarget) { + return { + ...getVulnerabilitiesDetailBaseData(element), + ...getCodeScanVulnerabilities(element, hidePolicy), + } + } + return getGroupedVulnerabilitiesDetailData(element, setDetailViewData, hidePolicy) +} + +const getImageScanProgressingState = (status: StatusType['status']) => { + switch (status) { + case 'Completed': + return + case 'Failed': + return + case 'Progressing': + return ( + + ) + default: + return null + } +} + +const getTimeString = (timeString: string, status: StatusType['status']) => { + if (timeString && timeString !== ZERO_TIME_STRING && status === 'Completed') { + return dayjs(timeString).format(DATE_TIME_FORMATS.TWELVE_HOURS_FORMAT) + } + if (status === 'Progressing') { + return 'Scan in progress' + } + return null +} + +const getVulnerabilitiesData = ( + data: ImageScan['vulnerability'], + setDetailViewData: OpenDetailViewButtonProps['setDetailViewData'], + hidePolicy: boolean, +) => ({ + headers: [ + { headerText: 'image', isSortable: false, width: 256 }, + { headerText: 'vulnerability', isSortable: false, width: 256 }, + { headerText: 'last scanned on', isSortable: false, width: 200 }, + ], + rows: !data?.list?.length + ? null + : data.list.map((element, index) => ({ + id: index, + expandableComponent: null, + cells: [ + { + component: ( + + {element.image} + + ), + cellContent: element.image, + }, + { + component: null, + cellContent: + stringifySeverities(element.summary.severities) || + (element.status === 'Completed' ? 'No vulnerability found' : '-'), + }, + { + component: ( +
+ {getImageScanProgressingState(element.status)} + {getTimeString(element.StartedOn, element.status)} +
+ ), + cellContent: element.status, + }, + ], + })), +}) + +const getLicenseDetailData = (element: ImageScanLicenseListType) => ({ + titlePrefix: 'Image', + title: element.image, + headers: [ + { headerText: 'classification', isSortable: false, width: 150 }, + { + headerText: 'severity', + isSortable: true, + width: 100, + compareFunc: compareSeverity, + defaultSortOrder: SEVERITY_DEFAULT_SORT_ORDER, + }, + { headerText: 'license', isSortable: false, width: 150 }, + { headerText: 'package', isSortable: true, width: 296 }, + ], + rows: !element?.list?.length + ? null + : element.list.map((child, index) => ({ + id: index, + expandableComponent: null, + cells: [ + { + component: ( + + {child.classification} + + ), + cellContent: child.classification, + }, + { + component: ( + + {child.severity} + + ), + cellContent: child.severity, + }, + { + component: null, + cellContent: child.license, + }, + { + component: null, + cellContent: child.package || child.source, + }, + ], + })), + defaultSortIndex: 1, + entities: mapSeveritiesToSegmentedBarChartEntities(element.summary.severities), + lastScanTimeString: element.StartedOn, + scanToolId: MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[element.scanToolName], + status: element.status, +}) + +const getLicenseData = ( + data: ImageScan['license'], + setDetailViewData: OpenDetailViewButtonProps['setDetailViewData'], +) => ({ + headers: [ + { headerText: 'image', isSortable: false, width: 256 }, + { headerText: 'risks detected', isSortable: false, width: 256 }, + { headerText: 'last scanned on', isSortable: false, width: 200 }, + ], + rows: !data?.list?.length + ? null + : data.list.map((element, index) => ({ + id: index, + expandableComponent: null, + cells: [ + { + component: ( + + {element.image} + + ), + cellContent: element.image, + }, + { + component: null, + cellContent: + stringifySeverities(element.summary.severities) || + (element.status === 'Completed' ? 'No license risk found' : '-'), + }, + { + component: ( +
+ {getImageScanProgressingState(element.status)} + {getTimeString(element.StartedOn, element.status)} +
+ ), + cellContent: element.status, + }, + ], + })), +}) + +export const getImageScanTableData = ( + data: ImageScan, + subCategory: SecurityModalStateType['subCategory'], + setDetailViewData: OpenDetailViewButtonProps['setDetailViewData'], + hidePolicy: boolean, +): TablePropsType => { + switch (subCategory) { + case SUB_CATEGORIES.VULNERABILITIES: + return getVulnerabilitiesData(data[subCategory], setDetailViewData, hidePolicy) + case SUB_CATEGORIES.LICENSE: + return getLicenseData(data[subCategory], setDetailViewData) + default: + return null + } +} + +export const getImageScanInfoCardData = ( + data: ImageScan, + subCategory: SecurityModalStateType['subCategory'], +): InfoCardPropsType => { + switch (subCategory) { + case SUB_CATEGORIES.VULNERABILITIES: + return { + entities: mapSeveritiesToSegmentedBarChartEntities(data[subCategory]?.summary.severities), + } + case SUB_CATEGORIES.LICENSE: + return { + entities: mapSeveritiesToSegmentedBarChartEntities(data[subCategory]?.summary.severities), + } + default: + return null + } +} + +const getCompletedEmptyState = ( + subCategory: SecurityModalStateType['subCategory'], + detailViewData: NonNullable, +) => { + /** + * NOTE: show empty state only when we don't have any data to show; + * ImageScan can only have empty state in detailView */ + if (detailViewData.rows) { + return null + } + + const detailViewTitleText = `${detailViewData.titlePrefix}: ${detailViewData.title}` + + switch (subCategory) { + case SUB_CATEGORIES.VULNERABILITIES: + return { + ...getScanCompletedEmptyState(detailViewData.scanToolId), + subTitle: `No security vulnerability found in ${detailViewTitleText}`, + } + case SUB_CATEGORIES.LICENSE: + return { + ...getScanCompletedEmptyState(detailViewData.scanToolId), + subTitle: `No license risk found in ${detailViewTitleText}`, + } + default: + return null + } +} + +export const getImageScanEmptyState = ( + data: ApiResponseResultType, + subCategory: SecurityModalStateType['subCategory'], + detailViewData: DetailViewDataType, +): EmptyStateType => { + /** + * NOTE: handling for resourceScan in ResourceBrowser + * TODO: handle properly */ + if (!data[CATEGORIES.IMAGE_SCAN][subCategory]?.list?.length) { + return SCAN_IN_PROGRESS_EMPTY_STATE + } + + if (!detailViewData) { + return null + } + + switch (detailViewData.status) { + case 'Failed': + return SCAN_FAILED_EMPTY_STATE + case 'Completed': + return getCompletedEmptyState(subCategory, detailViewData) + case 'Progressing': + case 'Running': + default: + return SCAN_IN_PROGRESS_EMPTY_STATE + } +} diff --git a/src/Shared/Components/Security/SecurityModal/config/InfoCard.tsx b/src/Shared/Components/Security/SecurityModal/config/InfoCard.tsx new file mode 100644 index 000000000..0995384a0 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/config/InfoCard.tsx @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import { InfoCardPropsType, ApiResponseResultType, CATEGORIES, SecurityModalStateType } from '../types' +import { getImageScanInfoCardData } from './ImageScan' +import { getCodeScanInfoCardData } from './CodeScan' +import { getKubernetesManifestInfoCardData } from './KubernetesManifest' + +export const getInfoCardData = ( + data: ApiResponseResultType, + category: SecurityModalStateType['category'], + subCategory: SecurityModalStateType['subCategory'], +): InfoCardPropsType => { + switch (category) { + case CATEGORIES.IMAGE_SCAN: + return getImageScanInfoCardData(data[category], subCategory) + case CATEGORIES.CODE_SCAN: + return getCodeScanInfoCardData(data[category], subCategory) + case CATEGORIES.KUBERNETES_MANIFEST: + return getKubernetesManifestInfoCardData(data[category], subCategory) + default: + return null + } +} diff --git a/src/Shared/Components/Security/SecurityModal/config/KubernetesManifest.tsx b/src/Shared/Components/Security/SecurityModal/config/KubernetesManifest.tsx new file mode 100644 index 000000000..1e20caf79 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/config/KubernetesManifest.tsx @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import { getScanCompletedEmptyState, mapSeveritiesToSegmentedBarChartEntities } from '../utils' +import { + ApiResponseResultType, + CATEGORIES, + DetailViewDataType, + EmptyStateType, + InfoCardPropsType, + KubernetesManifest, + OpenDetailViewButtonProps, + SUB_CATEGORIES, + SecurityModalStateType, + TablePropsType, +} from '../types' +import { MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID, SCAN_FAILED_EMPTY_STATE, SCAN_IN_PROGRESS_EMPTY_STATE } from '../constants' +import { getCodeScanExposedSecrets, getCodeScanMisconfigurations } from './CodeScan' + +export const getKubernetesManifestTableData = ( + data: KubernetesManifest, + subCategory: SecurityModalStateType['subCategory'], + setDetailViewData: OpenDetailViewButtonProps['setDetailViewData'], +): TablePropsType => { + switch (subCategory) { + case SUB_CATEGORIES.MISCONFIGURATIONS: + return getCodeScanMisconfigurations( + data[subCategory], + setDetailViewData, + data.StartedOn, + data.status, + data.scanToolName, + ) + case SUB_CATEGORIES.EXPOSED_SECRETS: + return getCodeScanExposedSecrets( + data[subCategory], + setDetailViewData, + data.StartedOn, + data.status, + data.scanToolName, + ) + default: + return null + } +} + +export const getKubernetesManifestInfoCardData = ( + data: KubernetesManifest, + subCategory: SecurityModalStateType['subCategory'], +): InfoCardPropsType => { + switch (subCategory) { + case SUB_CATEGORIES.MISCONFIGURATIONS: + return { + entities: mapSeveritiesToSegmentedBarChartEntities(data[subCategory]?.misConfSummary.status), + lastScanTimeString: data.StartedOn, + scanToolId: MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[data.scanToolName], + } + case SUB_CATEGORIES.EXPOSED_SECRETS: + return { + entities: mapSeveritiesToSegmentedBarChartEntities(data[subCategory]?.summary.severities), + lastScanTimeString: data.StartedOn, + scanToolId: MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[data.scanToolName], + } + default: + return null + } +} + +const getCompletedEmptyState = ( + data: KubernetesManifest, + subCategory: SecurityModalStateType['subCategory'], + detailViewData: DetailViewDataType, +) => { + /* NOTE: check necessary to narrow the types to that which are compatible with KubernetesManifest */ + if (subCategory !== SUB_CATEGORIES.EXPOSED_SECRETS && subCategory !== SUB_CATEGORIES.MISCONFIGURATIONS) { + return null + } + + /* NOTE: if we are in detailView & it has data, no need for EmptyState */ + if ((data[subCategory]?.list && !detailViewData) || detailViewData?.rows) { + return null + } + + const detailViewTitleText = detailViewData ? `${detailViewData.titlePrefix}: ${detailViewData.title}` : '' + const subTitleText = detailViewTitleText || 'Kubernetes manifests' + const scanToolId = MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID[data.scanToolName] + + switch (subCategory) { + case SUB_CATEGORIES.MISCONFIGURATIONS: + /** + * NOTE: if we are not in detail view then check for empty list in the subCategory; + * otherwise the check for emptiness is done at start of the func */ + return { + ...getScanCompletedEmptyState(scanToolId), + subTitle: `No misconfigurations found in ${subTitleText}`, + } + case SUB_CATEGORIES.EXPOSED_SECRETS: + return { + ...getScanCompletedEmptyState(scanToolId), + subTitle: `No exposed secrets found in ${subTitleText}`, + } + default: + return null + } +} + +export const getKubernetesManifestEmptyState = ( + data: ApiResponseResultType, + subCategory: SecurityModalStateType['subCategory'], + detailViewData: DetailViewDataType, +): EmptyStateType => { + switch (data[CATEGORIES.KUBERNETES_MANIFEST].status) { + case 'Failed': + return SCAN_FAILED_EMPTY_STATE + /* FIXME: api is sending empty state for status after re-deployment */ + case 'Completed': + return getCompletedEmptyState(data[CATEGORIES.KUBERNETES_MANIFEST], subCategory, detailViewData) + case 'Progressing': + case 'Running': + default: + return SCAN_IN_PROGRESS_EMPTY_STATE + } +} diff --git a/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts b/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts new file mode 100644 index 000000000..719e49dc5 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import { CATEGORY_LABELS, SUB_CATEGORY_LABELS } from '../constants' +import { CATEGORIES, SUB_CATEGORIES, SidebarDataType } from '../types' + +export const SIDEBAR_DATA: SidebarDataType[] = [ + { + label: CATEGORY_LABELS.IMAGE_SCAN, + isExpanded: true, + children: [ + { + label: SUB_CATEGORY_LABELS.VULNERABILITIES, + value: { + category: CATEGORIES.IMAGE_SCAN, + subCategory: SUB_CATEGORIES.VULNERABILITIES, + }, + }, + { + label: SUB_CATEGORY_LABELS.LICENSE, + value: { + category: CATEGORIES.IMAGE_SCAN, + subCategory: SUB_CATEGORIES.LICENSE, + }, + }, + ], + }, + { + label: CATEGORY_LABELS.CODE_SCAN, + isExpanded: true, + hideInHelmApp: true, + children: [ + { + label: SUB_CATEGORY_LABELS.VULNERABILITIES, + value: { + category: CATEGORIES.CODE_SCAN, + subCategory: SUB_CATEGORIES.VULNERABILITIES, + }, + }, + { + label: SUB_CATEGORY_LABELS.LICENSE, + value: { + category: CATEGORIES.CODE_SCAN, + subCategory: SUB_CATEGORIES.LICENSE, + }, + }, + { + label: SUB_CATEGORY_LABELS.MISCONFIGURATIONS, + value: { + category: CATEGORIES.CODE_SCAN, + subCategory: SUB_CATEGORIES.MISCONFIGURATIONS, + }, + }, + { + label: SUB_CATEGORY_LABELS.EXPOSED_SECRETS, + value: { + category: CATEGORIES.CODE_SCAN, + subCategory: SUB_CATEGORIES.EXPOSED_SECRETS, + }, + }, + ], + }, + { + label: CATEGORY_LABELS.KUBERNETES_MANIFEST, + isExpanded: true, + children: [ + { + label: SUB_CATEGORY_LABELS.MISCONFIGURATIONS, + value: { + category: CATEGORIES.KUBERNETES_MANIFEST, + subCategory: SUB_CATEGORIES.MISCONFIGURATIONS, + }, + }, + { + label: SUB_CATEGORY_LABELS.EXPOSED_SECRETS, + value: { + category: CATEGORIES.KUBERNETES_MANIFEST, + subCategory: SUB_CATEGORIES.EXPOSED_SECRETS, + }, + }, + ], + }, +] diff --git a/src/Shared/Components/Security/SecurityModal/config/Table.tsx b/src/Shared/Components/Security/SecurityModal/config/Table.tsx new file mode 100644 index 000000000..5ea02432c --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/config/Table.tsx @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import { + ApiResponseResultType, + CATEGORIES, + OpenDetailViewButtonProps, + SecurityModalStateType, + TablePropsType, +} from '../types' +import { getImageScanTableData } from './ImageScan' +import { getCodeScanTableData } from './CodeScan' +import { getKubernetesManifestTableData } from './KubernetesManifest' + +export const getTableData = ( + data: ApiResponseResultType, + category: SecurityModalStateType['category'], + subCategory: SecurityModalStateType['subCategory'], + setDetailViewData: OpenDetailViewButtonProps['setDetailViewData'], + hidePolicy: boolean, +): TablePropsType => { + switch (category) { + case CATEGORIES.IMAGE_SCAN: + return getImageScanTableData(data[category], subCategory, setDetailViewData, hidePolicy) + case CATEGORIES.CODE_SCAN: + return getCodeScanTableData(data[category], subCategory, setDetailViewData, hidePolicy) + case CATEGORIES.KUBERNETES_MANIFEST: + return getKubernetesManifestTableData(data[category], subCategory, setDetailViewData) + default: + return null + } +} diff --git a/src/Shared/Components/Security/SecurityModal/config/index.ts b/src/Shared/Components/Security/SecurityModal/config/index.ts new file mode 100644 index 000000000..d56e6560e --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/config/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +export { getTableData } from './Table' +export { getInfoCardData } from './InfoCard' +export { SIDEBAR_DATA } from './Sidebar' diff --git a/src/Shared/Components/Security/SecurityModal/constants.tsx b/src/Shared/Components/Security/SecurityModal/constants.tsx new file mode 100644 index 000000000..2d7bf034f --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/constants.tsx @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import { SCAN_TOOL_ID_CLAIR, SCAN_TOOL_ID_TRIVY } from '@Shared/constants' +import PageNotFound from '@Images/ic-page-not-found.svg' +import { ReactComponent as MechanicalOperation } from '@Icons/ic-mechanical-operation.svg' +import { CATEGORIES, SUB_CATEGORIES, SeveritiesDTO, SortOrderEnum, EmptyStateType, StatusType } from './types' + +export const DEFAULT_SECURITY_MODAL_STATE = { + category: CATEGORIES.IMAGE_SCAN, + subCategory: SUB_CATEGORIES.VULNERABILITIES, + detailViewData: null, +} + +export const CATEGORY_LABELS = { + IMAGE_SCAN: 'Image Scan', + CODE_SCAN: 'Code Scan', + KUBERNETES_MANIFEST: 'Kubernetes Manifest', +} as const + +export const SUB_CATEGORY_LABELS = { + VULNERABILITIES: 'Vulnerability', + LICENSE: 'License Risks', + MISCONFIGURATIONS: 'Misconfigurations', + EXPOSED_SECRETS: 'Exposed Secrets', +} as const + +export const SEVERITIES = { + [SeveritiesDTO.CRITICAL]: { + label: 'Critical', + color: '#B21212', + }, + [SeveritiesDTO.HIGH]: { + label: 'High', + color: '#F33E3E', + }, + [SeveritiesDTO.MEDIUM]: { + label: 'Medium', + color: '#FF7E5B', + }, + [SeveritiesDTO.LOW]: { + label: 'Low', + color: '#FFB549', + }, + [SeveritiesDTO.UNKNOWN]: { + label: 'Unknown', + color: '#B1B7BC', + }, + [SeveritiesDTO.FAILURES]: { + label: 'Failures', + color: '#F33E3E', + }, + [SeveritiesDTO.SUCCESSES]: { + label: 'Successes', + color: '#1DAD70', + }, + [SeveritiesDTO.EXCEPTIONS]: { + label: 'Exceptions', + color: '#B1B7BC', + }, +} as const + +export const ORDERED_SEVERITY_KEYS = [ + SeveritiesDTO.CRITICAL, + SeveritiesDTO.HIGH, + SeveritiesDTO.MEDIUM, + SeveritiesDTO.LOW, + SeveritiesDTO.UNKNOWN, + SeveritiesDTO.FAILURES, + SeveritiesDTO.EXCEPTIONS, + SeveritiesDTO.SUCCESSES, +] as const + +export const SEVERITY_DEFAULT_SORT_ORDER = SortOrderEnum.DESC + +export const SCAN_FAILED_EMPTY_STATE: EmptyStateType = { + image: PageNotFound, + title: 'Scan failed', + subTitle: 'Error: Security scan failed', +} + +export const SCAN_IN_PROGRESS_EMPTY_STATE: EmptyStateType = { + SvgImage: MechanicalOperation, + title: 'Scan in progress', + subTitle: 'Scan result will be available once complete. Please check again later', +} + +export const MAP_SCAN_TOOL_NAME_TO_SCAN_TOOL_ID: Record = { + TRIVY: SCAN_TOOL_ID_TRIVY, + CLAIR: SCAN_TOOL_ID_CLAIR, +} diff --git a/src/Shared/Components/Security/SecurityModal/index.ts b/src/Shared/Components/Security/SecurityModal/index.ts new file mode 100644 index 000000000..75f34a8e1 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +export { default as SecurityModal } from './SecurityModal' +export { + getSecurityScanSeveritiesCount, + getTotalVulnerabilityCount, + parseGetResourceScanDetailsResponse, + parseExecutionDetailResponse, +} from './utils' +export type { + AppDetailsPayload, + ExecutionDetailsPayload, + ApiResponseResultType, + SidebarPropsType, + SidebarDataChildType, + SidebarDataType, + GetResourceScanDetailsPayloadType, + GetResourceScanDetailsResponseType, +} from './types' +export { SIDEBAR_DATA } from './config' +export { CATEGORY_LABELS } from './constants' +export { getExecutionDetails } from './service' diff --git a/src/Shared/Components/Security/SecurityModal/service.ts b/src/Shared/Components/Security/SecurityModal/service.ts new file mode 100644 index 000000000..0deb4eca3 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/service.ts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import { get } from '@Common/Api' +import { ResponseType } from '@Common/Types' +import { getUrlWithSearchParams } from '@Common/Helper' +import { ROUTES } from '@Common/Constants' +import { ApiResponseResultType, ExecutionDetailsPayload } from './types' +import { parseExecutionDetailResponse } from './utils' + +export const getExecutionDetails = async ( + executionDetailPayload: ExecutionDetailsPayload, +): Promise> => { + const url = getUrlWithSearchParams(ROUTES.SECURITY_SCAN_EXECUTION_DETAILS, executionDetailPayload) + const response = await get(url) + const parsedResult = { + ...(response.result || {}), + scanExecutionId: response.result?.ScanExecutionId, + lastExecution: response.result?.executionTime, + objectType: response.result?.objectType, + vulnerabilities: + response.result?.vulnerabilities?.map((cve) => ({ + name: cve.cveName, + severity: cve.severity, + package: cve.package, + version: cve.currentVersion, + fixedVersion: cve.fixedVersion, + permission: cve.permission, + })) || [], + } + return { ...response, result: parseExecutionDetailResponse(parsedResult) } +} diff --git a/src/Shared/Components/Security/SecurityModal/styles.scss b/src/Shared/Components/Security/SecurityModal/styles.scss new file mode 100644 index 000000000..8fdf23372 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/styles.scss @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +button.selected-tab { + border-bottom: 2px solid var(--B500); +} + +.info-card { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + border-radius: 8px; + border: 1px solid var(--N200, #D0D4D9); + background: var(--N0, #FFF); + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.10); +} diff --git a/src/Shared/Components/Security/SecurityModal/types.ts b/src/Shared/Components/Security/SecurityModal/types.ts new file mode 100644 index 000000000..adbf3f187 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/types.ts @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import React from 'react' +import { GenericEmptyStateType } from '@Common/Types' +import { LastExecutionResultType, NodeType, Nodes } from '@Shared/types' +import { SegmentedBarChartProps } from '@Common/SegmentedBarChart' +import { ServerErrors } from '@Common/ServerError' + +export interface GetResourceScanDetailsPayloadType { + name: string + namespace: string + group: string + version: string + kind: Nodes | NodeType + clusterId: number + appId?: string + appType?: number + deploymentType?: number + isAppDetailView?: boolean +} + +export interface AppDetailsPayload { + appId?: number | string + envId?: number | string + installedAppId?: number | string + artifactId?: number | string + installedAppVersionHistoryId?: number | string +} + +export interface ExecutionDetailsPayload extends Partial> { + imageScanDeployInfoId?: number | string + artifactId?: number | string +} + +export const CATEGORIES = { + IMAGE_SCAN: 'imageScan', + CODE_SCAN: 'codeScan', + KUBERNETES_MANIFEST: 'kubernetesManifest', +} as const + +export const SUB_CATEGORIES = { + VULNERABILITIES: 'vulnerability', + LICENSE: 'license', + MISCONFIGURATIONS: 'misConfigurations', + EXPOSED_SECRETS: 'exposedSecrets', +} as const + +export enum SortOrderEnum { + 'ASC' = 1, + 'DESC' = -1, +} + +export type TableRowCellType = { + component: React.ReactNode | JSX.Element + cellContent: string | object +} + +export type TableHeaderCellType = { + headerText: string + isSortable: boolean + width: number + compareFunc?: (a: TableRowCellType['cellContent'], b: TableRowCellType['cellContent']) => number + defaultSortOrder?: SortOrderEnum +} + +export interface TableRowType { + id: string | number + cells: Array + expandableComponent: React.ReactNode | JSX.Element +} + +export interface TablePropsType { + headers: Array + rows: Array + defaultSortIndex?: number + hasExpandableRows?: boolean + /* TODO: a better/more meaningful name? */ + headerTopPosition?: number +} + +export type TableSortStateType = { + index: number + order: SortOrderEnum +} + +export interface InfoCardPropsType { + entities: SegmentedBarChartProps['entities'] + lastScanTimeString?: string + scanToolId?: number +} + +export interface StatusType { + status: 'Completed' | 'Running' | 'Failed' | 'Progressing' + StartedOn: string + scanToolName: 'TRIVY' | 'CLAIR' +} + +export type DetailViewDataType = { + titlePrefix: string + title: string + status: StatusType['status'] +} & TablePropsType & + InfoCardPropsType + +export type SecurityModalStateType = { + category: (typeof CATEGORIES)[keyof typeof CATEGORIES] + subCategory: (typeof SUB_CATEGORIES)[keyof typeof SUB_CATEGORIES] + detailViewData: DetailViewDataType[] +} + +export interface SidebarPropsType { + isHelmApp: boolean + modalState: SecurityModalStateType + setModalState: React.Dispatch> + isExternalCI: boolean +} + +export enum SeveritiesDTO { + CRITICAL = 'CRITICAL', + HIGH = 'HIGH', + MEDIUM = 'MEDIUM', + LOW = 'LOW', + UNKNOWN = 'UNKNOWN', + FAILURES = 'fail', + SUCCESSES = 'success', + EXCEPTIONS = 'exceptions', +} + +type Summary = Record>> + +type GenericGroupType = { + list: T[] +} + +type GenericGroupTypeWithSummary = { + summary: Summary<'severities'> +} & GenericGroupType + +type GenericGroupTypeWithMisConfSummary = { + misConfSummary: Summary<'status'> +} & GenericGroupType + +export interface CodeScanVulnerabilityType { + cveId: string + severity: SeveritiesDTO + package: string + currentVersion: string + fixedInVersion: string + permission?: string +} + +export interface ImageScanVulnerabilityType extends CodeScanVulnerabilityType { + target?: string +} + +export interface ImageScanVulnerabilityListType extends StatusType { + image: string + summary: Summary<'severities'> + list: ImageScanVulnerabilityType[] +} + +export interface CodeScanLicenseType { + classification: string + severity: string + license: string + package: string + source: string +} + +export interface ImageScanLicenseListType extends StatusType { + image: string + summary: Summary<'severities'> + list: CodeScanLicenseType[] +} + +export type ImageScan = { + [SUB_CATEGORIES.VULNERABILITIES]: GenericGroupTypeWithSummary + [SUB_CATEGORIES.LICENSE]: GenericGroupTypeWithSummary +} + +export interface Line { + number: number + content: string + isCause: boolean + truncated: boolean +} + +export interface CauseMetadata { + startLine: number + EndLine: number + lines?: Line[] +} + +export interface CodeScanMisconfigurationsDetailListType { + id: string + title: string + message: string + resolution: string + status: string + severity: string + causeMetadata: CauseMetadata +} + +export interface CodeScanMisconfigurationsListType { + filePath: string + link: string + type: string + misConfSummary: Summary<'status'> + summary: Summary<'severities'> + list: CodeScanMisconfigurationsDetailListType[] +} + +export interface CodeScanExposedSecretsDetailListType { + severity: string + ruleId: string + category: string + startLine: number + EndLine: number + title: string /* TODO: confirm with real data */ + lines: Line[] +} + +export interface CodeScanExposedSecretsListType { + filePath: string + link: string + summary: Summary<'severities'> + list: CodeScanExposedSecretsDetailListType[] +} + +export type CodeScan = { + [SUB_CATEGORIES.VULNERABILITIES]: GenericGroupTypeWithSummary + [SUB_CATEGORIES.LICENSE]: GenericGroupTypeWithSummary + [SUB_CATEGORIES.MISCONFIGURATIONS]: GenericGroupTypeWithMisConfSummary + [SUB_CATEGORIES.EXPOSED_SECRETS]: GenericGroupTypeWithSummary +} & StatusType + +export type KubernetesManifest = { + [SUB_CATEGORIES.MISCONFIGURATIONS]: GenericGroupTypeWithMisConfSummary + [SUB_CATEGORIES.EXPOSED_SECRETS]: GenericGroupTypeWithSummary +} & StatusType + +export type ApiResponseResultType = { + scanned: boolean + [CATEGORIES.IMAGE_SCAN]: ImageScan + [CATEGORIES.CODE_SCAN]: CodeScan + [CATEGORIES.KUBERNETES_MANIFEST]: KubernetesManifest +} + +interface SecurityModalBaseProps extends Partial> { + isLoading: boolean + error: ServerErrors + responseData: ApiResponseResultType + handleModalClose: (event?: React.MouseEvent) => void + Sidebar: React.FC + isHelmApp?: boolean + isResourceScan?: boolean + isSecurityScanV2Enabled: boolean + hidePolicy?: boolean +} + +export type SecurityModalPropsType = SecurityModalBaseProps + +export interface IndexedTextDisplayPropsType { + title: string + lines: Line[] + link: string +} + +export type SidebarDataChildType = { + label: string + value: { + category: (typeof CATEGORIES)[keyof typeof CATEGORIES] + subCategory: (typeof SUB_CATEGORIES)[keyof typeof SUB_CATEGORIES] + } +} + +export type SidebarDataType = { + label: string + isExpanded: boolean + children: NonNullable + hideInHelmApp?: boolean +} + +export type EmptyStateType = Pick + +export const VulnerabilityState = { + [-1]: 'Failed', + 0: 'Progressing', + 1: 'Completed', +} as const + +export interface ImageVulnerabilityType { + image: string + state: keyof typeof VulnerabilityState + error?: string + scanResult: LastExecutionResultType | null +} + +export interface VulnerabilityCountType { + unknownVulnerabilitiesCount: number + lowVulnerabilitiesCount: number + mediumVulnerabilitiesCount: number + highVulnerabilitiesCount: number + criticalVulnerabilitiesCount: number +} + +export interface GetResourceScanDetailsResponseType extends VulnerabilityCountType { + imageVulnerabilities: ImageVulnerabilityType[] +} + +export interface OpenDetailViewButtonProps { + detailViewData: DetailViewDataType + setDetailViewData: (detailViewData: DetailViewDataType) => void +} diff --git a/src/Shared/Components/Security/SecurityModal/utils.tsx b/src/Shared/Components/Security/SecurityModal/utils.tsx new file mode 100644 index 000000000..d58b5a762 --- /dev/null +++ b/src/Shared/Components/Security/SecurityModal/utils.tsx @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +import { ScannedByToolModal } from '@Shared/Components/ScannedByToolModal' +import { SegmentedBarChartProps } from '@Common/SegmentedBarChart' +import { Severity } from '@Shared/types' +import { VulnerabilityType } from '@Common/Types' +import { ZERO_TIME_STRING } from '@Common/Constants' +import { ReactComponent as NoVulnerability } from '@Icons/ic-vulnerability-not-found.svg' +import { SCAN_TOOL_ID_TRIVY } from '@Shared/constants' +import { + ApiResponseResultType, + SeveritiesDTO, + CATEGORIES, + GetResourceScanDetailsResponseType, + ImageScanVulnerabilityType, + ImageVulnerabilityType, + SUB_CATEGORIES, + VulnerabilityCountType, + VulnerabilityState, +} from './types' +import { SEVERITIES, ORDERED_SEVERITY_KEYS } from './constants' + +export const mapSeveritiesToSegmentedBarChartEntities = ( + severities: Partial>, +) => + /* for all the SEVERITY keys in @severities create @Entity */ + severities && + ORDERED_SEVERITY_KEYS.map( + (key: keyof typeof SEVERITIES) => + severities[key] && { + color: SEVERITIES[key].color, + label: SEVERITIES[key].label, + value: severities[key], + }, + ).filter((entity: SegmentedBarChartProps['entities'][number]) => !!entity) + +export const stringifySeverities = (severities: Partial>) => + severities && + Object.keys(severities) + .sort( + (a: keyof typeof SEVERITIES, b: keyof typeof SEVERITIES) => + ORDERED_SEVERITY_KEYS.indexOf(a) - ORDERED_SEVERITY_KEYS.indexOf(b), + ) + .map((key: keyof typeof SEVERITIES) => `${severities[key]} ${SEVERITIES[key].label}`) + .join(', ') + +export const getSeverityWeight = (severity: SeveritiesDTO): number => + ({ + [SeveritiesDTO.UNKNOWN]: 1, + [SeveritiesDTO.LOW]: 2, + [SeveritiesDTO.MEDIUM]: 3, + [SeveritiesDTO.HIGH]: 4, + [SeveritiesDTO.CRITICAL]: 5, + [SeveritiesDTO.FAILURES]: 6, + [SeveritiesDTO.EXCEPTIONS]: 7, + [SeveritiesDTO.SUCCESSES]: 8, + })[severity] || 10000 +/* NOTE: not using POS_INFY or MAX_VALUE due to possibility of NaN & overflow */ + +export const compareSeverity = (a: SeveritiesDTO, b: SeveritiesDTO) => getSeverityWeight(a) - getSeverityWeight(b) + +export const getSecurityScanSeveritiesCount = (data: ApiResponseResultType) => { + const imageScanSeverities = data[CATEGORIES.IMAGE_SCAN].vulnerability?.summary?.severities + const codeScanSeverities = data[CATEGORIES.CODE_SCAN].vulnerability?.summary?.severities + return { + critical: (imageScanSeverities?.CRITICAL || 0) + (codeScanSeverities?.CRITICAL || 0), + high: (imageScanSeverities?.HIGH || 0) + (codeScanSeverities?.HIGH || 0), + medium: (imageScanSeverities?.MEDIUM || 0) + (codeScanSeverities?.MEDIUM || 0), + low: (imageScanSeverities?.LOW || 0) + (codeScanSeverities?.LOW || 0), + unknown: (imageScanSeverities?.UNKNOWN || 0) + (codeScanSeverities?.UNKNOWN || 0), + } +} + +export const compareSeverities = (a: Record, b: Record) => + ORDERED_SEVERITY_KEYS.reduce((result, currentKey) => result || a[currentKey] - b[currentKey], 0) + +export const getScanCompletedEmptyState = (scanToolId: number) => ({ + SvgImage: NoVulnerability, + title: "You're secure!", + children: ( + + + + ), +}) + +export const compareStringAndObject = (a: string | object, b: string | object) => + a.toString().localeCompare(b.toString()) + +const getSeverityFromVulnerabilitySeverity = (severity: VulnerabilityType['severity']) => { + switch (severity.toLowerCase()) { + case Severity.HIGH: + return SeveritiesDTO.HIGH + case Severity.UNKNOWN: + return SeveritiesDTO.UNKNOWN + case Severity.MEDIUM: + return SeveritiesDTO.MEDIUM + case Severity.LOW: + return SeveritiesDTO.LOW + case Severity.CRITICAL: + return SeveritiesDTO.CRITICAL + default: + return null + } +} + +export const parseExecutionDetailResponse = (scanResult): ApiResponseResultType => ({ + [CATEGORIES.IMAGE_SCAN]: { + [SUB_CATEGORIES.VULNERABILITIES]: { + summary: { + severities: { + [SeveritiesDTO.CRITICAL]: scanResult.severityCount?.critical || 0, + [SeveritiesDTO.HIGH]: scanResult.severityCount?.high || 0, + [SeveritiesDTO.MEDIUM]: scanResult.severityCount?.medium || 0, + [SeveritiesDTO.LOW]: scanResult.severityCount?.low || 0, + [SeveritiesDTO.UNKNOWN]: scanResult.severityCount?.unknown || 0, + }, + }, + list: [ + { + image: scanResult.image, + summary: { + severities: { + [SeveritiesDTO.CRITICAL]: scanResult.severityCount?.critical || 0, + [SeveritiesDTO.HIGH]: scanResult.severityCount?.high || 0, + [SeveritiesDTO.MEDIUM]: scanResult.severityCount?.medium || 0, + [SeveritiesDTO.LOW]: scanResult.severityCount?.low || 0, + [SeveritiesDTO.UNKNOWN]: scanResult.severityCount?.unknown || 0, + }, + }, + list: scanResult.vulnerabilities?.map((vulnerability) => ({ + cveId: vulnerability?.name, + package: vulnerability?.package, + currentVersion: vulnerability?.version, + fixedInVersion: vulnerability?.fixedVersion, + severity: getSeverityFromVulnerabilitySeverity(vulnerability?.severity), + permission: vulnerability?.permission, + })), + scanToolName: scanResult.scanToolId === SCAN_TOOL_ID_TRIVY ? 'TRIVY' : 'CLAIR', + StartedOn: + scanResult.executionTime && scanResult.executionTime !== ZERO_TIME_STRING + ? scanResult.executionTime + : '--', + status: scanResult.scanned ? 'Completed' : 'Progressing', + }, + ], + }, + [SUB_CATEGORIES.LICENSE]: null, + }, + [CATEGORIES.CODE_SCAN]: null, + [CATEGORIES.KUBERNETES_MANIFEST]: null, + scanned: scanResult.scanned, +}) + +export const parseGetResourceScanDetailsResponse = ( + data: GetResourceScanDetailsResponseType, +): ApiResponseResultType => ({ + [CATEGORIES.IMAGE_SCAN]: { + [SUB_CATEGORIES.VULNERABILITIES]: { + summary: { + severities: { + [SeveritiesDTO.CRITICAL]: data.criticalVulnerabilitiesCount || 0, + [SeveritiesDTO.HIGH]: data.highVulnerabilitiesCount || 0, + [SeveritiesDTO.MEDIUM]: data.mediumVulnerabilitiesCount || 0, + [SeveritiesDTO.LOW]: data.lowVulnerabilitiesCount || 0, + [SeveritiesDTO.UNKNOWN]: data.unknownVulnerabilitiesCount || 0, + }, + }, + list: data.imageVulnerabilities.map((value) => ({ + image: value.image, + summary: { + severities: { + ...(value.scanResult.severityCount.critical + ? { [SeveritiesDTO.CRITICAL]: value.scanResult.severityCount.critical } + : {}), + ...(value.scanResult.severityCount.high + ? { [SeveritiesDTO.HIGH]: value.scanResult.severityCount.high } + : {}), + ...(value.scanResult.severityCount.medium + ? { [SeveritiesDTO.MEDIUM]: value.scanResult.severityCount.medium } + : {}), + ...(value.scanResult.severityCount.low + ? { [SeveritiesDTO.LOW]: value.scanResult.severityCount.low } + : {}), + ...(value.scanResult.severityCount.unknown + ? { [SeveritiesDTO.LOW]: value.scanResult.severityCount.unknown } + : {}), + }, + }, + list: value.scanResult.vulnerabilities.map((vulnerability) => ({ + cveId: vulnerability.name, + package: vulnerability.package, + currentVersion: vulnerability.version, + fixedInVersion: vulnerability.fixedVersion, + severity: getSeverityFromVulnerabilitySeverity(vulnerability.severity), + })), + scanToolName: 'TRIVY' /* TODO: need to create a mapping */, + StartedOn: value.scanResult.lastExecution, + status: VulnerabilityState[value.state], + })), + }, + [SUB_CATEGORIES.LICENSE]: null, + }, + [CATEGORIES.CODE_SCAN]: null, + [CATEGORIES.KUBERNETES_MANIFEST]: null, + scanned: true, +}) + +export const getTotalVulnerabilityCount = (scannedResult: ImageVulnerabilityType[]): VulnerabilityCountType => + scannedResult.reduce( + (acc, imageVulnerability) => { + if (!imageVulnerability?.scanResult?.severityCount) { + return acc + } + + const { + unknownVulnerabilitiesCount, + lowVulnerabilitiesCount, + mediumVulnerabilitiesCount, + highVulnerabilitiesCount, + criticalVulnerabilitiesCount, + } = acc + const { + severityCount: { critical, high, medium, low, unknown }, + } = imageVulnerability.scanResult + + /* NOTE: counts can be sent as undefined */ + return { + unknownVulnerabilitiesCount: unknownVulnerabilitiesCount + (unknown || 0), + lowVulnerabilitiesCount: lowVulnerabilitiesCount + (low || 0), + mediumVulnerabilitiesCount: mediumVulnerabilitiesCount + (medium || 0), + highVulnerabilitiesCount: highVulnerabilitiesCount + (high || 0), + criticalVulnerabilitiesCount: criticalVulnerabilitiesCount + (critical || 0), + } + }, + { + unknownVulnerabilitiesCount: 0, + lowVulnerabilitiesCount: 0, + mediumVulnerabilitiesCount: 0, + highVulnerabilitiesCount: 0, + criticalVulnerabilitiesCount: 0, + }, + ) + +const getSeveritiesFrequencyMap = (severities: SeveritiesDTO[]) => { + const map: Partial> = {} + severities.forEach((severity) => { + map[severity] = (map[severity] ?? 0) + 1 + }) + return map +} + +export const groupByTarget = (list: ImageScanVulnerabilityType[]) => { + const map: Record> = {} + list.forEach((element) => { + if (map[element.target]) { + map[element.target].push(element) + } else { + map[element.target] = [element] + } + }) + return Object.entries(map).map(([key, value]) => ({ + source: key, + list: value, + summary: { + severities: getSeveritiesFrequencyMap(value.map((el) => el.severity)), + }, + })) +} diff --git a/src/Shared/Components/Security/SecuritySummaryCard/SecuritySummaryCard.tsx b/src/Shared/Components/Security/SecuritySummaryCard/SecuritySummaryCard.tsx new file mode 100644 index 000000000..0a0bfa393 --- /dev/null +++ b/src/Shared/Components/Security/SecuritySummaryCard/SecuritySummaryCard.tsx @@ -0,0 +1,77 @@ +import { SegmentedBarChart } from '@Common/SegmentedBarChart' +import { useState } from 'react' +import { ScannedByToolModal } from '../../ScannedByToolModal' +import { SecuritySummaryCardProps } from './types' +import { SecurityModal } from '../SecurityModal' +import { SEVERITIES } from '../SecurityModal/constants' + +const SecuritySummaryCard = ({ + severityCount, + scanToolId, + rootClassName, + isHelmApp = false, + SecurityModalSidebar, + responseData, + isSecurityScanV2Enabled, + hidePolicy = false, +}: SecuritySummaryCardProps) => { + const [showSecurityModal, setShowSecurityModal] = useState(false) + const { critical = 0, high = 0, medium = 0, low = 0, unknown = 0 } = severityCount + const totalCount = critical + high + medium + low + unknown + const entities = [ + { label: SEVERITIES.CRITICAL.label, color: SEVERITIES.CRITICAL.color, value: critical }, + { label: SEVERITIES.HIGH.label, color: SEVERITIES.HIGH.color, value: high }, + { label: SEVERITIES.MEDIUM.label, color: SEVERITIES.MEDIUM.color, value: medium }, + { label: SEVERITIES.LOW.label, color: SEVERITIES.LOW.color, value: low }, + { label: SEVERITIES.UNKNOWN.label, color: SEVERITIES.UNKNOWN.color, value: unknown }, + ] + + const handleOpenSecurityModal = () => { + setShowSecurityModal(true) + } + + const handleCloseSecurityModal = () => { + setShowSecurityModal(false) + } + + return ( + <> +
+
+
+
Security scan summary
+
{totalCount} Vulnerabilities found in image scan
+
+ +
+
+ Details + +
+
+ {showSecurityModal && ( + + )} + + ) +} + +export default SecuritySummaryCard diff --git a/src/Shared/Components/Security/SecuritySummaryCard/index.tsx b/src/Shared/Components/Security/SecuritySummaryCard/index.tsx new file mode 100644 index 000000000..0325b6725 --- /dev/null +++ b/src/Shared/Components/Security/SecuritySummaryCard/index.tsx @@ -0,0 +1 @@ +export { default as SecuritySummaryCard } from './SecuritySummaryCard' diff --git a/src/Shared/Components/Security/SecuritySummaryCard/types.tsx b/src/Shared/Components/Security/SecuritySummaryCard/types.tsx new file mode 100644 index 000000000..de5403bb5 --- /dev/null +++ b/src/Shared/Components/Security/SecuritySummaryCard/types.tsx @@ -0,0 +1,13 @@ +import { SeverityCount } from '@Shared/types' +import { ImageCardAccordionProps } from '@Shared/Components/ImageCardAccordion/types' +import { ApiResponseResultType } from '../SecurityModal' + +export type SecuritySummaryCardProps = { + severityCount: SeverityCount + scanToolId: number + rootClassName?: string + isHelmApp?: boolean + isSecurityScanV2Enabled: boolean + responseData: ApiResponseResultType + hidePolicy?: boolean +} & Pick diff --git a/src/Shared/Components/Security/Vulnerabilities/Vulnerabilities.tsx b/src/Shared/Components/Security/Vulnerabilities/Vulnerabilities.tsx new file mode 100644 index 000000000..7d654437a --- /dev/null +++ b/src/Shared/Components/Security/Vulnerabilities/Vulnerabilities.tsx @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2024. Devtron Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect } from 'react' +import { EMPTY_STATE_STATUS, SCAN_TOOL_ID_CLAIR, SCAN_TOOL_ID_TRIVY } from '@Shared/constants' +import { SeverityCount } from '@Shared/types' +import { Progressing } from '../../../../Common' +import { ScannedByToolModal } from '../../ScannedByToolModal' +import { VulnerabilitiesProps } from './types' +import { SecuritySummaryCard } from '../SecuritySummaryCard' +import { getSeverityCountFromSummary, getTotalSeverityCount } from '../utils' +import { useGetSecurityVulnerabilities } from './utils' + +const Vulnerabilities = ({ + isScanned, + isScanEnabled, + artifactId, + applicationId, + environmentId, + setVulnerabilityCount, + SecurityModalSidebar, + getSecurityScan, +}: VulnerabilitiesProps) => { + const isScanV2Enabled = window._env_.ENABLE_RESOURCE_SCAN_V2 + const { scanDetailsLoading, scanResultResponse, scanDetailsError, reloadScanDetails } = + useGetSecurityVulnerabilities({ + appId: String(applicationId), + artifactId: String(artifactId), + envId: environmentId, + isScanEnabled, + isScanned, + isScanV2Enabled, + getSecurityScan, + }) + + useEffect(() => { + if (scanResultResponse) { + setVulnerabilityCount(scanResultResponse.result.imageScan.vulnerability?.list?.[0].list?.length) + } + }, [scanResultResponse]) + + if (!isScanEnabled) { + return ( +
+

Scan is Disabled

+
+ ) + } + + if (scanDetailsLoading) { + return ( +
+ +
+ ) + } + + if (!isScanned || (scanResultResponse && !scanResultResponse?.result.scanned)) { + return ( +
+

Image was not scanned

+
+ ) + } + + if (scanDetailsError) { + return ( +
+

Failed to fetch vulnerabilities

+ +
+ ) + } + + const scanToolId = + scanResultResponse?.result.imageScan.vulnerability?.list[0].scanToolName === 'TRIVY' + ? SCAN_TOOL_ID_TRIVY + : SCAN_TOOL_ID_CLAIR + const scanResultSeverities = scanResultResponse?.result.imageScan.vulnerability?.summary.severities + const severityCount: SeverityCount = getSeverityCountFromSummary(scanResultSeverities) + + const totalCount = getTotalSeverityCount(severityCount) + + if (!totalCount) { + return ( +
+

+ {EMPTY_STATE_STATUS.CI_DEATILS_NO_VULNERABILITY_FOUND.TITLE} +

+

{EMPTY_STATE_STATUS.CI_DEATILS_NO_VULNERABILITY_FOUND.SUBTITLE}

+

+ {scanResultResponse?.result.imageScan.vulnerability?.list[0].StartedOn} +

+
+ +
+
+ ) + } + + return ( +
+ +
+ ) +} + +export default Vulnerabilities diff --git a/src/Shared/Components/Vulnerabilities/index.ts b/src/Shared/Components/Security/Vulnerabilities/index.ts similarity index 100% rename from src/Shared/Components/Vulnerabilities/index.ts rename to src/Shared/Components/Security/Vulnerabilities/index.ts diff --git a/src/Shared/Components/Security/Vulnerabilities/types.ts b/src/Shared/Components/Security/Vulnerabilities/types.ts new file mode 100644 index 000000000..0ca351157 --- /dev/null +++ b/src/Shared/Components/Security/Vulnerabilities/types.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024. Devtron Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ResponseType } from '@Common/Types' +import { ImageCardAccordionProps } from '@Shared/Components/ImageCardAccordion/types' +import { MaterialSecurityInfoType } from '../../../types' +import { ApiResponseResultType } from '../SecurityModal' + +export interface VulnerabilitiesProps + extends MaterialSecurityInfoType, + Pick { + artifactId: number + applicationId: number + environmentId: number + setVulnerabilityCount: React.Dispatch> +} + +export interface UseGetSecurityVulnerabilitiesProps extends Pick { + artifactId: string + appId: string + envId: number + isScanned: boolean + isScanEnabled: boolean + isScanV2Enabled: boolean +} + +export interface UseGetSecurityVulnerabilitiesReturnType { + scanDetailsLoading: boolean + scanResultResponse: ResponseType + scanDetailsError: any + reloadScanDetails: () => void +} diff --git a/src/Shared/Components/Vulnerabilities/utils.ts b/src/Shared/Components/Security/Vulnerabilities/utils.ts similarity index 59% rename from src/Shared/Components/Vulnerabilities/utils.ts rename to src/Shared/Components/Security/Vulnerabilities/utils.ts index 24e34624e..7b8c95757 100644 --- a/src/Shared/Components/Vulnerabilities/utils.ts +++ b/src/Shared/Components/Security/Vulnerabilities/utils.ts @@ -16,9 +16,11 @@ import moment from 'moment' import { numberComparatorBySortOrder } from '@Shared/Helpers' -import { DATE_TIME_FORMAT_STRING } from '../../constants' -import { SortingOrder, VULNERABILITIES_SORT_PRIORITY, ZERO_TIME_STRING } from '../../../Common' -import { LastExecutionResponseType, LastExecutionResultType } from '../../types' +import { DATE_TIME_FORMAT_STRING } from '../../../constants' +import { SortingOrder, useAsync, VULNERABILITIES_SORT_PRIORITY, ZERO_TIME_STRING } from '../../../../Common' +import { LastExecutionResponseType, LastExecutionResultType } from '../../../types' +import { UseGetSecurityVulnerabilitiesProps, UseGetSecurityVulnerabilitiesReturnType } from './types' +import { getExecutionDetails } from '../SecurityModal' export const getSortedVulnerabilities = (vulnerabilities) => vulnerabilities.sort((a, b) => @@ -61,3 +63,38 @@ export const parseLastExecutionResponse = (response): LastExecutionResponseType ...response, result: getParsedScanResult(response.result), }) + +export const useGetSecurityVulnerabilities = ({ + artifactId, + appId, + envId, + isScanned, + isScanEnabled, + isScanV2Enabled, + getSecurityScan, +}: UseGetSecurityVulnerabilitiesProps): UseGetSecurityVulnerabilitiesReturnType => { + const [executionDetailsLoading, executionDetailsResponse, executionDetailsError, reloadExecutionDetails] = useAsync( + () => getExecutionDetails({ artifactId, appId, envId }), + [], + isScanned && isScanEnabled && !isScanV2Enabled, + { + resetOnChange: false, + }, + ) + + const [scanResultLoading, scanResultResponse, scanResultError, reloadScanResult] = useAsync( + () => getSecurityScan({ artifactId, appId, envId }), + [], + isScanned && isScanEnabled && isScanV2Enabled && !!getSecurityScan, + { + resetOnChange: false, + }, + ) + + return { + scanDetailsLoading: scanResultLoading || executionDetailsLoading, + scanResultResponse: isScanV2Enabled ? scanResultResponse : executionDetailsResponse, + scanDetailsError: scanResultError || executionDetailsError, + reloadScanDetails: isScanV2Enabled ? reloadScanResult : reloadExecutionDetails, + } +} diff --git a/src/Shared/Components/Security/index.tsx b/src/Shared/Components/Security/index.tsx new file mode 100644 index 000000000..8205bc5c6 --- /dev/null +++ b/src/Shared/Components/Security/index.tsx @@ -0,0 +1,4 @@ +export * from './SecuritySummaryCard' +export * from './Vulnerabilities' +export * from './SecurityModal' +export * from './utils' diff --git a/src/Shared/Components/Security/utils.tsx b/src/Shared/Components/Security/utils.tsx new file mode 100644 index 000000000..122c2bd91 --- /dev/null +++ b/src/Shared/Components/Security/utils.tsx @@ -0,0 +1,22 @@ +import { SeverityCount } from '@Shared/types' +import { SeveritiesDTO } from './SecurityModal/types' + +export const getTotalSeverityCount = (severityCount: SeverityCount): number => { + const totalCount = + (severityCount.critical || 0) + + (severityCount.high || 0) + + (severityCount.medium || 0) + + (severityCount.low || 0) + + (severityCount.unknown || 0) + return totalCount +} + +export const getSeverityCountFromSummary = ( + scanResultSeverities: Partial>, +): SeverityCount => ({ + critical: scanResultSeverities?.CRITICAL || 0, + high: scanResultSeverities?.HIGH || 0, + medium: scanResultSeverities?.MEDIUM || 0, + low: scanResultSeverities?.LOW || 0, + unknown: scanResultSeverities?.UNKNOWN || 0, +}) diff --git a/src/Shared/Components/SelectPicker/utils.ts b/src/Shared/Components/SelectPicker/utils.ts index 6a57f70da..f06ea4152 100644 --- a/src/Shared/Components/SelectPicker/utils.ts +++ b/src/Shared/Components/SelectPicker/utils.ts @@ -21,15 +21,24 @@ import { SelectPickerOptionType, SelectPickerProps, SelectPickerVariantType } fr const getMenuWidthFromSize = ( menuSize: SelectPickerProps['menuSize'], -): string => { +): { width: string; minWidth: string } => { switch (menuSize) { case ComponentSizeType.medium: - return '125%' + return { + width: '125%', + minWidth: '250px', + } case ComponentSizeType.large: - return '150%' + return { + width: '150%', + minWidth: '300px', + } case ComponentSizeType.small: default: - return '100%' + return { + width: '100%', + minWidth: '200px', + } } } @@ -95,8 +104,8 @@ export const getCommonSelectStyle = ({ backgroundColor: 'var(--N0)', border: '1px solid var(--N200)', boxShadow: '0px 2px 4px 0px rgba(0, 0, 0, 0.20)', - width: getMenuWidthFromSize(menuSize), - minWidth: '200px', + width: getMenuWidthFromSize(menuSize).width, + minWidth: getMenuWidthFromSize(menuSize).minWidth, zIndex: 'var(--select-picker-menu-index)', ...(shouldMenuAlignRight && { right: 0, diff --git a/src/Shared/Components/TabGroup/TabGroup.component.tsx b/src/Shared/Components/TabGroup/TabGroup.component.tsx index b5a97a5b9..c5f65879b 100644 --- a/src/Shared/Components/TabGroup/TabGroup.component.tsx +++ b/src/Shared/Components/TabGroup/TabGroup.component.tsx @@ -1,20 +1,19 @@ import { Link, NavLink } from 'react-router-dom' -import { ReactComponent as ICErrorExclamation } from '@Icons/ic-error-exclamation.svg' -import { ReactComponent as ICWarning } from '@Icons/ic-warning.svg' import { ComponentSizeType } from '@Shared/constants' import { TabGroupProps, TabProps } from './TabGroup.types' +import { getClassNameBySizeMap, tabGroupClassMap } from './TabGroup.utils' +import { getTabBadge, getTabDescription, getTabIcon, getTabIndicator } from './TabGroup.helpers' + import './TabGroup.scss' -import { getClassNameBySizeMap, getIconColorClassMap } from './TabGroup.utils' const Tab = ({ label, props, tabType, active, - icon: Icon, - iconType = 'fill', + icon, size, badge = null, alignActiveBorderWithContainer, @@ -23,25 +22,34 @@ const Tab = ({ showError, showWarning, disabled, + description, }: TabProps & Pick) => { const { tabClassName, iconClassName, badgeClassName } = getClassNameBySizeMap({ hideTopPadding, alignActiveBorderWithContainer, })[size] + const onClickHandler = ( + e: React.MouseEvent & + React.MouseEvent & + React.MouseEvent, + ) => { + if (active || e.currentTarget.classList.contains('active')) { + e.preventDefault() + } + props?.onClick?.(e) + } + const getTabComponent = () => { const content = ( <> - {showError && } - {!showError && showWarning && } - {!showError && !showWarning && Icon && ( - - )} - {label} - {badge !== null && ( -
{badge}
- )} - {showIndicator && } +

+ {getTabIcon({ className: iconClassName, icon, showError, showWarning })} + {label} + {getTabBadge(badge, badgeClassName)} + {getTabIndicator(showIndicator)} +

+ {getTabDescription(description)} ) @@ -49,9 +57,10 @@ const Tab = ({ case 'link': return ( {content} @@ -59,20 +68,31 @@ const Tab = ({ case 'navLink': return ( {content} ) + case 'block': + return ( +
+ {content} +
+ ) default: return ( @@ -82,7 +102,7 @@ const Tab = ({ return (
  • {getTabComponent()}
  • @@ -96,11 +116,8 @@ export const TabGroup = ({ alignActiveBorderWithContainer, hideTopPadding, }: TabGroupProps) => ( -
    -
      +
      +
        {tabs.map(({ id, ...resProps }) => ( & { className: string }) => { + if (showError) { + return + } + if (showWarning) { + return + } + if (Icon) { + return + } + return null +} + +export const getTabBadge = (badge: TabProps['badge'], className: string) => + badge !== null &&
        {badge}
        + +export const getTabIndicator = (showIndicator: TabProps['showIndicator']) => + showIndicator && + +export const getTabDescription = (description: TabProps['description']) => + description && ( +
          + {Array.isArray(description) + ? description.map((desc, idx) => ( +
        • + {!!idx && } + {desc} +
        • + )) + : description} +
        + ) diff --git a/src/Shared/Components/TabGroup/TabGroup.scss b/src/Shared/Components/TabGroup/TabGroup.scss index 5f7295a6b..3f863feef 100644 --- a/src/Shared/Components/TabGroup/TabGroup.scss +++ b/src/Shared/Components/TabGroup/TabGroup.scss @@ -1,3 +1,13 @@ +@mixin svg-styles($color) { + svg.tab-group__tab__icon *[stroke^='#'] { + stroke: $color; + } + + svg.tab-group__tab__icon *[fill^='#'] { + fill: $color; + } +} + .tab-group { list-style: none; @@ -5,6 +15,8 @@ $parent-selector: &; position: relative; + @include svg-styles(var(--N700)); + &::after { content: ''; position: absolute; @@ -21,32 +33,19 @@ bottom: -1px; } - &:hover { + &:hover:not(.tab-group__tab--block) { color: var(--B500); + @include svg-styles(var(--B500)); + } - #{$parent-selector}__icon { - &--stroke { - stroke: var(--B500); - - path { - stroke: var(--B500); - } - } - - &--fill { - fill: var(--B500); + &--active { + @include svg-styles(var(--B500)); - path { - fill: var(--B500); - } - } + &::after { + background-color: var(--B500); } } - &--active::after { - background-color: var(--B500); - } - &__badge { border-radius: 10px; min-width: 20px; @@ -62,26 +61,15 @@ border-radius: 100%; } + &__description { + list-style: none; + } + &__nav-link { &.active { color: var(--B500); font-weight: 600; - - > #{$parent-selector}__icon--fill { - fill: var(--B500); - - path { - fill: var(--B500); - } - } - - > #{$parent-selector}__icon--stroke { - stroke: var(--B500); - - path { - stroke: var(--B500); - } - } + @include svg-styles(var(--B500)); } &:not(.active) { diff --git a/src/Shared/Components/TabGroup/TabGroup.types.ts b/src/Shared/Components/TabGroup/TabGroup.types.ts index 55e1d55df..893125b9b 100644 --- a/src/Shared/Components/TabGroup/TabGroup.types.ts +++ b/src/Shared/Components/TabGroup/TabGroup.types.ts @@ -8,7 +8,7 @@ type TabComponentProps = TabTypeProps & DataAttributes type ConditionalTabType = | { /** - * Type of the tab, either `button`, `link` or `navLink`. + * Type of the tab, either `button`, `link`, `navLink` or `block`. */ tabType: 'button' /** @@ -22,7 +22,7 @@ type ConditionalTabType = } | { /** - * Type of the tab, either `button`, `link` or `navLink`. + * Type of the tab, either `button`, `link`, `navLink` or `block`. */ tabType: 'navLink' /** @@ -32,11 +32,11 @@ type ConditionalTabType = /** * Active state is determined by matching the URL. */ - active?: false + active?: never | false } | { /** - * Type of the tab, either `button`, `link` or `navLink`. + * Type of the tab, either `button`, `link`, `navLink` or `block`. */ tabType: 'link' /** @@ -48,6 +48,21 @@ type ConditionalTabType = */ active?: boolean } + | { + /** + * Type of the tab, either `button`, `link`, `navLink` or `block`. + * @note When `tabType` is set to `block`, the tab becomes non-interactive. It won't be active and will not have hover states. + */ + tabType: 'block' + /** + * Props passed to div component. + */ + props?: TabComponentProps, 'className' | 'style'>> + /** + * Indicates if the tab is currently active. + */ + active?: never | false + } export type TabProps = { /** @@ -58,15 +73,16 @@ export type TabProps = { * Text label for the tab. */ label: string + /** + * Description for the tab. + * @note - If passed as a `string[]`, it will be rendered with a bullet in-between strings. + */ + description?: string | string[] /** * Icon component to be displayed in the tab. * This should be a functional component that renders an SVG. */ icon?: React.FunctionComponent> - /** - * Type of the icon, determining whether it uses a stroke or fill style. - */ - iconType?: 'stroke' | 'fill' /** * Badge number to be displayed on the tab, typically for notifications. */ @@ -100,7 +116,7 @@ export interface TabGroupProps { * Size of the tabs. * @default ComponentSizeType.large */ - size?: ComponentSizeType.large | ComponentSizeType.medium + size?: ComponentSizeType.large | ComponentSizeType.medium | ComponentSizeType.xl /** * Optional component to be rendered on the right side of the tab list. */ diff --git a/src/Shared/Components/TabGroup/TabGroup.utils.ts b/src/Shared/Components/TabGroup/TabGroup.utils.ts index 2878ad662..916b499ab 100644 --- a/src/Shared/Components/TabGroup/TabGroup.utils.ts +++ b/src/Shared/Components/TabGroup/TabGroup.utils.ts @@ -1,6 +1,6 @@ import { ComponentSizeType } from '@Shared/constants' -import { TabGroupProps, TabProps } from './TabGroup.types' +import { TabGroupProps } from './TabGroup.types' export const getClassNameBySizeMap = ({ hideTopPadding, @@ -23,9 +23,15 @@ export const getClassNameBySizeMap = ({ iconClassName: 'icon-dim-16', badgeClassName: 'fs-12 lh-20', }, + [ComponentSizeType.xl]: { + tabClassName: `min-w-200 fs-13 ${!hideTopPadding ? 'pt-10' : ''} ${alignActiveBorderWithContainer ? 'pb-9' : 'pb-10'}`, + iconClassName: 'icon-dim-16', + badgeClassName: 'fs-12 lh-20', + }, }) -export const getIconColorClassMap = ({ active }: Pick): Record => ({ - fill: `tab-group__tab__icon--fill ${active ? 'fcb-5' : 'fcn-7'}`, - stroke: `tab-group__tab__icon--stroke ${active ? 'scb-5' : 'scn-7'}`, -}) +export const tabGroupClassMap: Record = { + [ComponentSizeType.medium]: 'dc__gap-12', + [ComponentSizeType.large]: 'dc__gap-16', + [ComponentSizeType.xl]: 'dc__gap-16', +} diff --git a/src/Shared/Components/Vulnerabilities/Vulnerabilities.tsx b/src/Shared/Components/Vulnerabilities/Vulnerabilities.tsx deleted file mode 100644 index 71bb1a218..000000000 --- a/src/Shared/Components/Vulnerabilities/Vulnerabilities.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useEffect } from 'react' -import { Progressing, ScanVulnerabilitiesTable, useAsync } from '../../../Common' -import { ScannedByToolModal } from '../ScannedByToolModal' -import { NO_VULNERABILITY_TEXT } from './constants' -import { getLastExecutionByArtifactAppEnv } from './service' -import { VulnerabilitiesProps } from './types' - -const Vulnerabilities = ({ - isScanned, - isScanEnabled, - artifactId, - applicationId, - environmentId, - setVulnerabilityCount, -}: VulnerabilitiesProps) => { - const [areVulnerabilitiesLoading, vulnerabilitiesResponse, vulnerabilitiesError, reloadVulnerabilities] = useAsync( - () => getLastExecutionByArtifactAppEnv(artifactId, applicationId, environmentId), - [], - isScanned && isScanEnabled, - { - resetOnChange: false, - }, - ) - - useEffect(() => { - if (vulnerabilitiesResponse) { - setVulnerabilityCount(vulnerabilitiesResponse.result.vulnerabilities?.length) - } - }, [vulnerabilitiesResponse]) - - if (!isScanned) { - return ( -
        -

        Image was not scanned

        -
        - ) - } - - if (!isScanEnabled) { - return ( -
        -

        Scan is Disabled

        -
        - ) - } - - if (areVulnerabilitiesLoading) { - return ( -
        - -
        - ) - } - - if (vulnerabilitiesError) { - return ( -
        -

        Failed to fetch vulnerabilities

        - -
        - ) - } - - if (vulnerabilitiesResponse.result.vulnerabilities.length === 0) { - return ( -
        -

        {NO_VULNERABILITY_TEXT.SECURED}

        -

        {NO_VULNERABILITY_TEXT.NO_VULNERABILITY_FOUND}

        -

        {vulnerabilitiesResponse.result.lastExecution}

        -

        - -

        -
        - ) - } - - return ( -
        -
        - - Scanned on {vulnerabilitiesResponse.result.lastExecution}  - - - - -
        - - -
        - ) -} - -export default Vulnerabilities diff --git a/src/Shared/Components/Vulnerabilities/constants.ts b/src/Shared/Components/Vulnerabilities/constants.ts deleted file mode 100644 index 9aa48789a..000000000 --- a/src/Shared/Components/Vulnerabilities/constants.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export const NO_VULNERABILITY_TEXT = { - SECURED: 'You’re secure!', - NO_VULNERABILITY_FOUND: 'No security vulnerability found for this image.', -} diff --git a/src/Shared/Components/Vulnerabilities/service.ts b/src/Shared/Components/Vulnerabilities/service.ts deleted file mode 100644 index 0847484a6..000000000 --- a/src/Shared/Components/Vulnerabilities/service.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ROUTES, get, getUrlWithSearchParams } from '../../../Common' -import { LastExecutionResponseType } from '../../types' -import { parseLastExecutionResponse } from './utils' - -export function getLastExecutionByArtifactAppEnv( - artifactId: string | number, - appId: number | string, - envId: number | string, -): Promise { - return get( - getUrlWithSearchParams(ROUTES.SECURITY_SCAN_EXECUTION_DETAILS, { - artifactId, - appId, - envId, - }), - ).then((response) => parseLastExecutionResponse(response)) -} diff --git a/src/Shared/Components/Vulnerabilities/types.ts b/src/Shared/Components/Vulnerabilities/types.ts deleted file mode 100644 index 792f0dfb3..000000000 --- a/src/Shared/Components/Vulnerabilities/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { MaterialSecurityInfoType } from '../../types' - -export interface VulnerabilitiesProps extends MaterialSecurityInfoType { - artifactId: number - applicationId: number - environmentId: number - setVulnerabilityCount: React.Dispatch> -} diff --git a/src/Shared/Components/index.ts b/src/Shared/Components/index.ts index 3b9d5bc02..ba901ef62 100644 --- a/src/Shared/Components/index.ts +++ b/src/Shared/Components/index.ts @@ -21,7 +21,6 @@ export * from './ImageCard' export * from './ImageCardAccordion' export * from './ExcludedImageNode' export * from './ScannedByToolModal' -export * from './Vulnerabilities' export * from './FilterChips' export * from './ActivityIndicator' export * from './GenericSectionErrorState' @@ -55,5 +54,5 @@ export * from './DetectBottom' export * from './TabGroup' export * from './EditImageFormField' export * from './Collapse' -export * from './FilterButton' +export * from './Security' export * from './Button' diff --git a/src/Shared/Helpers.tsx b/src/Shared/Helpers.tsx index e5285f18e..5dff56f68 100644 --- a/src/Shared/Helpers.tsx +++ b/src/Shared/Helpers.tsx @@ -29,6 +29,7 @@ import { UserApprovalConfigType, PATTERNS, ZERO_TIME_STRING, + noop, } from '../Common' import { AggregationKeys, @@ -138,17 +139,12 @@ export const numberComparatorBySortOrder = ( sortOrder: SortingOrder = SortingOrder.ASC, ): number => (sortOrder === SortingOrder.ASC ? a - b : b - a) -export function versionComparatorBySortOrder( - a: Record, - b: Record, - compareKey: string, - orderBy: SortingOrder, -) { +export function versionComparatorBySortOrder(a: string, b: string, orderBy = SortingOrder.ASC) { if (orderBy === SortingOrder.DESC) { - return b[compareKey].localeCompare(a[compareKey], undefined, { numeric: true }) + return a?.localeCompare(b, undefined, { numeric: true }) ?? 1 } - return a[compareKey].localeCompare(b[compareKey], undefined, { numeric: true }) + return b?.localeCompare(a, undefined, { numeric: true }) ?? 1 } export const getWebhookEventIcon = (eventName: WebhookEventNameType) => { @@ -807,3 +803,21 @@ export const getIsManualApprovalSpecific = (userApprovalConfig?: Pick () => { window.open(url, '_blank', 'noreferrer') } + +export const getDefaultValueFromType = (value: unknown) => { + switch (typeof value) { + case 'number': + return 0 + case 'string': + return '' + case 'object': + if (value === null) { + return null + } + return Array.isArray(value) ? [] : {} + case 'function': + return noop + default: + return null + } +} diff --git a/src/Shared/Hooks/UseDownload/UseDownload.tsx b/src/Shared/Hooks/UseDownload/UseDownload.tsx index 497b038bd..b63ccf70c 100644 --- a/src/Shared/Hooks/UseDownload/UseDownload.tsx +++ b/src/Shared/Hooks/UseDownload/UseDownload.tsx @@ -15,12 +15,11 @@ */ import { showError } from '@Common/Helper' -import { ToastBody } from '@Common/ToastBody' import { useState } from 'react' -import { toast } from 'react-toastify' import { API_STATUS_CODES } from '@Common/Constants' import { ServerErrors } from '@Common/ServerError' import { getFileNameFromHeaders } from '@Shared/Helpers' +import { ToastManager, ToastVariantType } from '@Shared/Services' import { getDownloadResponse } from './service' import { HandleDownloadProps } from './types' @@ -46,12 +45,11 @@ const useDownload = () => { const response = await getDownloadResponse(downloadUrl) if (response.status === API_STATUS_CODES.OK) { if (showFilePreparingToast) { - toast.info( - , - ) + ToastManager.showToast({ + variant: ToastVariantType.info, + title: 'Preparing file for download', + description: 'File will be downloaded when it is available.', + }) } const data = await (response as any).blob() @@ -77,7 +75,10 @@ const useDownload = () => { }, 0) if (showSuccessfulToast) { - toast.success(downloadSuccessToastContent) + ToastManager.showToast({ + variant: ToastVariantType.success, + description: downloadSuccessToastContent, + }) } } else if (response.status === API_STATUS_CODES.NO_CONTENT) { throw new Error('No content to download') diff --git a/src/Shared/Hooks/index.ts b/src/Shared/Hooks/index.ts index 7fcb4539e..3461ecb16 100644 --- a/src/Shared/Hooks/index.ts +++ b/src/Shared/Hooks/index.ts @@ -17,3 +17,4 @@ export * from './UsePrompt' export * from './useGetResourceKindsOptions' export * from './UseDownload' +export * from './useForm' diff --git a/src/Shared/Hooks/useForm/index.ts b/src/Shared/Hooks/useForm/index.ts new file mode 100644 index 000000000..097083861 --- /dev/null +++ b/src/Shared/Hooks/useForm/index.ts @@ -0,0 +1,2 @@ +export * from './useForm' +export * from './useForm.types' diff --git a/src/Shared/Hooks/useForm/useForm.ts b/src/Shared/Hooks/useForm/useForm.ts new file mode 100644 index 000000000..e3362ede5 --- /dev/null +++ b/src/Shared/Hooks/useForm/useForm.ts @@ -0,0 +1,244 @@ +import { ChangeEvent, FormEvent, useState } from 'react' + +import { checkValidation } from './useForm.utils' +import { + DirtyFields, + UseFormErrors, + TouchedFields, + UseFormSubmitHandler, + UseFormValidation, + UseFormValidations, +} from './useForm.types' + +/** + * A custom hook to manage form state, validation, and submission handling. + * + * @param options - Optional configuration object for the form. + * @returns The form state and utility methods + */ +export const useForm = = {}>(options?: { + /** An object containing validation rules for each form field. */ + validations?: UseFormValidations + /** An object representing the initial values for the form fields. */ + initialValues?: Partial + /** Defines when validation should occur: + * - 'onChange': Validation occurs when the user modifies the input + * - 'onBlur': Validation occurs when the input loses focus. + * @default 'onChange' + */ + validationMode?: 'onChange' | 'onBlur' +}) => { + const [data, setData] = useState((options?.initialValues || {}) as T) + const [dirtyFields, setDirtyFields] = useState>({}) + const [touchedFields, setTouchedFields] = useState>({}) + const [errors, setErrors] = useState>({}) + const [enableValidationOnChange, setEnableValidationOnChange] = useState>>({}) + + /** + * Handles change events for form fields, updates the form data, and triggers validation. + * + * @param key - The key of the form field to be updated. + * @param sanitizeFn - An optional function to sanitize the input value. + * @returns The event handler for input changes. + */ + const onChange = + (key: keyof T, sanitizeFn?: (value: V) => S) => + // TODO: add support for `Checkbox`, `SelectPicker` and `RadioGroup` components + (e: ChangeEvent) => { + const value = sanitizeFn ? sanitizeFn(e.target.value as V) : e.target.value + setData({ + ...data, + [key]: value, + }) + const initialValues: Partial = options?.initialValues ?? {} + setDirtyFields({ ...dirtyFields, [key]: initialValues[key] !== value }) + + const validationMode = options?.validationMode ?? 'onChange' + if (validationMode === 'onChange' || enableValidationOnChange[key] || errors[key]) { + const validations = options?.validations ?? {} + const error = checkValidation(value as T[keyof T], validations[key as string]) + setErrors({ ...errors, [key]: error }) + } + } + + /** + * Handles blur events for form fields and triggers validation if the form mode is 'onBlur'. + * + * @param key - The key of the form field. + * @returns The event handler for the blur event. + */ + const onBlur = (key: keyof T, noTrim: boolean) => () => { + if (!noTrim) { + setData({ ...data, [key]: data[key].trim() }) + } + + if (options?.validationMode === 'onBlur') { + const validations = options?.validations ?? {} + const error = checkValidation(data[key] as T[keyof T], validations[key as string]) + if (error && !enableValidationOnChange[key]) { + setEnableValidationOnChange({ ...enableValidationOnChange, [key]: true }) + } + setErrors({ ...errors, [key]: error }) + } + } + + /** + * Handles the focus event for form fields and updates the `touchedFields` state to mark the field as touched. + * + * @param key - The key of the form field. + * @return The event handler for the focus event. + */ + const onFocus = (key: keyof T) => () => { + setTouchedFields({ + ...touchedFields, + [key]: true, + }) + } + + /** + * Handles form submission, validates all form fields, and calls the provided `onValid` function if valid. + * + * @param onValid - A function to handle valid form data on submission. + * @returns The event handler for form submission. + */ + const handleSubmit = (onValid: UseFormSubmitHandler) => (e: FormEvent) => { + e.preventDefault() + + // Enables validation for all form fields if not enabled after form submission. + if (Object.keys(enableValidationOnChange).length !== Object.keys(data).length) { + setEnableValidationOnChange(Object.keys(data).reduce((acc, key) => ({ ...acc, [key]: true }), {})) + } + + const validations = options?.validations + if (validations) { + const newErrors: UseFormErrors = {} + + Object.keys(validations).forEach((key) => { + const validation: UseFormValidation = validations[key] + const error = checkValidation(data[key], validation) + if (error) { + newErrors[key] = error + } + }) + + if (Object.keys(newErrors).length) { + setErrors(newErrors) + return + } + } + + setErrors({}) + onValid(data, e) + } + + /** + * Manually triggers validation for specific form fields. + * + * @param name - The key(s) of the form field(s) to validate. + * @returns The validation error(s), if any. + */ + const trigger = (name: keyof T | (keyof T)[]): (string | string[]) | (string | string[])[] => { + const validations = options?.validations + + if (Array.isArray(name)) { + const newErrors: UseFormErrors = {} + + const _errors = name.map((key) => { + if (validations) { + const validation = validations[key] + const error = checkValidation(data[key], validation) + newErrors[key] = error + + return error + } + + return null + }) + + if (Object.keys(newErrors).length) { + setErrors({ ...errors, ...newErrors }) + } + + return _errors + } + + if (validations) { + const validation = validations[name] + const error = checkValidation(data[name], validation) + + if (error) { + setErrors({ ...errors, [name]: error }) + } + + return error + } + + return null + } + + /** + * Registers form input fields with onChange, onBlur and onFocus handlers. + * + * @param name - The key of the form field to register. + * @param sanitizeFn - An optional function to sanitize the input value. + * @returns An object containing form field `name`, `onChange`, `onBlur` and `onFocus` event handlers. + */ + const register = ( + name: keyof T, + sanitizeFn?: (value: V) => S, + registerOptions?: { + /** + * Prevents the input value from being trimmed. + * + * If `noTrim` is set to true, the input value will not be automatically trimmed.\ + * This can be useful when whitespace is required for certain inputs. + * + * @default false - By default, the input will be trimmed. + */ + noTrim?: boolean + }, + ) => ({ + onChange: onChange(name, sanitizeFn), + onBlur: onBlur(name, registerOptions?.noTrim), + onFocus: onFocus(name), + name, + }) + + return { + /** The current form data. */ + data, + /** An object containing validation errors for each form field. */ + errors, + /** + * Registers form input fields with onChange, onBlur and onFocus handlers. + * + * @param name - The key of the form field to register. + * @param sanitizeFn - An optional function to sanitize the input value. + * @returns An object containing form field `name`, `onChange`, `onBlur` and `onFocus` event handlers. + */ + register, + /** + * Handles form submission, validates all form fields, and calls the provided `onValid` function if valid. + * + * @param onValid - A function to handle valid form data on submission. + * @returns The event handler for form submission. + */ + handleSubmit, + /** + * Manually triggers validation for specific form fields. + * + * @param name - The key(s) of the form field(s) to validate. + * @returns The validation error(s), if any. + */ + trigger, + /** An object representing additional form state. */ + formState: { + /** An object indicating which fields have been touched (interacted with). */ + touchedFields, + /** An object indicating which fields have been modified. */ + dirtyFields, + /** A boolean indicating if any field has been modified. */ + isDirty: Object.values(dirtyFields).some((value) => value), + }, + } +} diff --git a/src/Shared/Hooks/useForm/useForm.types.ts b/src/Shared/Hooks/useForm/useForm.types.ts new file mode 100644 index 000000000..c15f3097c --- /dev/null +++ b/src/Shared/Hooks/useForm/useForm.types.ts @@ -0,0 +1,83 @@ +import { FormEvent } from 'react' + +/** + * Describes the "required" validation rule. + * It can be a simple boolean or an object containing a boolean value and an error message. + */ +type ValidationRequired = + | boolean + | { + value: boolean + message: string + } + +/** + * Describes the "pattern" validation rule, which ensures a value matches a specific regular expression. + * It can be a single validation object or an array of multiple patterns. + */ +type ValidationPattern = + | { + value: RegExp + message: string + } + | { + value: RegExp + message: string + }[] + +/** + * Describes custom validation logic. + * It checks if a value passes a custom validation function, which returns a boolean. + * If validation fails, an error message is provided. + */ +type ValidationCustom = + | { + isValid: (value: string) => boolean + message: string + } + | { + isValid: (value: string) => boolean + message: string + }[] + +/** + * Defines the validation rules for form fields. + * Includes `required`, `pattern`, and `custom` validation types. + */ +export interface UseFormValidation { + required?: ValidationRequired + pattern?: ValidationPattern + custom?: ValidationCustom +} + +/** + * Represents the structure for form validation errors. + * Maps each field to an error message or an array of error messages. + */ +export type UseFormErrors = Partial> + +/** + * Represents the fields that have been modified ("dirty") in the form. + * Maps each field to a boolean value indicating whether it has been changed. + */ +export type DirtyFields = Partial> + +/** + * Represents the fields that have been interacted with ("touched") in the form. + * Maps each field to a boolean value indicating whether it has been focused or interacted with. + */ +export type TouchedFields = Partial> + +/** + * Defines the structure for form validations. + * Maps each form field to its corresponding validation rules. + */ +export type UseFormValidations = Partial> + +/** + * Describes the function signature for handling form submission. + * + * @param data - The form data collected during submission. + * @param e - The form event, optionally passed when the form is submitted. + */ +export type UseFormSubmitHandler = (data: T, e?: FormEvent) => void diff --git a/src/Shared/Hooks/useForm/useForm.utils.ts b/src/Shared/Hooks/useForm/useForm.utils.ts new file mode 100644 index 000000000..4c9ee0acf --- /dev/null +++ b/src/Shared/Hooks/useForm/useForm.utils.ts @@ -0,0 +1,57 @@ +import { UseFormValidation } from './useForm.types' + +/** + * Validates a form field based on the provided validation rules. + * + * @template T - A record type representing form data. + * @param value - The value of the form field to be validated. + * @param validation - The validation rules for the form field. + * @returns Returns error message(s) or null if valid. + */ +export const checkValidation = = {}>( + value: T[keyof T], + validation: UseFormValidation, +): string | string[] | null => { + if ( + (typeof validation?.required === 'object' ? validation.required.value : validation.required) && + (value === null || value === undefined || value === '') + ) { + return typeof validation?.required === 'object' ? validation.required.message : 'This is a required field' + } + + const errors = [] + + const pattern = validation?.pattern + if (Array.isArray(pattern)) { + const error = pattern.reduce((acc, p) => { + if (!p.value.test(value)) { + acc.push(p.message) + } + return acc + }, []) + + if (error.length) { + errors.push(...error) + } + } else if (pattern?.value && !pattern.value.test(value)) { + errors.push(pattern.message) + } + + const custom = validation?.custom + if (Array.isArray(custom)) { + const error = custom.reduce((acc, c) => { + if (!c.isValid(value)) { + acc.push(c.message) + } + return acc + }, []) + + if (error.length) { + errors.push(...error) + } + } else if (custom?.isValid && !custom.isValid(value)) { + errors.push(custom.message) + } + + return errors.length ? errors : null +} diff --git a/src/Shared/Services/ToastManager/ToastContent.tsx b/src/Shared/Services/ToastManager/ToastContent.tsx new file mode 100644 index 000000000..3de7d6ab5 --- /dev/null +++ b/src/Shared/Services/ToastManager/ToastContent.tsx @@ -0,0 +1,24 @@ +import { Button, ButtonStyleType, ButtonVariantType } from '@Shared/Components' +import { ComponentSizeType } from '@Shared/constants' +import { ToastProps } from './types' + +export const ToastContent = ({ + title, + description, + buttonProps, +}: Pick) => ( +
        +
        +

        {title}

        +

        {description}

        +
        + {buttonProps && ( +
        +) diff --git a/src/Shared/Services/ToastManager/constants.tsx b/src/Shared/Services/ToastManager/constants.tsx new file mode 100644 index 000000000..8b79420ec --- /dev/null +++ b/src/Shared/Services/ToastManager/constants.tsx @@ -0,0 +1,72 @@ +// eslint-disable-next-line no-restricted-imports +import { ToastOptions, ToastContainerProps } from 'react-toastify' +import { ReactComponent as ICInfoFilled } from '@Icons/ic-info-filled.svg' +import { ReactComponent as ICSuccess } from '@Icons/ic-success.svg' +import { ReactComponent as ICError } from '@Icons/ic-error.svg' +import { ReactComponent as ICWarning } from '@Icons/ic-warning.svg' +import { ReactComponent as ICLocked } from '@Icons/ic-locked.svg' +import { ReactComponent as ICCross } from '@Icons/ic-cross.svg' +import { Button, ButtonStyleType, ButtonVariantType } from '@Shared/Components' +import { ComponentSizeType } from '@Shared/constants' +import { ToastProps, ToastVariantType } from './types' + +export const TOAST_BASE_CONFIG: ToastContainerProps = { + autoClose: 5000, + hideProgressBar: false, + pauseOnHover: true, + pauseOnFocusLoss: true, + closeOnClick: false, + newestOnTop: true, + toastClassName: 'custom-toast', + bodyClassName: 'custom-toast__body', + closeButton: ({ closeToast }) => ( +
        +
        + ), +} + +export const TOAST_VARIANT_TO_CONFIG_MAP: Record< + ToastVariantType, + Required> & Pick +> = { + [ToastVariantType.info]: { + icon: , + type: 'info', + title: 'Information', + progressBarBg: 'var(--B500)', + }, + [ToastVariantType.success]: { + icon: , + type: 'success', + title: 'Success', + progressBarBg: 'var(--G500)', + }, + [ToastVariantType.error]: { + icon: , + type: 'error', + title: 'Error', + progressBarBg: 'var(--R500)', + }, + [ToastVariantType.warn]: { + icon: , + type: 'warning', + title: 'Warning', + progressBarBg: 'var(--Y500)', + }, + [ToastVariantType.notAuthorized]: { + icon: , + type: 'warning', + title: 'Not authorized', + progressBarBg: 'var(--Y500)', + }, +} diff --git a/src/Shared/Services/ToastManager/index.ts b/src/Shared/Services/ToastManager/index.ts new file mode 100644 index 000000000..4d7ab7b88 --- /dev/null +++ b/src/Shared/Services/ToastManager/index.ts @@ -0,0 +1,3 @@ +export { default as ToastManager, ToastManagerContainer } from './toastManager.service' +export { ToastVariantType } from './types' +export type { ToastProps } from './types' diff --git a/src/Shared/Services/ToastManager/toastManager.scss b/src/Shared/Services/ToastManager/toastManager.scss new file mode 100644 index 000000000..141a9a118 --- /dev/null +++ b/src/Shared/Services/ToastManager/toastManager.scss @@ -0,0 +1,83 @@ +@import 'react-toastify/dist/ReactToastify.css'; + +.Toastify { + &__toast { + gap: 8px; + } + + &__toast-container { + padding: 0; + width: 280px; + + &--top-right { + top: 8em; + } + } + + &__progress-bar { + height: 3px; + } +} + +.custom-toast { + max-height: none; + padding: 12px; + box-shadow: none; + border-radius: 4px; + margin-bottom: 16px; + min-height: 0; + color: var(--N0); + background: #1B1C1E; + font-family: inherit; + + &__body { + margin: 0; + padding: 0; + gap: 8px; + align-items: flex-start; + align-self: center; + + >.Toastify__toast-icon { + margin: 0; + width: auto; + align-self: flex-start; + } + } + + &__content { + + // Override the style for the action button + button { + color: var(--N0); + + svg *[stroke^="#"] { + stroke: var(--N0); + } + + svg *[fill^="#"] { + fill: var(--N0); + } + + &:hover, + &:active { + color: var(--N0); + } + } + } + + // Custom styling for close btn + &__close-btn { + button { + svg { + path { + fill: var(--N0) !important; + } + } + + &:hover, + &:active { + background: #fff3 !important; + } + } + } +} diff --git a/src/Shared/Services/ToastManager/toastManager.service.tsx b/src/Shared/Services/ToastManager/toastManager.service.tsx new file mode 100644 index 000000000..ff36667c2 --- /dev/null +++ b/src/Shared/Services/ToastManager/toastManager.service.tsx @@ -0,0 +1,130 @@ +// eslint-disable-next-line no-restricted-imports +import { toast, ToastContainer, ToastOptions } from 'react-toastify' +import { ToastProps, ToastVariantType } from './types' +import { TOAST_BASE_CONFIG, TOAST_VARIANT_TO_CONFIG_MAP } from './constants' +import { ToastContent } from './ToastContent' +import './toastManager.scss' + +/** + * Service for handling toast across the application + * + * Note: The application needs to have `ToastManagerContainer` at the root + * level for the toast to work + * + * @example Default Usage + * ```ts + * ToastManager.showToast({ + * description: 'Lorem ipsum' + * }) + * ``` + * + * @example Custom Title + * ```ts + * ToastManager.showToast({ + * description: 'Lorem ipsum', + * title: 'Toast title' + * }) + * ``` + * + * @example With Button + * ```ts + * ToastManager.showToast({ + * description: 'Lorem ipsum', + * buttonProps: { + * dataTestId: 'toast-btn', + * text: 'Reload', + * startIcon: + * } + * }) + * ``` + * + * @example Auto close disabled + * ```ts + * ToastManager.showToast({ + * description: 'Lorem ipsum', + * toastOptions: { + * autoClose: false, + * }, + * }) + * ``` + * + * @example Custom progress bar color + * ```ts + * ToastManager.showToast({ + * description: 'Lorem ipsum', + * progressBarBg: 'var(--N700)', + * }) + * ``` + * + * @example Custom icon + * ```ts + * ToastManager.showToast({ + * description: 'Lorem ipsum', + * icon: , + * }) + * ``` + */ +class ToastManager { + // eslint-disable-next-line no-use-before-define + static #instance: ToastManager + + public static get instance(): ToastManager { + if (!ToastManager.#instance) { + ToastManager.#instance = new ToastManager() + } + + return ToastManager.#instance + } + + /** + * Handler for showing the toast + */ + // eslint-disable-next-line class-methods-use-this + showToast = ( + { + variant = ToastVariantType.info, + icon: customIcon, + title, + description, + buttonProps, + progressBarBg: customProgressBarBg, + }: ToastProps, + options: Pick = {}, + ) => { + const { icon, type, title: defaultTitle, progressBarBg } = TOAST_VARIANT_TO_CONFIG_MAP[variant] + + return toast( + , + { + ...options, + icon: () => ( +
        {customIcon ?? icon}
        + ), + type, + progressStyle: { + background: customProgressBarBg || progressBarBg, + }, + // Show the progress bar if the auto close is disabled + ...(options.autoClose === false + ? { + progress: 1, + } + : {}), + }, + ) + } + + /** + * Handler for dismissing an existing toast + */ + dismissToast = toast.dismiss + + /** + * Handler for checking if the toast is active + */ + isToastActive = toast.isActive +} + +export const ToastManagerContainer = () => + +export default ToastManager.instance diff --git a/src/Shared/Services/ToastManager/types.ts b/src/Shared/Services/ToastManager/types.ts new file mode 100644 index 000000000..581a2184f --- /dev/null +++ b/src/Shared/Services/ToastManager/types.ts @@ -0,0 +1,42 @@ +import { ButtonProps } from '@Shared/Components' +import { ReactElement } from 'react' + +export enum ToastVariantType { + info = 'info', + success = 'success', + error = 'error', + warn = 'warn', + notAuthorized = 'notAuthorized', +} + +export interface ToastProps { + /** + * Title for the toast + * If not provided, defaults to a value based on the selected variant + */ + title?: string + /** + * Description for the toast + */ + description: string + /** + * Custom icon for the toast to override the icon based on variant + */ + icon?: ReactElement + /** + * Variant for the toast + * + * @default ToastVariantType.info + */ + variant?: ToastVariantType + /** + * Props for the action button to be displayed in the toast + * + * Note: Size, variant and style are hard-coded and cannot be overriden + */ + buttonProps?: ButtonProps + /** + * Custom progress bar color + */ + progressBarBg?: string +} diff --git a/src/Shared/Services/app.types.ts b/src/Shared/Services/app.types.ts index c928ec7aa..88c4bd402 100644 --- a/src/Shared/Services/app.types.ts +++ b/src/Shared/Services/app.types.ts @@ -253,6 +253,12 @@ export interface TemplateListDTO { pipelineId?: number } +export interface ManifestTemplateDTO { + data: string + resolvedData: string + variableSnapshot: null +} + export enum DraftState { Init = 1, Discarded = 2, @@ -264,4 +270,5 @@ export enum EnvResourceType { ConfigMap = 'configmap', Secret = 'secrets', DeploymentTemplate = 'deployment-template', + Manifest = 'manifest', } diff --git a/src/Shared/Services/index.ts b/src/Shared/Services/index.ts index f4a6547c1..0fa10ddf8 100644 --- a/src/Shared/Services/index.ts +++ b/src/Shared/Services/index.ts @@ -18,3 +18,4 @@ export * from './types' export * from './app.types' export * from './app.service' export * from './common.service' +export * from './ToastManager' diff --git a/src/Shared/constants.tsx b/src/Shared/constants.tsx index b42513b86..04b606701 100644 --- a/src/Shared/constants.tsx +++ b/src/Shared/constants.tsx @@ -34,6 +34,11 @@ export const STAGE_TYPE = { export const SCAN_TOOL_ID_TRIVY = 3 +/** + * @description This is only used to show mapping for clair, scan tool id for clair can be either 1 or 2 + * */ +export const SCAN_TOOL_ID_CLAIR = 2 + export const IMAGE_SCAN_TOOL = { Clair: 'Clair', Trivy: 'Trivy', diff --git a/src/Shared/types.ts b/src/Shared/types.ts index 2c719121e..513ec54da 100644 --- a/src/Shared/types.ts +++ b/src/Shared/types.ts @@ -479,6 +479,7 @@ export interface LastExecutionResultType { scanned?: boolean scanEnabled?: boolean scanToolId?: number + imageScanDeployInfoId?: number } export interface LastExecutionResponseType extends ResponseType {}