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..92c00d2a 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/ReportEndpoint.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/ReportEndpoint.java @@ -298,10 +298,20 @@ public Response list( @Parameter( description = "Filter by product ID (metadata.product_id)" ) - @QueryParam("productId") String productId) { - - var filter = uriInfo.getQueryParameters().entrySet().stream().filter(e -> !FIXED_QUERY_PARAMS.contains(e.getKey())) - .collect(Collectors.toMap(Entry::getKey, e -> e.getValue().getFirst())); + @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().size() > 1 + ? String.join(",", e.getValue()) + : e.getValue().getFirst() + )); var result = reportService.list(filter, SortField.fromSortBy(sortBy), page, pageSize); return Response.ok(result.results) .header("X-Total-Pages", result.totalPages) 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..b77fb234 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ReportRepositoryService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ReportRepositoryService.java @@ -270,8 +270,9 @@ public List findByName(String name) { private static final Map SORT_MAPPINGS = Map.of( "completedAt", "input.scan.completed_at", "submittedAt", "metadata.submitted_at", - "name", "input.scan.id", - "vuln_id", "output.vuln_id"); + "vuln_id", "output.vuln_id", + "ref", "input.image.source_info.ref", + "gitRepo", "input.image.source_info.git_repo"); public PaginatedResult list(Map queryFilter, List sortFields, Pagination pagination) { @@ -280,11 +281,18 @@ public PaginatedResult list(Map queryFilter, List sorts = new ArrayList<>(); sortFields.forEach(sf -> { - var fieldName = SORT_MAPPINGS.get(sf.field()); - if (SortType.ASC.equals(sf.type())) { - sorts.add(Sorts.ascending(fieldName)); + if ("state".equals(sf.field())) { + sorts.add(Sorts.descending("input.scan.completed_at")); + sorts.add(Sorts.ascending("error.type")); } else { - sorts.add(Sorts.descending(fieldName)); + var fieldName = SORT_MAPPINGS.get(sf.field()); + if (fieldName != null) { + if (SortType.ASC.equals(sf.type())) { + sorts.add(Sorts.ascending(fieldName)); + } else { + sorts.add(Sorts.descending(fieldName)); + } + } } }); @@ -528,36 +536,127 @@ public void removeBefore(Instant threshold) { LOGGER.debugf("Removed %s reports before %s", count, threshold); } + private void handleMultipleValues(String valueString, + java.util.function.Function filterBuilder, + List filters) { + String[] values = valueString.split(","); + if (values.length == 1) { + filters.add(filterBuilder.apply(values[0].trim())); + } else { + List valueFilters = new ArrayList<>(); + for (String value : values) { + valueFilters.add(filterBuilder.apply(value.trim())); + } + filters.add(valueFilters.size() == 1 ? valueFilters.get(0) : Filters.or(valueFilters)); + } + } + 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()) { case "reportId": - filters.add(Filters.eq("input.scan.id", e.getValue())); + handleMultipleValues(e.getValue(), (value) -> + Filters.eq("input.scan.id", value), filters); break; case "vulnId": - filters.add(Filters.elemMatch("input.scan.vulns", Filters.eq("vuln_id", e.getValue()))); + handleMultipleValues(e.getValue(), (value) -> + Filters.elemMatch("input.scan.vulns", Filters.eq("vuln_id", value)), filters); break; case "status": - var field = e.getValue(); - filters.add(STATUS_FILTERS.get(field)); + var statusValues = e.getValue().split(","); + if (statusValues.length == 1) { + var statusFilter = STATUS_FILTERS.get(statusValues[0].trim()); + if (statusFilter != null) { + filters.add(statusFilter); + } + } else { + List statusFilters = new ArrayList<>(); + for (String statusValue : statusValues) { + var statusFilter = STATUS_FILTERS.get(statusValue.trim()); + if (statusFilter != null) { + statusFilters.add(statusFilter); + } + } + if (!statusFilters.isEmpty()) { + filters.add(Filters.or(statusFilters)); + } + } break; case "imageName": - filters.add(Filters.eq("input.image.name", e.getValue())); + handleMultipleValues(e.getValue(), (value) -> + Filters.eq("input.image.name", value), filters); break; case "imageTag": - filters.add(Filters.eq("input.image.tag", e.getValue())); + handleMultipleValues(e.getValue(), (value) -> + Filters.eq("input.image.tag", value), filters); break; case "productId": - filters.add(Filters.eq("metadata.product_id", e.getValue())); + handleMultipleValues(e.getValue(), (value) -> + Filters.eq("metadata.product_id", value), filters); + break; + case "gitRepo": + var gitRepoValues = e.getValue().split(","); + if (gitRepoValues.length == 1) { + filters.add(Filters.elemMatch("input.image.source_info", + Filters.and( + Filters.eq("type", "code"), + Filters.regex("git_repo", gitRepoValues[0].trim(), "i") + ) + )); + } else { + List gitRepoFilters = new ArrayList<>(); + for (String gitRepoValue : gitRepoValues) { + gitRepoFilters.add(Filters.elemMatch("input.image.source_info", + Filters.and( + Filters.eq("type", "code"), + Filters.regex("git_repo", gitRepoValue.trim(), "i") + ) + )); + } + filters.add(Filters.or(gitRepoFilters)); + } + break; + case "exploitIqStatus": break; default: - filters.add(Filters.eq(String.format("metadata.%s", e.getKey()), e.getValue())); + handleMultipleValues(e.getValue(), (value) -> + Filters.eq(String.format("metadata.%s", e.getKey()), value), filters); break; } }); + + if (exploitIqStatus != null && !exploitIqStatus.isEmpty()) { + String[] exploitIqStatusValues = exploitIqStatus.split(","); + List exploitIqStatusFilters = new ArrayList<>(); + + for (String statusValue : exploitIqStatusValues) { + String trimmedStatus = statusValue.trim(); + if (vulnId != null && !vulnId.isEmpty()) { + exploitIqStatusFilters.add(Filters.elemMatch("output", + Filters.and( + Filters.eq("vuln_id", vulnId), + Filters.eq("justification.status", trimmedStatus) + ) + )); + } else { + exploitIqStatusFilters.add(Filters.elemMatch("output", + Filters.eq("justification.status", trimmedStatus) + )); + } + } + + if (!exploitIqStatusFilters.isEmpty()) { + filters.add(exploitIqStatusFilters.size() == 1 + ? exploitIqStatusFilters.get(0) + : Filters.or(exploitIqStatusFilters)); + } + } var filter = Filters.empty(); if (!filters.isEmpty()) { filter = Filters.and(filters); diff --git a/src/main/webui/index.html b/src/main/webui/index.html index 9cd13985..8341dbf6 100644 --- a/src/main/webui/index.html +++ b/src/main/webui/index.html @@ -2,9 +2,9 @@ - + - Agent Morpheus Client + ExploitIQ
diff --git a/src/main/webui/src/assets/redhat.svg b/src/main/webui/public/redhat.svg similarity index 99% rename from src/main/webui/src/assets/redhat.svg rename to src/main/webui/public/redhat.svg index ae113a25..91026f4f 100644 --- a/src/main/webui/src/assets/redhat.svg +++ b/src/main/webui/public/redhat.svg @@ -4,4 +4,3 @@ - diff --git a/src/main/webui/src/components/Filtering.tsx b/src/main/webui/src/components/Filtering.tsx new file mode 100644 index 00000000..1d899259 --- /dev/null +++ b/src/main/webui/src/components/Filtering.tsx @@ -0,0 +1,280 @@ +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", +]; + +/** + * 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 + */ +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; + singleSelect?: boolean; +} + +export function CheckboxFilter({ + id, + label, + options, + selected, + onSelect, + loading = false, + singleSelect = 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); + + 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 = ( + 0 && { + badge: {selected.length}, + })} + style={{ width: "200px" } as React.CSSProperties} + isDisabled={loading} + > + {singleSelect && selected.length > 0 ? `${label}: ${selected[0]}` : label} + + ); + + const menu = ( + + + + {options.map((option) => ( + + {option} + + ))} + + + + ); + + return ( +
+ +
+ ); +} diff --git a/src/main/webui/src/components/MetricsCard.tsx b/src/main/webui/src/components/MetricsCard.tsx index 0fcadc52..72410530 100644 --- a/src/main/webui/src/components/MetricsCard.tsx +++ b/src/main/webui/src/components/MetricsCard.tsx @@ -59,7 +59,7 @@ const MetricsCard: React.FC = () => { - Last 24 Hours Metrics + Last Week Metrics @@ -85,7 +85,7 @@ const MetricsCard: React.FC = () => { - Based on the data from the last 24 hours. These metrics help identify false positives by tracking the percentage of analysis results that are correctly identified as not vulnerable. + Based on the data from the last week. These metrics help identify false positives by tracking the percentage of analysis results that are correctly identified as not vulnerable. ); }; diff --git a/src/main/webui/src/components/PageHeader.tsx b/src/main/webui/src/components/PageHeader.tsx index 28db92e5..e932109a 100644 --- a/src/main/webui/src/components/PageHeader.tsx +++ b/src/main/webui/src/components/PageHeader.tsx @@ -19,24 +19,28 @@ import { import { PageToggleButton } from '@patternfly/react-core'; import BarsIcon from '@patternfly/react-icons/dist/esm/icons/bars-icon'; import UserAvatarDropdown from './UserAvatarDropdown'; -import redhatLogo from '../assets/redhat.svg?url'; /** * Brand component - displays Red Hat logo and product name */ const Brand: React.FC = () => { return ( -
- Red Hat +
+
+ Red Hat +
+
- <strong>Red Hat</strong> + Red Hat - - Trusted Profile + <Title headingLevel="h6" size="md" > + <strong>Trusted Profile Analyzer</strong> - Analyzer ExploitIQ + <strong>ExploitIQ</strong>
diff --git a/src/main/webui/src/components/ReportComponentStatesPieChart.tsx b/src/main/webui/src/components/ReportComponentStatesPieChart.tsx index 0397d7a3..6038a2e4 100644 --- a/src/main/webui/src/components/ReportComponentStatesPieChart.tsx +++ b/src/main/webui/src/components/ReportComponentStatesPieChart.tsx @@ -28,7 +28,7 @@ const STATE_ORDER = [ // Color mapping for each component state const STATE_COLORS: Record = { completed: "#3E8635", // green - expired: "#6A6E73", // dark gray + expired: "#F0AB00", // orange failed: "#C9190B", // red queued: "#F0AB00", // orange sent: "#6753AC", // purple diff --git a/src/main/webui/src/components/ReportCveStatusPieChart.tsx b/src/main/webui/src/components/ReportCveStatusPieChart.tsx index 428352a0..7116a21b 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"; @@ -38,18 +31,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 }>) => { @@ -89,22 +76,16 @@ const ReportCveStatusPieChart: React.FC = ({ - {chartData.length === 0 ? ( - - No CVE incidents found for this CVE. - - ) : ( - - )} + ); diff --git a/src/main/webui/src/components/ReportsSummaryCard.tsx b/src/main/webui/src/components/ReportsSummaryCard.tsx index 8b14edeb..dadb3564 100644 --- a/src/main/webui/src/components/ReportsSummaryCard.tsx +++ b/src/main/webui/src/components/ReportsSummaryCard.tsx @@ -22,6 +22,7 @@ import { ExclamationTriangleIcon, InProgressIcon, } from "@patternfly/react-icons"; +import SkeletonCard from "./SkeletonCard"; interface SummaryStatItemProps { label: string; @@ -59,21 +60,6 @@ const SummaryStatItem: React.FC = ({ ); }; -const LoadingState: React.FC = () => { - return ( - - - - - - - Loading reports summary... - - - - ); -}; - const ErrorState: React.FC<{ error: unknown }> = ({ error }) => { return ( @@ -109,7 +95,7 @@ const ReportsSummaryCard: React.FC = () => { const { summary, loading, error, reports } = useSummary(); if (loading) { - return ; + return ; } if (error) { diff --git a/src/main/webui/src/components/ReportsTable.tsx b/src/main/webui/src/components/ReportsTable.tsx index 0c876615..412e0ee7 100644 --- a/src/main/webui/src/components/ReportsTable.tsx +++ b/src/main/webui/src/components/ReportsTable.tsx @@ -25,8 +25,9 @@ import { ReportsToolbarFilters } from "./ReportsToolbar"; import ReportsToolbar from "./ReportsToolbar"; import { getErrorMessage } from "../utils/errorHandling"; import FormattedTimestamp from "./FormattedTimestamp"; +import TableEmptyState from "./TableEmptyState"; -const PER_PAGE = 8; +const PER_PAGE = 10; interface ReportsTableProps { searchValue: string; @@ -119,7 +120,7 @@ const ReportsTable: React.FC = ({ if (loading) { return ( = ({ ); } + if (paginatedRows.length === 0) { + return ( + <> + setPage(newPage), + }} + /> + + + ); + } + return ( <> = ({ }) => { 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 cf977dce..bed110b4 100644 --- a/src/main/webui/src/components/RepositoryReportsTable.tsx +++ b/src/main/webui/src/components/RepositoryReportsTable.tsx @@ -4,10 +4,8 @@ import { Button, Alert, AlertVariant, - EmptyState, - EmptyStateBody, - Title, Label, + Icon, } from "@patternfly/react-core"; import { Table, @@ -16,18 +14,26 @@ import { Tr, Th, Tbody, - Td, + 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"; import { getErrorMessage } from "../utils/errorHandling"; import FormattedTimestamp from "./FormattedTimestamp"; import RepositoryTableToolbar from "./RepositoryTableToolbar"; +import { mapDisplayLabelToApiValue } from "./Filtering"; +import TableEmptyState from "./TableEmptyState"; const PER_PAGE = 10; +type SortColumn = "gitRepo" | "completedAt" | "state"; +type SortDirection = "asc" | "desc"; + // Shared style function for table cells with ellipsis truncation const getEllipsisStyle = (maxWidthRem: number): CSSProperties => ({ maxWidth: `${maxWidthRem}rem`, @@ -49,39 +55,145 @@ const RepositoryReportsTable: React.FC = ({ }) => { const navigate = useNavigate(); const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(PER_PAGE); + const [sortColumn, setSortColumn] = useState("state"); + const [sortDirection, setSortDirection] = useState("asc"); const [scanStateFilter, setScanStateFilter] = useState([]); + const [exploitIqStatusFilter, setExploitIqStatusFilter] = useState( + [] + ); + const [repositorySearchValue, setRepositorySearchValue] = + useState(""); + + // Convert filter array to comma-separated API values (all selected values) + const exploitIqStatusApiValue = useMemo(() => { + if (exploitIqStatusFilter.length === 0) return undefined; + // Map all selected display labels to API values and join with comma + return exploitIqStatusFilter + .map((label) => mapDisplayLabelToApiValue(label)) + .join(","); + }, [exploitIqStatusFilter]); 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; + }; + + // Build sortBy parameter for API + const sortByParam = useMemo(() => { + return [`${sortColumn}:${sortDirection.toUpperCase()}`]; + }, [sortColumn, sortDirection]); + + // Build status filter - send all selected status values as comma-separated string + const statusFilterValue = useMemo(() => { + if (scanStateFilter.length === 0) return undefined; + return scanStateFilter.join(","); + }, [scanStateFilter]); + + const { + data: reports, + loading, + error, + pagination, + } = usePaginatedApi>( () => ({ - method: 'GET', - url: '/api/reports', + method: "GET", + url: "/api/reports", query: { page: page - 1, - pageSize: PER_PAGE, + pageSize: perPage, productId: productId, vulnId: cveId, - ...(scanStateFilter.length > 0 && scanStateFilter[0] && { status: scanStateFilter[0] }), + sortBy: sortByParam, + ...(statusFilterValue && { status: statusFilterValue }), + ...(exploitIqStatusApiValue && { + exploitIqStatus: exploitIqStatusApiValue, + }), + ...(repositorySearchValue && { gitRepo: repositorySearchValue }), }, }), - { deps: [page, productId, cveId, scanStateFilter] } + { + deps: [ + page, + perPage, + productId, + cveId, + sortByParam, + statusFilterValue, + exploitIqStatusApiValue, + repositorySearchValue, + ], + } ); - const handleFilterChange = (filters: string[]) => { + const displayReports = reports || []; + const totalFilteredCount = pagination?.totalElements ?? 0; + + const handleScanStateFilterChange = (filters: string[]) => { setScanStateFilter(filters); setPage(1); }; - const getVulnerabilityStatus = (report: Report) => { - if (!report.vulns || !cveId) return null; - const vuln = report.vulns.find((v) => v.vulnId === cveId); - return vuln?.justification?.status; + const handleExploitIqStatusFilterChange = (filters: string[]) => { + setExploitIqStatusFilter(filters); + setPage(1); + }; + + const handleRepositorySearchChange = (value: string) => { + setRepositorySearchValue(value); + 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 handleSortToggle = (column: SortColumn) => { + if (sortColumn === column) { + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortColumn(column); + setSortDirection("asc"); + } + setPage(1); }; + // Map sort columns to their column indices + const getColumnIndex = (column: SortColumn): number => { + switch (column) { + case "gitRepo": + return 0; + case "completedAt": + return 3; + case "state": + return 4; + default: + return 0; + } + }; + + // Get the current sort index and direction for PatternFly + const activeSortIndex = getColumnIndex(sortColumn); + const activeSortDirection = sortDirection; + const renderExploitIqStatus = (report: Report) => { const status = getVulnerabilityStatus(report); if (!status) return ""; @@ -98,8 +210,86 @@ const RepositoryReportsTable: React.FC = ({ return ""; }; - if (loading) { + const renderAnalysisState = (report: Report) => { + const state = report.state?.toLowerCase(); + + if (state === "completed") { + return ( + + ); + } + + if (state === "expired") { + return ( + + ); + } + + return ( + + ); + }; + + const toolbar = ( + + ); + + if (error) { return ( + <> + {toolbar} + + {getErrorMessage(error)} + + + ); + } + + let content; + if (loading) { + content = ( = ({ ]} /> ); - } - - if (error) { - return ( - - {getErrorMessage(error)} - + } else if (!reports || reports.length === 0) { + content = ( + ); - } - - if (!reports || reports.length === 0) { - return ( - <> - setPage(newPage), - } - : undefined - } - /> - - - No repository reports found - - - {scanStateFilter.length > 0 - ? "No repository reports found matching the selected filters." - : "No repository reports found for this product and CVE combination."} - - - + } else { + content = ( + + + + + + + + + + + + + {displayReports.map((report) => ( + + + + + + + + + ))} + +
handleSortToggle("gitRepo"), + columnIndex: 0, + }} + > + Repository + Commit IDExploitIQ Status handleSortToggle("completedAt"), + columnIndex: 3, + }} + > + Completed + handleSortToggle("state"), + columnIndex: 4, + }} + > + Analysis state + CVE Repository Report
+ {report.gitRepo ? ( + + {report.gitRepo} + + ) : ( + + {report.gitRepo || ""} + + )} + + {report.gitRepo && report.ref ? ( + + {report.ref.substring(0, 7)} + + ) : ( + + {report.ref ? report.ref.substring(0, 7) : ""} + + )} + + {renderExploitIqStatus(report)} + + + {renderAnalysisState(report)} + + + +
); } return ( <> - setPage(newPage), - }} - /> - - - - - - - - - - - - - {reports.map((report) => ( - - - - - - - - - ))} - -
RepositoryCommit IDExploitIQ StatusCompletedAnalysis stateCVE Repository Report
- {report.gitRepo || ""} - - {report.ref || ""} - - {renderExploitIqStatus(report)} - - - - - - - - -
+ {toolbar} + {content} ); }; diff --git a/src/main/webui/src/components/RepositoryTableToolbar.tsx b/src/main/webui/src/components/RepositoryTableToolbar.tsx index 6ce8e7c3..772ee18f 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,172 +6,162 @@ import { ToolbarGroup, ToolbarFilter, ToolbarToggleGroup, - Menu, - MenuContent, - MenuList, - MenuItem, - MenuToggle, - Badge, - Popper, + SearchInput, Pagination, } from "@patternfly/react-core"; import { FilterIcon } from "@patternfly/react-icons"; +import { + AttributeSelector, + CheckboxFilter, + ALL_EXPLOIT_IQ_STATUS_OPTIONS, +} from "./Filtering"; interface RepositoryTableToolbarProps { + repositorySearchValue: string; + onRepositorySearchChange: (value: string) => void; 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; 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; }; } +type ActiveAttribute = "Repository" | "Analysis State" | "ExploitIQ Status"; + const RepositoryTableToolbar: React.FC = ({ + repositorySearchValue, + onRepositorySearchChange, 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("Repository"); - 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([]); + onRepositorySearchChange(""); + 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 = ( -
- -
+ const repositorySearchInput = ( + onRepositorySearchChange(value)} + onClear={() => onRepositorySearchChange("")} + /> ); return ( 0 ? handleFilterDeleteGroup : undefined + repositorySearchValue !== "" || + scanStateFilter.length > 0 || + exploitIqStatusFilter.length > 0 + ? handleFilterDeleteGroup + : undefined } > } breakpoint="xl"> + + + setActiveAttribute(attr as ActiveAttribute) + } + /> + + onRepositorySearchChange("")} + deleteLabelGroup={() => onRepositorySearchChange("")} + categoryName="Repository" + showToolbarItem={activeAttribute === "Repository"} + > + {repositorySearchInput} + + + + - {scanStateSelect} + @@ -183,8 +173,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 }, + ]} /> @@ -195,4 +190,3 @@ const RepositoryTableToolbar: React.FC = ({ }; export default RepositoryTableToolbar; - diff --git a/src/main/webui/src/components/TableEmptyState.tsx b/src/main/webui/src/components/TableEmptyState.tsx new file mode 100644 index 00000000..79d3b00e --- /dev/null +++ b/src/main/webui/src/components/TableEmptyState.tsx @@ -0,0 +1,45 @@ +import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; +import { + Bullseye, + EmptyState, + EmptyStateVariant, +} from "@patternfly/react-core"; +import { SearchIcon } from "@patternfly/react-icons"; + +interface TableEmptyStateProps { + columnCount: number; + titleText?: string; +} + +const TableEmptyState: React.FC = ({ + columnCount, + titleText = "No results found", +}) => { + return ( + + + + {Array.from({ length: columnCount }).map((_, index) => ( + + + + + + + +
+ ))} +
+ + + +
+ ); +}; + +export default TableEmptyState; 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); 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", + }, + }, }; diff --git a/src/main/webui/src/pages/ReportPage.tsx b/src/main/webui/src/pages/ReportPage.tsx index fbd0a227..5506092b 100644 --- a/src/main/webui/src/pages/ReportPage.tsx +++ b/src/main/webui/src/pages/ReportPage.tsx @@ -9,8 +9,11 @@ import { BreadcrumbItem, Title, Label, + Icon, + Flex, + FlexItem, } 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 +55,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 +63,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 ( -