From 424ffbf9fe5535130a1193d59c217a467ce6688c Mon Sep 17 00:00:00 2001 From: rhartuv Date: Tue, 30 Dec 2025 14:38:39 +0200 Subject: [PATCH 01/18] chore: add mocks and pagination to repository table --- .../src/components/RepositoryReportsTable.tsx | 37 ++++++-- .../src/components/RepositoryTableToolbar.tsx | 19 +++- src/main/webui/src/mocks/handlers.ts | 60 ++++++++++++ src/main/webui/src/mocks/mockFullReports.ts | 91 +++++++++++++++++++ 4 files changed, 196 insertions(+), 11 deletions(-) diff --git a/src/main/webui/src/components/RepositoryReportsTable.tsx b/src/main/webui/src/components/RepositoryReportsTable.tsx index cf977dce..7b9118ad 100644 --- a/src/main/webui/src/components/RepositoryReportsTable.tsx +++ b/src/main/webui/src/components/RepositoryReportsTable.tsx @@ -1,4 +1,5 @@ import { useState, useMemo, type CSSProperties } from "react"; +import type React from "react"; import { useNavigate } from "react-router"; import { Button, @@ -16,7 +17,7 @@ import { Tr, Th, Tbody, - Td, + Td, } from "@patternfly/react-table"; import { CheckCircleIcon } from "@patternfly/react-icons"; import SkeletonTable from "@patternfly/react-component-groups/dist/dynamic/SkeletonTable"; @@ -49,6 +50,7 @@ const RepositoryReportsTable: React.FC = ({ }) => { const navigate = useNavigate(); const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(PER_PAGE); const [scanStateFilter, setScanStateFilter] = useState([]); const scanStateOptions = useMemo(() => { @@ -62,13 +64,14 @@ const RepositoryReportsTable: React.FC = ({ url: '/api/reports', query: { page: page - 1, - pageSize: PER_PAGE, + pageSize: perPage, productId: productId, vulnId: cveId, - ...(scanStateFilter.length > 0 && scanStateFilter[0] && { status: scanStateFilter[0] }), + ...(scanStateFilter.length > 0 && + scanStateFilter[0] && { status: scanStateFilter[0] }), }, }), - { deps: [page, productId, cveId, scanStateFilter] } + { deps: [page, perPage, productId, cveId, scanStateFilter] } ); const handleFilterChange = (filters: string[]) => { @@ -76,6 +79,22 @@ const RepositoryReportsTable: React.FC = ({ setPage(1); }; + const onSetPage = ( + _event: React.MouseEvent | React.KeyboardEvent | MouseEvent, + newPage: number + ) => { + setPage(newPage); + }; + + const onPerPageSelect = ( + _event: React.MouseEvent | React.KeyboardEvent | MouseEvent, + newPerPage: number, + newPage: number + ) => { + setPerPage(newPerPage); + setPage(newPage); + }; + const getVulnerabilityStatus = (report: Report) => { if (!report.vulns || !cveId) return null; const vuln = report.vulns.find((v) => v.vulnId === cveId); @@ -134,8 +153,9 @@ const RepositoryReportsTable: React.FC = ({ ? { itemCount: pagination.totalElements ?? 0, page, - perPage: PER_PAGE, - onSetPage: (_, newPage) => setPage(newPage), + perPage: perPage, + onSetPage: onSetPage, + onPerPageSelect: onPerPageSelect, } : undefined } @@ -164,8 +184,9 @@ const RepositoryReportsTable: React.FC = ({ pagination={{ itemCount: pagination?.totalElements ?? 0, page, - perPage: PER_PAGE, - onSetPage: (_, newPage) => setPage(newPage), + perPage: perPage, + onSetPage: onSetPage, + onPerPageSelect: onPerPageSelect, }} /> diff --git a/src/main/webui/src/components/RepositoryTableToolbar.tsx b/src/main/webui/src/components/RepositoryTableToolbar.tsx index 6ce8e7c3..1eabb453 100644 --- a/src/main/webui/src/components/RepositoryTableToolbar.tsx +++ b/src/main/webui/src/components/RepositoryTableToolbar.tsx @@ -26,7 +26,15 @@ interface RepositoryTableToolbarProps { itemCount: number; page: number; perPage: number; - onSetPage: (event: unknown, newPage: number) => void; + onSetPage: ( + event: React.MouseEvent | React.KeyboardEvent | MouseEvent, + newPage: number + ) => void; + onPerPageSelect?: ( + event: React.MouseEvent | React.KeyboardEvent | MouseEvent, + newPerPage: number, + newPage: number + ) => void; }; } @@ -183,8 +191,13 @@ const RepositoryTableToolbar: React.FC = ({ page={pagination.page} perPage={pagination.perPage} onSetPage={pagination.onSetPage} - onPerPageSelect={() => {}} - perPageOptions={[]} + onPerPageSelect={pagination.onPerPageSelect || (() => {})} + perPageOptions={[ + { title: "10", value: 10 }, + { title: "20", value: 20 }, + { title: "50", value: 50 }, + { title: "100", value: 100 }, + ]} /> diff --git a/src/main/webui/src/mocks/handlers.ts b/src/main/webui/src/mocks/handlers.ts index 9415e1ab..0eb418ea 100644 --- a/src/main/webui/src/mocks/handlers.ts +++ b/src/main/webui/src/mocks/handlers.ts @@ -368,6 +368,66 @@ const mockReports: Report[] = [ gitRepo: "https://github.com/example/uncertain-repo", ref: "main", }, + + // Report 10: Sample Product A / CVE-2024-1001 - First report for pagination testing + { + id: "report-product1-cve1001-1", + name: "Report report-product1-cve1001-1", + startedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), + completedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + imageName: "sample-product-a-repo-1", + imageTag: "v1.0.0", + state: "completed", + vulns: [ + { + vulnId: "CVE-2024-1001", + justification: { + status: "TRUE", + label: "vulnerable", + }, + }, + ], + metadata: { + productId: "product-1", + environment: "production", + }, + gitRepo: "https://github.com/example/sample-product-a-repo-1", + ref: "main", + }, + + // Generate 30+ additional reports for Sample Product A / CVE-2024-1001 for pagination testing + ...Array.from({ length: 30 }, (_, i) => { + const reportNum = i + 2; + const daysAgo = 3 + Math.floor(i / 10); + const isVulnerable = i % 3 === 0; // Mix of vulnerable, not vulnerable, and uncertain + const status = isVulnerable ? "TRUE" : i % 3 === 1 ? "FALSE" : "UNKNOWN"; + const label = isVulnerable ? "vulnerable" : i % 3 === 1 ? "not_vulnerable" : "uncertain"; + + return { + id: `report-product1-cve1001-${reportNum}`, + name: `Report report-product1-cve1001-${reportNum}`, + startedAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString(), + completedAt: new Date(Date.now() - (daysAgo - 1) * 24 * 60 * 60 * 1000).toISOString(), + imageName: `sample-product-a-repo-${reportNum}`, + imageTag: `v1.${reportNum}.0`, + state: "completed" as const, + vulns: [ + { + vulnId: "CVE-2024-1001", + justification: { + status, + label, + }, + }, + ], + metadata: { + productId: "product-1", + environment: "production", + }, + gitRepo: `https://github.com/example/sample-product-a-repo-${reportNum}`, + ref: `branch-${reportNum}`, + }; + }), ]; // Generate reports summary based on actual mock data diff --git a/src/main/webui/src/mocks/mockFullReports.ts b/src/main/webui/src/mocks/mockFullReports.ts index f0f3ef5b..f3bcda32 100644 --- a/src/main/webui/src/mocks/mockFullReports.ts +++ b/src/main/webui/src/mocks/mockFullReports.ts @@ -168,5 +168,96 @@ export const mockFullReports: Record = { environment: "staging", }, }, + "report-product1-cve1001-1": { + _id: "report-product1-cve1001-1", + input: { + scan: { + id: "scan-product1-cve1001-1", + type: "image", + started_at: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), + completed_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + vulns: [ + { + vuln_id: "CVE-2024-1001", + description: "Sample Product A vulnerability - Critical security issue in dependency", + score: 8.5, + severity: "HIGH", + published_date: "2024-01-15", + last_modified_date: "2024-02-01", + url: "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-1001", + feed_group: "nvd", + package: "vulnerable-package", + package_version: "1.2.3", + package_name: "vulnerable-package", + package_type: "npm", + }, + ], + }, + image: { + analysis_type: "image", + ecosystem: "nodejs", + name: "sample-product-a-repo-1", + tag: "v1.0.0", + source_info: [ + { + type: "git", + git_repo: "https://github.com/example/sample-product-a-repo-1", + ref: "main", + include: ["**/*.js", "package.json"], + exclude: ["node_modules/**"], + }, + ], + sbom_info: { + packages: [ + { + name: "vulnerable-package", + version: "1.2.3", + }, + ], + }, + }, + }, + output: [ + { + vuln_id: "CVE-2024-1001", + checklist: [ + { + input: "Verify if the vulnerable package is being used", + response: "The package vulnerable-package version 1.2.3 is confirmed to be in use in this repository.", + intermediate_steps: null, + }, + { + input: "Assess the impact of the vulnerability", + response: "The vulnerability has a high severity score (8.5) and could lead to remote code execution in the production environment.", + intermediate_steps: null, + }, + ], + summary: "The CVE is exploitable. Sample Product A repository 1 uses the vulnerable package version and the vulnerability has been confirmed through analysis.", + justification: { + status: "TRUE", + label: "vulnerable", + reason: "The analysis confirms that the vulnerable package is in use and the vulnerability is exploitable in this context. Immediate action is required.", + }, + intel_score: 90, + cvss: { + score: "8.5", + vector_string: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N", + }, + }, + ], + info: { + vdb: { + version: "2024.1", + }, + intel: { + score: 90, + }, + }, + metadata: { + productId: "product-1", + environment: "production", + team: "security", + }, + }, }; From b0fc5ae37c64b25cfca084720d2538dbcad34685 Mon Sep 17 00:00:00 2001 From: rhartuv Date: Tue, 30 Dec 2025 15:57:15 +0200 Subject: [PATCH 02/18] feat: add filtering component and filter by EIQ stat to repository table --- src/main/webui/src/components/Filtering.tsx | 253 ++++++++++++ .../webui/src/components/ReportsToolbar.tsx | 364 ++---------------- .../src/components/RepositoryReportsTable.tsx | 209 +++++++--- .../src/components/RepositoryTableToolbar.tsx | 188 ++++----- 4 files changed, 505 insertions(+), 509 deletions(-) create mode 100644 src/main/webui/src/components/Filtering.tsx diff --git a/src/main/webui/src/components/Filtering.tsx b/src/main/webui/src/components/Filtering.tsx new file mode 100644 index 00000000..b315da4a --- /dev/null +++ b/src/main/webui/src/components/Filtering.tsx @@ -0,0 +1,253 @@ +import { useState, useEffect, useRef } from "react"; +import { + Menu, + MenuContent, + MenuList, + MenuItem, + MenuToggle, + Badge, + Popper, +} from "@patternfly/react-core"; +import { FilterIcon } from "@patternfly/react-icons"; + +export const ALL_EXPLOIT_IQ_STATUS_OPTIONS = [ + "Vulnerable", + "Not Vulnerable", + "Uncertain", +]; + +/** + * Hook for managing menu open/close state with keyboard and click outside handlers + */ +export function useMenuHandlers( + isMenuOpen: boolean, + setIsMenuOpen: (open: boolean) => void, + menuRef: React.RefObject, + toggleRef: React.RefObject, + containerRef?: React.RefObject +) { + useEffect(() => { + const handleMenuKeys = (event: KeyboardEvent) => { + if (!isMenuOpen) return; + if ( + menuRef.current?.contains(event.target as Node) || + toggleRef.current?.contains(event.target as Node) + ) { + if (event.key === "Escape" || event.key === "Tab") { + setIsMenuOpen(false); + toggleRef.current?.focus(); + } + } + }; + + const handleClickOutside = (event: MouseEvent) => { + if (isMenuOpen) { + const isOutsideMenu = !menuRef.current?.contains(event.target as Node); + const isOutsideToggle = !toggleRef.current?.contains( + event.target as Node + ); + const isOutsideContainer = !containerRef?.current?.contains( + event.target as Node + ); + + if (isOutsideMenu && isOutsideToggle && isOutsideContainer) { + setIsMenuOpen(false); + } + } + }; + + window.addEventListener("keydown", handleMenuKeys); + window.addEventListener("click", handleClickOutside); + return () => { + window.removeEventListener("keydown", handleMenuKeys); + window.removeEventListener("click", handleClickOutside); + }; + }, [isMenuOpen, setIsMenuOpen, menuRef, toggleRef, containerRef]); +} + +/** + * Attribute selector dropdown component + */ +export interface AttributeSelectorProps { + activeAttribute: T; + attributes: T[]; + onAttributeChange: (attribute: T) => void; +} + +export function AttributeSelector({ + activeAttribute, + attributes, + onAttributeChange, +}: AttributeSelectorProps) { + const [isAttributeMenuOpen, setIsAttributeMenuOpen] = useState(false); + const attributeToggleRef = useRef(null); + const attributeMenuRef = useRef(null); + const attributeContainerRef = useRef(null); + + useMenuHandlers( + isAttributeMenuOpen, + setIsAttributeMenuOpen, + attributeMenuRef, + attributeToggleRef, + attributeContainerRef + ); + + const onAttributeToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (attributeMenuRef.current) { + const firstElement = attributeMenuRef.current.querySelector( + "li > button:not(:disabled)" + ); + firstElement && (firstElement as HTMLElement).focus(); + } + }, 0); + setIsAttributeMenuOpen((prev) => !prev); + }; + + const attributeToggle = ( + } + > + {activeAttribute} + + ); + + const attributeMenu = ( + { + onAttributeChange(itemId?.toString() as T); + setIsAttributeMenuOpen(false); + }} + > + + + {attributes.map((attr) => ( + + {attr} + + ))} + + + + ); + + return ( +
+ +
+ ); +} + +/** + * Generic checkbox filter component for dynamic options + */ +export interface CheckboxFilterProps { + id: string; + label: string; + options: string[]; + selected: string[]; + onSelect: (selected: string[]) => void; + loading?: boolean; +} + +export function CheckboxFilter({ + id, + label, + options, + selected, + onSelect, + loading = false, +}: CheckboxFilterProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const toggleRef = useRef(null); + const menuRef = useRef(null); + const containerRef = useRef(null); + + useMenuHandlers(isMenuOpen, setIsMenuOpen, menuRef, toggleRef, containerRef); + + const onToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (menuRef.current) { + const firstElement = menuRef.current.querySelector( + "li > button:not(:disabled)" + ); + firstElement && (firstElement as HTMLElement).focus(); + } + }, 0); + setIsMenuOpen((prev) => !prev); + }; + + const handleSelect = ( + _event: React.MouseEvent | undefined, + itemId: string | number | undefined + ) => { + if (typeof itemId === "undefined") return; + const itemStr = itemId.toString(); + const isSelected = selected.includes(itemStr); + onSelect( + isSelected + ? selected.filter((v) => v !== itemStr) + : [...selected, itemStr] + ); + }; + + const toggle = ( + 0 && { + badge: {selected.length}, + })} + style={{ width: "200px" } as React.CSSProperties} + isDisabled={loading} + > + {label} + + ); + + const menu = ( + + + + {options.map((option) => ( + + {option} + + ))} + + + + ); + + return ( +
+ +
+ ); +} diff --git a/src/main/webui/src/components/ReportsToolbar.tsx b/src/main/webui/src/components/ReportsToolbar.tsx index 56203bf0..3c7604a8 100644 --- a/src/main/webui/src/components/ReportsToolbar.tsx +++ b/src/main/webui/src/components/ReportsToolbar.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState } from "react"; import { Toolbar, ToolbarContent, @@ -7,16 +7,14 @@ import { ToolbarFilter, ToolbarToggleGroup, SearchInput, - Menu, - MenuContent, - MenuList, - MenuItem, - MenuToggle, - Badge, - Popper, Pagination, } from "@patternfly/react-core"; import { FilterIcon } from "@patternfly/react-icons"; +import { + AttributeSelector, + CheckboxFilter, + ALL_EXPLOIT_IQ_STATUS_OPTIONS, +} from "./Filtering"; export interface ReportsToolbarFilters { exploitIqStatus: string[]; @@ -39,12 +37,6 @@ interface ReportsToolbarProps { }; } -const ALL_EXPLOIT_IQ_STATUS_OPTIONS = [ - "Vulnerable", - "Not Vulnerable", - "Uncertain", -]; - type ActiveAttribute = | "SBOM Name" | "CVE ID" @@ -63,178 +55,18 @@ const ReportsToolbar: React.FC = ({ }) => { const [activeAttribute, setActiveAttribute] = useState("SBOM Name"); - const [isAttributeMenuOpen, setIsAttributeMenuOpen] = useState(false); - const [isExploitIqStatusMenuOpen, setIsExploitIqStatusMenuOpen] = - useState(false); - const [isAnalysisStateMenuOpen, setIsAnalysisStateMenuOpen] = useState(false); - - const attributeToggleRef = useRef(null); - const attributeMenuRef = useRef(null); - const attributeContainerRef = useRef(null); - const exploitIqStatusToggleRef = useRef(null); - const exploitIqStatusMenuRef = useRef(null); - const exploitIqStatusContainerRef = useRef(null); - const analysisStateToggleRef = useRef(null); - const analysisStateMenuRef = useRef(null); - const analysisStateContainerRef = useRef(null); - - const handleAttributeMenuKeys = (event: KeyboardEvent) => { - if (!isAttributeMenuOpen) return; - if ( - attributeMenuRef.current?.contains(event.target as Node) || - attributeToggleRef.current?.contains(event.target as Node) - ) { - if (event.key === "Escape" || event.key === "Tab") { - setIsAttributeMenuOpen(false); - attributeToggleRef.current?.focus(); - } - } - }; - - const handleAttributeClickOutside = (event: MouseEvent) => { - if ( - isAttributeMenuOpen && - !attributeMenuRef.current?.contains(event.target as Node) - ) { - setIsAttributeMenuOpen(false); - } - }; - - useEffect(() => { - window.addEventListener("keydown", handleAttributeMenuKeys); - window.addEventListener("click", handleAttributeClickOutside); - return () => { - window.removeEventListener("keydown", handleAttributeMenuKeys); - window.removeEventListener("click", handleAttributeClickOutside); - }; - }, [isAttributeMenuOpen]); - - const handleExploitIqStatusMenuKeys = (event: KeyboardEvent) => { - if ( - isExploitIqStatusMenuOpen && - exploitIqStatusMenuRef.current?.contains(event.target as Node) - ) { - if (event.key === "Escape" || event.key === "Tab") { - setIsExploitIqStatusMenuOpen(false); - exploitIqStatusToggleRef.current?.focus(); - } - } - }; - - const handleExploitIqStatusClickOutside = (event: MouseEvent) => { - if ( - isExploitIqStatusMenuOpen && - !exploitIqStatusMenuRef.current?.contains(event.target as Node) - ) { - setIsExploitIqStatusMenuOpen(false); - } - }; - - useEffect(() => { - window.addEventListener("keydown", handleExploitIqStatusMenuKeys); - window.addEventListener("click", handleExploitIqStatusClickOutside); - return () => { - window.removeEventListener("keydown", handleExploitIqStatusMenuKeys); - window.removeEventListener("click", handleExploitIqStatusClickOutside); - }; - }, [isExploitIqStatusMenuOpen]); - - const handleAnalysisStateMenuKeys = (event: KeyboardEvent) => { - if ( - isAnalysisStateMenuOpen && - analysisStateMenuRef.current?.contains(event.target as Node) - ) { - if (event.key === "Escape" || event.key === "Tab") { - setIsAnalysisStateMenuOpen(false); - analysisStateToggleRef.current?.focus(); - } - } - }; - const handleAnalysisStateClickOutside = (event: MouseEvent) => { - if ( - isAnalysisStateMenuOpen && - !analysisStateMenuRef.current?.contains(event.target as Node) - ) { - setIsAnalysisStateMenuOpen(false); - } - }; - - useEffect(() => { - window.addEventListener("keydown", handleAnalysisStateMenuKeys); - window.addEventListener("click", handleAnalysisStateClickOutside); - return () => { - window.removeEventListener("keydown", handleAnalysisStateMenuKeys); - window.removeEventListener("click", handleAnalysisStateClickOutside); - }; - }, [isAnalysisStateMenuOpen]); - - const onAttributeToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); - setTimeout(() => { - if (attributeMenuRef.current) { - const firstElement = attributeMenuRef.current.querySelector( - "li > button:not(:disabled)" - ); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); - setIsAttributeMenuOpen(!isAttributeMenuOpen); - }; - - const onExploitIqStatusToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); - setTimeout(() => { - if (exploitIqStatusMenuRef.current) { - const firstElement = exploitIqStatusMenuRef.current.querySelector( - "li > button:not(:disabled)" - ); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); - setIsExploitIqStatusMenuOpen(!isExploitIqStatusMenuOpen); - }; - - const onAnalysisStateToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); - setTimeout(() => { - if (analysisStateMenuRef.current) { - const firstElement = analysisStateMenuRef.current.querySelector( - "li > button:not(:disabled)" - ); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); - setIsAnalysisStateMenuOpen(!isAnalysisStateMenuOpen); - }; - - const handleExploitIqStatusSelect = ( - _event: React.MouseEvent | undefined, - itemId: string | number | undefined - ) => { - if (typeof itemId === "undefined") return; - const itemStr = itemId.toString(); - const isSelected = filters.exploitIqStatus.includes(itemStr); + const handleExploitIqStatusSelect = (selected: string[]) => { onFiltersChange({ ...filters, - exploitIqStatus: isSelected - ? filters.exploitIqStatus.filter((v) => v !== itemStr) - : [...filters.exploitIqStatus, itemStr], + exploitIqStatus: selected, }); }; - const handleAnalysisStateSelect = ( - _event: React.MouseEvent | undefined, - itemId: string | number | undefined - ) => { - if (typeof itemId === "undefined") return; - const itemStr = itemId.toString(); - const isSelected = filters.analysisState.includes(itemStr); + const handleAnalysisStateSelect = (selected: string[]) => { onFiltersChange({ ...filters, - analysisState: isSelected - ? filters.analysisState.filter((v) => v !== itemStr) - : [...filters.analysisState, itemStr], + analysisState: selected, }); }; @@ -252,49 +84,6 @@ const ReportsToolbar: React.FC = ({ onFiltersChange({ ...filters, [type]: [] }); }; - const attributeToggle = ( - } - > - {activeAttribute} - - ); - - const attributeMenu = ( - { - setActiveAttribute(itemId?.toString() as ActiveAttribute); - setIsAttributeMenuOpen(false); - }} - > - - - SBOM Name - CVE ID - ExploitIQ Status - Analysis State - - - - ); - - const attributeDropdown = ( -
- -
- ); - const sbomSearchInput = ( = ({ /> ); - const exploitIqStatusToggle = ( - 0 && { - badge: {filters.exploitIqStatus.length}, - })} - style={{ width: "200px" } as React.CSSProperties} - > - Filter by ExploitIQ Status - - ); - - const exploitIqStatusMenu = ( - - - - {ALL_EXPLOIT_IQ_STATUS_OPTIONS.map((option) => ( - - {option} - - ))} - - - - ); - - const exploitIqStatusSelect = ( -
- -
- ); - - const analysisStateToggle = ( - 0 && { - badge: {filters.analysisState.length}, - })} - style={{ width: "200px" } as React.CSSProperties} - > - Filter by Analysis State - - ); - - const analysisStateMenu = ( - - - - {analysisStateOptions.map((option) => ( - - {option} - - ))} - - - - ); - - const analysisStateSelect = ( -
- -
- ); - return ( = ({ } breakpoint="xl"> - {attributeDropdown} + + + setActiveAttribute(attr as ActiveAttribute) + } + /> + onSearchChange("")} @@ -462,7 +162,13 @@ const ReportsToolbar: React.FC = ({ categoryName="ExploitIQ Status" showToolbarItem={activeAttribute === "ExploitIQ Status"} > - {exploitIqStatusSelect} + = ({ categoryName="Analysis State" showToolbarItem={activeAttribute === "Analysis State"} > - {analysisStateSelect} + diff --git a/src/main/webui/src/components/RepositoryReportsTable.tsx b/src/main/webui/src/components/RepositoryReportsTable.tsx index 7b9118ad..7dca2b3d 100644 --- a/src/main/webui/src/components/RepositoryReportsTable.tsx +++ b/src/main/webui/src/components/RepositoryReportsTable.tsx @@ -52,16 +52,30 @@ const RepositoryReportsTable: React.FC = ({ const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(PER_PAGE); const [scanStateFilter, setScanStateFilter] = useState([]); + const [exploitIqStatusFilter, setExploitIqStatusFilter] = useState( + [] + ); const scanStateOptions = useMemo(() => { const componentStates = productSummary.summary.componentStates || {}; return Object.keys(componentStates).sort(); }, [productSummary.summary.componentStates]); - const { data: reports, loading, error, pagination } = usePaginatedApi>( + const getVulnerabilityStatus = (report: Report) => { + if (!report.vulns || !cveId) return null; + const vuln = report.vulns.find((v) => v.vulnId === cveId); + return vuln?.justification?.status; + }; + + const { + data: reports, + loading, + error, + pagination, + } = usePaginatedApi>( () => ({ - method: 'GET', - url: '/api/reports', + method: "GET", + url: "/api/reports", query: { page: page - 1, pageSize: perPage, @@ -71,14 +85,53 @@ const RepositoryReportsTable: React.FC = ({ scanStateFilter[0] && { status: scanStateFilter[0] }), }, }), - { deps: [page, perPage, productId, cveId, scanStateFilter] } + { + deps: [ + page, + perPage, + productId, + cveId, + scanStateFilter, + exploitIqStatusFilter, + ], + } ); - const handleFilterChange = (filters: string[]) => { + // Filter reports by ExploitIQ status on the client side + const filteredReports = useMemo(() => { + if (!reports || exploitIqStatusFilter.length === 0) { + return reports || []; + } + + return reports.filter((report) => { + const status = getVulnerabilityStatus(report); + if (!status) return false; + // Map status to filter format: TRUE -> Vulnerable, FALSE -> Not Vulnerable, UNKNOWN -> Uncertain + const filterLabel = + status === "TRUE" + ? "Vulnerable" + : status === "FALSE" + ? "Not Vulnerable" + : status === "UNKNOWN" + ? "Uncertain" + : ""; + return filterLabel && exploitIqStatusFilter.includes(filterLabel); + }); + }, [reports, exploitIqStatusFilter, cveId]); + + const displayReports = filteredReports; + const totalFilteredCount = pagination?.totalElements ?? 0; + + const handleScanStateFilterChange = (filters: string[]) => { setScanStateFilter(filters); setPage(1); }; + const handleExploitIqStatusFilterChange = (filters: string[]) => { + setExploitIqStatusFilter(filters); + setPage(1); + }; + const onSetPage = ( _event: React.MouseEvent | React.KeyboardEvent | MouseEvent, newPage: number @@ -95,12 +148,6 @@ const RepositoryReportsTable: React.FC = ({ setPage(newPage); }; - const getVulnerabilityStatus = (report: Report) => { - if (!report.vulns || !cveId) return null; - const vuln = report.vulns.find((v) => v.vulnId === cveId); - return vuln?.justification?.status; - }; - const renderExploitIqStatus = (report: Report) => { const status = getVulnerabilityStatus(report); if (!status) return ""; @@ -140,18 +187,20 @@ const RepositoryReportsTable: React.FC = ({ ); } - if (!reports || reports.length === 0) { + if (!reports || (reports.length === 0 && !loading)) { return ( <> = ({ No repository reports found - {scanStateFilter.length > 0 + {scanStateFilter.length > 0 || exploitIqStatusFilter.length > 0 + ? "No repository reports found matching the selected filters." + : "No repository reports found for this product and CVE combination."} + + + + ); + } + + if (!displayReports || displayReports.length === 0) { + return ( + <> + + + + No repository reports found + + + {scanStateFilter.length > 0 || exploitIqStatusFilter.length > 0 ? "No repository reports found matching the selected filters." : "No repository reports found for this product and CVE combination."} @@ -179,10 +260,12 @@ const RepositoryReportsTable: React.FC = ({ = ({ }} />
- - - - - - - - - - - - {reports.map((report) => ( - - - - - - - - - ))} - -
RepositoryCommit IDExploitIQ StatusCompletedAnalysis stateCVE Repository Report
- {report.gitRepo || ""} - - {report.ref || ""} - - {renderExploitIqStatus(report)} - - - - - - - - -
+ + + Repository + Commit ID + ExploitIQ Status + Completed + Analysis state + CVE Repository Report + + + + {displayReports.map((report) => ( + + + {report.gitRepo || ""} + + + {report.ref || ""} + + + {renderExploitIqStatus(report)} + + + + + + + + + + + + + + ))} + + ); }; diff --git a/src/main/webui/src/components/RepositoryTableToolbar.tsx b/src/main/webui/src/components/RepositoryTableToolbar.tsx index 1eabb453..46a59804 100644 --- a/src/main/webui/src/components/RepositoryTableToolbar.tsx +++ b/src/main/webui/src/components/RepositoryTableToolbar.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState } from "react"; import { Toolbar, ToolbarContent, @@ -6,22 +6,22 @@ import { ToolbarGroup, ToolbarFilter, ToolbarToggleGroup, - Menu, - MenuContent, - MenuList, - MenuItem, - MenuToggle, - Badge, - Popper, Pagination, } from "@patternfly/react-core"; import { FilterIcon } from "@patternfly/react-icons"; +import { + AttributeSelector, + CheckboxFilter, + ALL_EXPLOIT_IQ_STATUS_OPTIONS, +} from "./Filtering"; interface RepositoryTableToolbarProps { scanStateFilter: string[]; scanStateOptions: string[]; + exploitIqStatusFilter: string[]; loading: boolean; - onFilterChange: (filters: string[]) => void; + onScanStateFilterChange: (filters: string[]) => void; + onExploitIqStatusFilterChange: (filters: string[]) => void; pagination?: { itemCount: number; page: number; @@ -38,148 +38,97 @@ interface RepositoryTableToolbarProps { }; } +type ActiveAttribute = "Analysis State" | "ExploitIQ Status"; + const RepositoryTableToolbar: React.FC = ({ scanStateFilter, scanStateOptions, + exploitIqStatusFilter, loading, - onFilterChange, + onScanStateFilterChange, + onExploitIqStatusFilterChange, pagination, }) => { - const [isScanStateMenuOpen, setIsScanStateMenuOpen] = useState(false); - - const scanStateToggleRef = useRef(null); - const scanStateMenuRef = useRef(null); - const scanStateContainerRef = useRef(null); + const [activeAttribute, setActiveAttribute] = + useState("Analysis State"); - const handleScanStateMenuKeys = (event: KeyboardEvent) => { - if ( - isScanStateMenuOpen && - scanStateMenuRef.current?.contains(event.target as Node) - ) { - if (event.key === "Escape" || event.key === "Tab") { - setIsScanStateMenuOpen(false); - scanStateToggleRef.current?.focus(); - } - } - }; - - const handleScanStateClickOutside = (event: MouseEvent) => { - if ( - isScanStateMenuOpen && - !scanStateMenuRef.current?.contains(event.target as Node) && - !scanStateToggleRef.current?.contains(event.target as Node) && - !scanStateContainerRef.current?.contains(event.target as Node) - ) { - setIsScanStateMenuOpen(false); + const handleScanStateFilterDelete = ( + _category: string | unknown, + label: string | unknown + ) => { + if (typeof label === "string") { + onScanStateFilterChange(scanStateFilter.filter((fil) => fil !== label)); } }; - useEffect(() => { - window.addEventListener("keydown", handleScanStateMenuKeys); - window.addEventListener("click", handleScanStateClickOutside); - return () => { - window.removeEventListener("keydown", handleScanStateMenuKeys); - window.removeEventListener("click", handleScanStateClickOutside); - }; - }, [isScanStateMenuOpen]); - - const onScanStateToggleClick = () => { - setIsScanStateMenuOpen(!isScanStateMenuOpen); - }; - - const handleScanStateSelect = ( - _event: React.MouseEvent | undefined, - itemId: string | number | undefined + const handleExploitIqStatusFilterDelete = ( + _category: string | unknown, + label: string | unknown ) => { - if (typeof itemId === "undefined") return; - const itemStr = itemId.toString(); - const isSelected = scanStateFilter.includes(itemStr); - onFilterChange( - isSelected - ? scanStateFilter.filter((v) => v !== itemStr) - : [...scanStateFilter, itemStr] - ); - }; - - const handleFilterDelete = (_category: string | unknown, label: string | unknown) => { if (typeof label === "string") { - onFilterChange(scanStateFilter.filter((fil) => fil !== label)); + onExploitIqStatusFilterChange( + exploitIqStatusFilter.filter((fil) => fil !== label) + ); } }; const handleFilterDeleteGroup = () => { - onFilterChange([]); + onScanStateFilterChange([]); + onExploitIqStatusFilterChange([]); }; - const scanStateToggle = ( - 0 && { - badge: {scanStateFilter.length}, - })} - style={{ width: "200px" } as React.CSSProperties} - isDisabled={loading} - > - Filter by Analysis State - - ); - - const scanStateMenu = ( - - - - {scanStateOptions.map((option) => ( - - {option} - - ))} - - - - ); - - const scanStateSelect = ( -
- -
- ); - return ( 0 ? handleFilterDeleteGroup : undefined + scanStateFilter.length > 0 || exploitIqStatusFilter.length > 0 + ? handleFilterDeleteGroup + : undefined } > } breakpoint="xl"> + + + setActiveAttribute(attr as ActiveAttribute) + } + /> + + + + - {scanStateSelect} + @@ -208,4 +157,3 @@ const RepositoryTableToolbar: React.FC = ({ }; export default RepositoryTableToolbar; - From 246fa98e495e58cdd22d1cc5a38ba4f5fba245f7 Mon Sep 17 00:00:00 2001 From: rhartuv Date: Wed, 31 Dec 2025 12:29:35 +0200 Subject: [PATCH 03/18] fix: add all statuses to CVE Status Summary pie chart --- .../components/ReportCveStatusPieChart.tsx | 63 ++++++++----------- 1 file changed, 25 insertions(+), 38 deletions(-) diff --git a/src/main/webui/src/components/ReportCveStatusPieChart.tsx b/src/main/webui/src/components/ReportCveStatusPieChart.tsx index 428352a0..453211e0 100644 --- a/src/main/webui/src/components/ReportCveStatusPieChart.tsx +++ b/src/main/webui/src/components/ReportCveStatusPieChart.tsx @@ -1,12 +1,5 @@ import { useMemo } from "react"; -import { - Card, - CardTitle, - CardBody, - Title, - EmptyState, - EmptyStateBody, -} from "@patternfly/react-core"; +import { Card, CardTitle, CardBody, Title } from "@patternfly/react-core"; import { ProductSummary } from "../generated-client"; import DonutChartWrapper from "./DonutChartWrapper"; @@ -21,7 +14,10 @@ const ReportCveStatusPieChart: React.FC = ({ }) => { const chartData = useMemo(() => { const cveStatusCounts = productSummary.summary.cveStatusCounts || {}; - const statusCounts = (cveStatusCounts[cveId] || {}) as Record; + const statusCounts = (cveStatusCounts[cveId] || {}) as Record< + string, + number + >; let vulnerableCount = 0; let notVulnerableCount = 0; @@ -38,18 +34,12 @@ const ReportCveStatusPieChart: React.FC = ({ } }); - const slices = []; - if (vulnerableCount > 0) { - slices.push({ x: "vulnerable", y: vulnerableCount }); - } - if (notVulnerableCount > 0) { - slices.push({ x: "not_vulnerable", y: notVulnerableCount }); - } - if (unknownCount > 0) { - slices.push({ x: "uncertain", y: unknownCount }); - } - - return slices; + // Always include all 3 statuses, even if count is 0 + return [ + { x: "vulnerable", y: vulnerableCount }, + { x: "not_vulnerable", y: notVulnerableCount }, + { x: "uncertain", y: unknownCount }, + ]; }, [productSummary, cveId]); const computeColors = (slices: Array<{ x: string; y: number }>) => { @@ -75,7 +65,10 @@ const ReportCveStatusPieChart: React.FC = ({ }; const colors = useMemo(() => computeColors(chartData), [chartData]); - const total = useMemo(() => chartData.reduce((sum, d) => sum + d.y, 0), [chartData]); + const total = useMemo( + () => chartData.reduce((sum, d) => sum + d.y, 0), + [chartData] + ); const legendData = useMemo( () => chartData.map((d) => ({ name: `${toTitleCase(d.x)}: ${d.y}` })), [chartData] @@ -89,22 +82,16 @@ const ReportCveStatusPieChart: React.FC = ({ - {chartData.length === 0 ? ( - - No CVE incidents found for this CVE. - - ) : ( - - )} + ); From 1554a434baa2b32a729bacdb7c87ad302a63a589 Mon Sep 17 00:00:00 2001 From: rhartuv Date: Wed, 31 Dec 2025 16:15:46 +0200 Subject: [PATCH 04/18] feat: add filter by exploitIQ Status to /reports API backend --- .../morpheus/rest/ReportEndpoint.java | 6 +- .../service/ReportRepositoryService.java | 24 ++++ src/main/webui/src/components/Filtering.tsx | 45 ++++-- .../src/components/RepositoryReportsTable.tsx | 131 ++++++++---------- .../src/components/RepositoryTableToolbar.tsx | 1 + .../webui/src/hooks/useReportsTableData.ts | 3 +- 6 files changed, 128 insertions(+), 82 deletions(-) diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/ReportEndpoint.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/ReportEndpoint.java index 42cbc862..48edccdc 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/ReportEndpoint.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/ReportEndpoint.java @@ -298,7 +298,11 @@ public Response list( @Parameter( description = "Filter by product ID (metadata.product_id)" ) - @QueryParam("productId") String productId) { + @QueryParam("productId") String productId, + @Parameter( + description = "Filter by ExploitIQ status. Valid values: TRUE, FALSE, UNKNOWN" + ) + @QueryParam("exploitIqStatus") String exploitIqStatus) { var filter = uriInfo.getQueryParameters().entrySet().stream().filter(e -> !FIXED_QUERY_PARAMS.contains(e.getKey())) .collect(Collectors.toMap(Entry::getKey, e -> e.getValue().getFirst())); diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ReportRepositoryService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ReportRepositoryService.java index 31491ed2..dace2270 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ReportRepositoryService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ReportRepositoryService.java @@ -530,6 +530,9 @@ public void removeBefore(Instant threshold) { private Bson buildQueryFilter(Map queryFilter) { List filters = new ArrayList<>(); + String vulnId = queryFilter.get("vulnId"); + String exploitIqStatus = queryFilter.get("exploitIqStatus"); + queryFilter.entrySet().forEach(e -> { switch (e.getKey()) { @@ -552,12 +555,33 @@ private Bson buildQueryFilter(Map queryFilter) { case "productId": filters.add(Filters.eq("metadata.product_id", e.getValue())); break; + case "exploitIqStatus": + // Skip here, will be handled after the loop + break; default: filters.add(Filters.eq(String.format("metadata.%s", e.getKey()), e.getValue())); break; } }); + + // Handle exploitIqStatus filter after processing other filters + if (exploitIqStatus != null && !exploitIqStatus.isEmpty()) { + if (vulnId != null && !vulnId.isEmpty()) { + // If vulnId is specified: filter reports where the specified CVE has the matching status + filters.add(Filters.elemMatch("output", + Filters.and( + Filters.eq("vuln_id", vulnId), + Filters.eq("justification.status", exploitIqStatus) + ) + )); + } else { + // If vulnId is NOT specified: filter reports where any CVE has the matching status + filters.add(Filters.elemMatch("output", + Filters.eq("justification.status", exploitIqStatus) + )); + } + } var filter = Filters.empty(); if (!filters.isEmpty()) { filter = Filters.and(filters); diff --git a/src/main/webui/src/components/Filtering.tsx b/src/main/webui/src/components/Filtering.tsx index b315da4a..1d899259 100644 --- a/src/main/webui/src/components/Filtering.tsx +++ b/src/main/webui/src/components/Filtering.tsx @@ -16,6 +16,22 @@ export const ALL_EXPLOIT_IQ_STATUS_OPTIONS = [ "Uncertain", ]; +/** + * Maps display label to API value for ExploitIQ status + */ +export function mapDisplayLabelToApiValue(displayLabel: string): string { + switch (displayLabel) { + case "Vulnerable": + return "TRUE"; + case "Not Vulnerable": + return "FALSE"; + case "Uncertain": + return "UNKNOWN"; + default: + return displayLabel.toLowerCase().replace(/\s+/g, "_"); + } +} + /** * Hook for managing menu open/close state with keyboard and click outside handlers */ @@ -160,6 +176,7 @@ export interface CheckboxFilterProps { selected: string[]; onSelect: (selected: string[]) => void; loading?: boolean; + singleSelect?: boolean; } export function CheckboxFilter({ @@ -169,6 +186,7 @@ export function CheckboxFilter({ selected, onSelect, loading = false, + singleSelect = false, }: CheckboxFilterProps) { const [isMenuOpen, setIsMenuOpen] = useState(false); const toggleRef = useRef(null); @@ -197,11 +215,19 @@ export function CheckboxFilter({ if (typeof itemId === "undefined") return; const itemStr = itemId.toString(); const isSelected = selected.includes(itemStr); - onSelect( - isSelected - ? selected.filter((v) => v !== itemStr) - : [...selected, itemStr] - ); + + if (singleSelect) { + // Single selection: replace current selection with new one, or clear if clicking the same + onSelect(isSelected ? [] : [itemStr]); + setIsMenuOpen(false); + } else { + // Multiple selection: toggle the item + onSelect( + isSelected + ? selected.filter((v) => v !== itemStr) + : [...selected, itemStr] + ); + } }; const toggle = ( @@ -209,13 +235,14 @@ export function CheckboxFilter({ ref={toggleRef} onClick={onToggleClick} isExpanded={isMenuOpen} - {...(selected.length > 0 && { - badge: {selected.length}, - })} + {...(!singleSelect && + selected.length > 0 && { + badge: {selected.length}, + })} style={{ width: "200px" } as React.CSSProperties} isDisabled={loading} > - {label} + {singleSelect && selected.length > 0 ? `${label}: ${selected[0]}` : label} ); diff --git a/src/main/webui/src/components/RepositoryReportsTable.tsx b/src/main/webui/src/components/RepositoryReportsTable.tsx index 7dca2b3d..2ba6b061 100644 --- a/src/main/webui/src/components/RepositoryReportsTable.tsx +++ b/src/main/webui/src/components/RepositoryReportsTable.tsx @@ -26,6 +26,7 @@ import { Report, ProductSummary } from "../generated-client"; import { getErrorMessage } from "../utils/errorHandling"; import FormattedTimestamp from "./FormattedTimestamp"; import RepositoryTableToolbar from "./RepositoryTableToolbar"; +import { mapDisplayLabelToApiValue } from "./Filtering"; const PER_PAGE = 10; @@ -56,6 +57,13 @@ const RepositoryReportsTable: React.FC = ({ [] ); + // Convert filter array to single API value (use first selected value) + const exploitIqStatusApiValue = useMemo(() => { + if (exploitIqStatusFilter.length === 0 || !exploitIqStatusFilter[0]) + return undefined; + return mapDisplayLabelToApiValue(exploitIqStatusFilter[0]); + }, [exploitIqStatusFilter]); + const scanStateOptions = useMemo(() => { const componentStates = productSummary.summary.componentStates || {}; return Object.keys(componentStates).sort(); @@ -83,6 +91,9 @@ const RepositoryReportsTable: React.FC = ({ vulnId: cveId, ...(scanStateFilter.length > 0 && scanStateFilter[0] && { status: scanStateFilter[0] }), + ...(exploitIqStatusApiValue && { + exploitIqStatus: exploitIqStatusApiValue, + }), }, }), { @@ -92,34 +103,12 @@ const RepositoryReportsTable: React.FC = ({ productId, cveId, scanStateFilter, - exploitIqStatusFilter, + exploitIqStatusApiValue, ], } ); - // Filter reports by ExploitIQ status on the client side - const filteredReports = useMemo(() => { - if (!reports || exploitIqStatusFilter.length === 0) { - return reports || []; - } - - return reports.filter((report) => { - const status = getVulnerabilityStatus(report); - if (!status) return false; - // Map status to filter format: TRUE -> Vulnerable, FALSE -> Not Vulnerable, UNKNOWN -> Uncertain - const filterLabel = - status === "TRUE" - ? "Vulnerable" - : status === "FALSE" - ? "Not Vulnerable" - : status === "UNKNOWN" - ? "Uncertain" - : ""; - return filterLabel && exploitIqStatusFilter.includes(filterLabel); - }); - }, [reports, exploitIqStatusFilter, cveId]); - - const displayReports = filteredReports; + const displayReports = reports || []; const totalFilteredCount = pagination?.totalElements ?? 0; const handleScanStateFilterChange = (filters: string[]) => { @@ -272,53 +261,53 @@ const RepositoryReportsTable: React.FC = ({ onPerPageSelect: onPerPageSelect, }} /> - - - - - - - - - - - - - {displayReports.map((report) => ( - - - - - - - - - ))} - -
RepositoryCommit IDExploitIQ StatusCompletedAnalysis stateCVE Repository Report
- {report.gitRepo || ""} - - {report.ref || ""} - - {renderExploitIqStatus(report)} - - - - - - - - -
+ + + + + + + + + + + + + {reports.map((report) => ( + + + + + + + + + ))} + +
RepositoryCommit IDExploitIQ StatusCompletedAnalysis stateCVE Repository Report
+ {report.gitRepo || ""} + + {report.ref || ""} + + {renderExploitIqStatus(report)} + + + + + + + + +
); }; diff --git a/src/main/webui/src/components/RepositoryTableToolbar.tsx b/src/main/webui/src/components/RepositoryTableToolbar.tsx index 46a59804..7dfa272a 100644 --- a/src/main/webui/src/components/RepositoryTableToolbar.tsx +++ b/src/main/webui/src/components/RepositoryTableToolbar.tsx @@ -128,6 +128,7 @@ const RepositoryTableToolbar: React.FC = ({ selected={exploitIqStatusFilter} onSelect={onExploitIqStatusFilterChange} loading={loading} + singleSelect={true} /> diff --git a/src/main/webui/src/hooks/useReportsTableData.ts b/src/main/webui/src/hooks/useReportsTableData.ts index 5149a575..8c6b0394 100644 --- a/src/main/webui/src/hooks/useReportsTableData.ts +++ b/src/main/webui/src/hooks/useReportsTableData.ts @@ -242,7 +242,8 @@ export function filterAndSortReportRows( ); } - // Apply ExploitIQ status filter + // ExploitIQ status filtering is now handled server-side via /api/reports endpoint + // This client-side filter is kept for backward compatibility with product summaries endpoint but should be removed once ReportsTable switches to using /api/reports with exploitIqStatus filter if (filters.exploitIqStatus.length > 0) { filtered = filtered.filter((row) => { const statusItems = getStatusItems(row.productStatus); From b0cbbc445c316fa30e7ab09f676a949f3c869ee8 Mon Sep 17 00:00:00 2001 From: rhartuv Date: Wed, 31 Dec 2025 17:05:25 +0200 Subject: [PATCH 05/18] style: add colors to scan state label on repository table --- .../src/components/RepositoryReportsTable.tsx | 138 ++++++++++++------ src/main/webui/src/pages/ReportPage.tsx | 34 ++++- 2 files changed, 120 insertions(+), 52 deletions(-) diff --git a/src/main/webui/src/components/RepositoryReportsTable.tsx b/src/main/webui/src/components/RepositoryReportsTable.tsx index 2ba6b061..db99fcf0 100644 --- a/src/main/webui/src/components/RepositoryReportsTable.tsx +++ b/src/main/webui/src/components/RepositoryReportsTable.tsx @@ -9,6 +9,7 @@ import { EmptyStateBody, Title, Label, + Icon, } from "@patternfly/react-core"; import { Table, @@ -19,7 +20,10 @@ import { Tbody, Td, } from "@patternfly/react-table"; -import { CheckCircleIcon } from "@patternfly/react-icons"; +import { + CheckCircleIcon, + ExclamationTriangleIcon, +} from "@patternfly/react-icons"; import SkeletonTable from "@patternfly/react-component-groups/dist/dynamic/SkeletonTable"; import { usePaginatedApi } from "../hooks/usePaginatedApi"; import { Report, ProductSummary } from "../generated-client"; @@ -153,6 +157,48 @@ const RepositoryReportsTable: React.FC = ({ return ""; }; + const renderAnalysisState = (report: Report) => { + const state = report.state?.toLowerCase(); + + if (state === "completed") { + return ( + + ); + } + + if (state === "expired") { + return ( + + ); + } + + return ( + + ); + }; + if (loading) { return ( = ({ onPerPageSelect: onPerPageSelect, }} /> - - - - - - - - - - - - - {reports.map((report) => ( - - - - - - - - - ))} - -
RepositoryCommit IDExploitIQ StatusCompletedAnalysis stateCVE Repository Report
- {report.gitRepo || ""} - - {report.ref || ""} - - {renderExploitIqStatus(report)} - - - - - - - - -
+ + + + + + + + + + + + + {reports.map((report) => ( + + + + + + + + + ))} + +
RepositoryCommit IDExploitIQ StatusCompletedAnalysis stateCVE Repository Report
+ {report.gitRepo || ""} + + {report.ref || ""} + + {renderExploitIqStatus(report)} + + + {renderAnalysisState(report)} + + + +
); }; diff --git a/src/main/webui/src/pages/ReportPage.tsx b/src/main/webui/src/pages/ReportPage.tsx index fbd0a227..de6d7605 100644 --- a/src/main/webui/src/pages/ReportPage.tsx +++ b/src/main/webui/src/pages/ReportPage.tsx @@ -9,8 +9,9 @@ import { BreadcrumbItem, Title, Label, + Icon, } from "@patternfly/react-core"; -import { CheckCircleIcon } from "@patternfly/react-icons"; +import {CheckCircleIcon,ExclamationTriangleIcon,} from "@patternfly/react-icons"; import { useReport } from "../hooks/useReport"; import ReportDetails from "../components/ReportDetails"; import ReportAdditionalDetails from "../components/ReportAdditionalDetails"; @@ -52,7 +53,6 @@ const ReportPage: React.FC = () => { const sbomName = data.data.name || ""; const breadcrumbText = `${sbomName}/${cveId}`; const productState = data.summary.productState || ""; - const isCompleted = productState === "completed"; const renderStatusLabel = () => { if (!productState) return null; @@ -61,9 +61,35 @@ const ReportPage: React.FC = () => { return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); }; - if (isCompleted) { + const state = productState.toLowerCase(); + + if (state === "completed") { + return ( + + ); + } + + if (state === "expired") { return ( -