From c660471b2e01fa98440a8f843ce4fd6fe29cbbd6 Mon Sep 17 00:00:00 2001 From: Shayan Malinda Date: Thu, 23 Apr 2026 18:35:23 +0530 Subject: [PATCH 1/4] [Customer Portal] Improvements in the Security Center --- .../backend/modules/entity/types.bal | 16 +- .../backend/modules/types/types.bal | 12 + apps/customer-portal/backend/openapi.yaml | 12 + apps/customer-portal/backend/utils.bal | 6 + .../ProductVulnerabilitiesFilters.tsx | 144 ++++-- .../components/ProductVulnerabilitiesList.tsx | 46 +- .../ProductVulnerabilitiesTable.tsx | 53 ++ .../ProductVulnerabilitiesTableSkeleton.tsx | 30 +- .../VulnerabilityDetailsContent.tsx | 474 +++++++----------- .../security/constants/securityConstants.ts | 22 +- .../src/features/security/types/security.ts | 7 + .../utils/productVulnerabilitiesTable.ts | 4 +- 12 files changed, 485 insertions(+), 341 deletions(-) diff --git a/apps/customer-portal/backend/modules/entity/types.bal b/apps/customer-portal/backend/modules/entity/types.bal index 28342fd75c..3a438c7ad0 100644 --- a/apps/customer-portal/backend/modules/entity/types.bal +++ b/apps/customer-portal/backend/modules/entity/types.bal @@ -1407,6 +1407,10 @@ public type ProductVulnerabilitySearchPayload record {| int statusId?; # Severity ID int severityId?; + # Product name filter + string productName?; + # Product version filter + string productVersion?; } filters?; # Sort configuration SortBy sortBy?; // TODO: Check the correct sort by fields for vulnerabilities @@ -1424,12 +1428,20 @@ public type ProductVulnerability record {| string vulnerabilityId; # Severity level ChoiceListItem severity; + # Name of the product + string productName?; + # Version of the product + string productVersion?; # Name of the component string componentName; # Version of the component string version; # Type string 'type; + # Type of the component + string componentType?; + # Update level for the vulnerability + string updateLevel?; # Use case description string? useCase; # Justification for the vulnerability @@ -1442,10 +1454,6 @@ public type ProductVulnerability record {| # Product vulnerability information. public type ProductVulnerabilityResponse record {| *ProductVulnerability; - # Type of the component - string componentType?; - # Update level for the vulnerability - string updateLevel; json...; |}; diff --git a/apps/customer-portal/backend/modules/types/types.bal b/apps/customer-portal/backend/modules/types/types.bal index 86f5007db0..bb3e47402c 100644 --- a/apps/customer-portal/backend/modules/types/types.bal +++ b/apps/customer-portal/backend/modules/types/types.bal @@ -814,6 +814,10 @@ public type ProductVulnerabilitySearchFilters record {| int statusId?; # Severity ID int severityId?; + # Product name filter + string productName?; + # Product version filter + string productVersion?; |}; # Base product vulnerability. @@ -826,12 +830,20 @@ public type ProductVulnerability record {| string vulnerabilityId; # Severity level ReferenceItem severity; + # Name of the product + string productName?; + # Version of the product + string productVersion?; # Name of the component string componentName; # Version of the component string version; # Type string 'type; + # Type of the component + string componentType?; + # Update level for the vulnerability + string updateLevel?; # Use case description string? useCase; # Justification diff --git a/apps/customer-portal/backend/openapi.yaml b/apps/customer-portal/backend/openapi.yaml index fdc79e00ab..ac29ed69ff 100644 --- a/apps/customer-portal/backend/openapi.yaml +++ b/apps/customer-portal/backend/openapi.yaml @@ -4636,9 +4636,21 @@ components: description: Vulnerability identifier severity: $ref: '#/components/schemas/ReferenceItem' + productName: + type: string + description: Name of the product + productVersion: + type: string + description: Version of the product componentName: type: string description: Name of the component + componentType: + type: string + description: Type of the component + updateLevel: + type: string + description: Update level for the vulnerability version: type: string description: Version of the component diff --git a/apps/customer-portal/backend/utils.bal b/apps/customer-portal/backend/utils.bal index e2014a7676..a49bdbca97 100644 --- a/apps/customer-portal/backend/utils.bal +++ b/apps/customer-portal/backend/utils.bal @@ -374,9 +374,13 @@ public isolated function mapProductVulnerabilitySearchResponse(entity:ProductVul cveId: vulnerability.cveId, vulnerabilityId: vulnerability.vulnerabilityId, severity: {id: vulnerability.severity.id.toString(), label: vulnerability.severity.label}, + productName: vulnerability.productName, + productVersion: vulnerability.productVersion, componentName: vulnerability.componentName, version: vulnerability.version, 'type: vulnerability.'type, + componentType: vulnerability.componentType, + updateLevel: vulnerability.updateLevel, useCase: vulnerability.useCase, justification: vulnerability.justification, resolution: vulnerability.resolution @@ -401,6 +405,8 @@ public isolated function mapProductVulnerabilityResponse(entity:ProductVulnerabi cveId: response.cveId, vulnerabilityId: response.vulnerabilityId, severity: {id: response.severity.id.toString(), label: response.severity.label}, + productName: response.productName, + productVersion: response.productVersion, componentName: response.componentName, version: response.version, 'type: response.'type, diff --git a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesFilters.tsx b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesFilters.tsx index 782b532b5d..bf6a2f78ad 100644 --- a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesFilters.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesFilters.tsx @@ -15,8 +15,9 @@ // under the License. import { + Box, + Button, FormControl, - Grid, InputLabel, MenuItem, Select, @@ -25,54 +26,133 @@ import { import type { JSX } from "react"; import type { SelectChangeEvent } from "@wso2/oxygen-ui"; import { + PRODUCT_VULNERABILITIES_ALL_PRODUCTS_LABEL, + PRODUCT_VULNERABILITIES_ALL_VERSIONS_LABEL, + PRODUCT_VULNERABILITIES_CLEAR_FILTERS_LABEL, + PRODUCT_VULNERABILITIES_PRODUCT_LABEL, + PRODUCT_VULNERABILITIES_PRODUCT_VERSION_LABEL, PRODUCT_VULNERABILITIES_SEVERITY_ALL_LABEL, PRODUCT_VULNERABILITIES_SEVERITY_LABEL, } from "@features/security/constants/securityConstants"; import type { ProductVulnerabilitiesFiltersProps } from "@features/security/types/security"; /** - * ProductVulnerabilitiesFilters component to display filter dropdowns. + * ProductVulnerabilitiesFilters component to display filter dropdowns for + * severity, product name, and product version. * - * @param {ProductVulnerabilitiesFiltersProps} props - Filter values and change handler. + * @param {ProductVulnerabilitiesFiltersProps} props - Filter values and change handlers. * @returns {JSX.Element} The rendered filter dropdowns. */ export default function ProductVulnerabilitiesFilters({ filters, severityOptions = [], + productOptions = [], + productVersionOptions = [], onFilterChange, + onClearFilters, }: ProductVulnerabilitiesFiltersProps): JSX.Element { - const handleSelectChange = (event: SelectChangeEvent) => { - const val = event.target.value; - onFilterChange("severityId", val); + const handleSeverityChange = (event: SelectChangeEvent) => { + onFilterChange("severityId", event.target.value); + }; + + const handleProductChange = (event: SelectChangeEvent) => { + // Changing product resets version + onFilterChange("productVersion", ""); + onFilterChange("productName", event.target.value); + }; + + const handleVersionChange = (event: SelectChangeEvent) => { + onFilterChange("productVersion", event.target.value); }; return ( - - - - - {PRODUCT_VULNERABILITIES_SEVERITY_LABEL} - - + + + {PRODUCT_VULNERABILITIES_SEVERITY_ALL_LABEL} + + + {severityOptions.map((option) => ( + + {option.label} - {severityOptions.map((option) => ( - - {option.label} - - ))} - - - - + ))} + + + + {/* Product */} + + + {PRODUCT_VULNERABILITIES_PRODUCT_LABEL} + + + + + {/* Product Version */} + + + {PRODUCT_VULNERABILITIES_PRODUCT_VERSION_LABEL} + + + + + {/* Clear Filters */} + + ); } diff --git a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx index cde9a01664..03d3f731c2 100644 --- a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx @@ -59,12 +59,18 @@ const ProductVulnerabilitiesList = ({ - CVE ID + CVE Vulnerability ID Severity + Product Name + Product Version Component Name - Version - Type + Component Version + Component Type + WSO2 Resolution + Usecase + Justification + Resolution @@ -135,6 +141,16 @@ const ProductVulnerabilitiesList = ({ }} /> + + + {row.productName ?? "--"} + + + + + {row.productVersion ?? "--"} + + - + + {row.componentType ?? "--"} + + + + + {row.updateLevel ?? "--"} + + + + + {row.useCase ?? "--"} + + + + + {row.justification ?? "--"} + + + + + {row.resolution ?? "--"} + ); diff --git a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx index 24f7fa59d5..3a0cca6bb3 100644 --- a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx @@ -24,6 +24,7 @@ import ProductVulnerabilitiesFilters from "@features/security/components/Product import ProductVulnerabilitiesList from "@features/security/components/ProductVulnerabilitiesList"; import { PRODUCT_VULNERABILITIES_DEFAULT_ROWS_PER_PAGE, + PRODUCT_VULNERABILITIES_OPTIONS_FETCH_LIMIT, PRODUCT_VULNERABILITIES_SEARCH_DEBOUNCE_MS, } from "@features/security/constants/securityConstants"; import type { ProductVulnerabilitiesTableProps } from "@features/security/types/security"; @@ -31,6 +32,12 @@ import { countProductVulnerabilityTableActiveFilters, } from "@features/security/utils/productVulnerabilitiesTable"; +// Stable request object for fetching all vulnerabilities to populate dropdown options. +// Kept outside the component to avoid creating a new object reference on every render. +const OPTIONS_FETCH_REQUEST = { + pagination: { offset: 0, limit: PRODUCT_VULNERABILITIES_OPTIONS_FETCH_LIMIT }, +}; + /** * Product Vulnerabilities table using the same structure as the dashboard outstanding cases table. * Fetches data via POST /products/vulnerabilities/search. @@ -53,6 +60,7 @@ const ProductVulnerabilitiesTable = ({ PRODUCT_VULNERABILITIES_DEFAULT_ROWS_PER_PAGE, ); + // --- Metadata for severity options --- const { data: metaData } = useGetVulnerabilitiesMetaData(); const severityOptions = useMemo( () => @@ -60,6 +68,46 @@ const ProductVulnerabilitiesTable = ({ [metaData?.severities], ); + // --- All-records fetch for product name / version dropdown options --- + // Uses the stable OPTIONS_FETCH_REQUEST constant to avoid re-fetching on every render. + const { data: allVulnerabilitiesData } = usePostProductVulnerabilitiesSearch( + OPTIONS_FETCH_REQUEST, + ); + + // Combine the bulk-fetch records with the current page so that options are + // immediately available from the first paginated response, even while the + // larger OPTIONS query is still in-flight. + const allKnownVulnerabilities = useMemo(() => { + const seen = new Map(); + allVulnerabilitiesData?.productVulnerabilities.forEach((v) => seen.set(v.id, v)); + data?.productVulnerabilities.forEach((v) => seen.set(v.id, v)); + return Array.from(seen.values()); + }, [allVulnerabilitiesData, data]); + + const productOptions = useMemo(() => { + const names = new Set(); + allKnownVulnerabilities.forEach((v) => { + if (v.productName) names.add(v.productName); + }); + return Array.from(names) + .sort() + .map((name) => ({ value: name, label: name })); + }, [allKnownVulnerabilities]); + + const productVersionOptions = useMemo(() => { + if (!filters.productName) return []; + const versions = new Set(); + allKnownVulnerabilities + .filter((v) => v.productName === filters.productName) + .forEach((v) => { + if (v.productVersion) versions.add(v.productVersion); + }); + return Array.from(versions) + .sort() + .map((v) => ({ value: v, label: v })); + }, [allKnownVulnerabilities, filters.productName]); + + // --- Main paginated search request --- const searchRequest = useMemo( () => ({ filters: { @@ -67,6 +115,8 @@ const ProductVulnerabilitiesTable = ({ | string | undefined, severityId: filters.severityId ? Number(filters.severityId) : undefined, + productName: (filters.productName as string) || undefined, + productVersion: (filters.productVersion as string) || undefined, }, pagination: { offset: page * rowsPerPage, @@ -152,7 +202,10 @@ const ProductVulnerabilitiesTable = ({ diff --git a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTableSkeleton.tsx b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTableSkeleton.tsx index dbc882369b..9e296c315f 100644 --- a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTableSkeleton.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTableSkeleton.tsx @@ -33,24 +33,18 @@ const ProductVulnerabilitiesTableSkeleton = ({ <> {Array.from({ length: rowsPerPage }).map((_, index) => ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + ))} diff --git a/apps/customer-portal/webapp/src/features/security/components/VulnerabilityDetailsContent.tsx b/apps/customer-portal/webapp/src/features/security/components/VulnerabilityDetailsContent.tsx index 48475445f0..de2f0d19b0 100644 --- a/apps/customer-portal/webapp/src/features/security/components/VulnerabilityDetailsContent.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/VulnerabilityDetailsContent.tsx @@ -20,6 +20,7 @@ import { Card, CardContent, Chip, + Divider, IconButton, Paper, Stack, @@ -27,15 +28,12 @@ import { } from "@wso2/oxygen-ui"; import { ArrowLeft, - Shield, - Package, - Code, ExternalLink, + Shield, } from "@wso2/oxygen-ui-icons-react"; import { type JSX } from "react"; import { VULNERABILITY_DETAILS_COMPONENT_NAME_LABEL, - VULNERABILITY_DETAILS_COMPONENT_SECTION_TITLE, VULNERABILITY_DETAILS_COMPONENT_TYPE_LABEL, VULNERABILITY_DETAILS_COMPONENT_VERSION_LABEL, VULNERABILITY_DETAILS_HEADER_SUBTITLE, @@ -43,13 +41,14 @@ import { VULNERABILITY_DETAILS_LOAD_ERROR_CAPTION, VULNERABILITY_DETAILS_LOAD_ERROR_TITLE, VULNERABILITY_DETAILS_NOT_APPLICABLE_LABEL, - VULNERABILITY_DETAILS_NO_RESOLUTION_MESSAGE, VULNERABILITY_DETAILS_NVD_BASE_URL, VULNERABILITY_DETAILS_NVD_BUTTON_LABEL, + VULNERABILITY_DETAILS_PRODUCT_NAME_LABEL, + VULNERABILITY_DETAILS_PRODUCT_VERSION_LABEL, VULNERABILITY_DETAILS_RESOLUTION_LABEL, - VULNERABILITY_DETAILS_RESOLUTION_SECTION_TITLE, VULNERABILITY_DETAILS_UPDATE_LEVEL_LABEL, VULNERABILITY_DETAILS_USE_CASE_LABEL, + VULNERABILITY_DETAILS_WSO2_RESOLUTION_LABEL, } from "@features/security/constants/securityConstants"; import type { VulnerabilityDetailsContentProps } from "@features/security/types/security"; import { @@ -59,6 +58,46 @@ import { import ErrorIndicator from "@components/error-indicator/ErrorIndicator"; import VulnerabilityDetailsSkeleton from "@features/security/components/VulnerabilityDetailsSkeleton"; +/** Renders a labelled read-only field matching the form style in the image. */ +function FieldBox({ + label, + value, + multiline = false, +}: { + label: string; + value: string; + multiline?: boolean; +}): JSX.Element { + return ( + + + {label} + + + + {value} + + + + ); +} + /** * VulnerabilityDetailsContent displays vulnerability header and detail cards. * @@ -229,296 +268,169 @@ export default function VulnerabilityDetailsContent({ pt: 2, }} > - - {/* Main details card */} - - - - - - - - {cveId} - - - {statusLabel && ( - - )} - - - {vulnerabilityId} - - - {data.cveId && ( - - )} - - - - + + + - {/* Component Information card */} - - - - - - - - + + Product Vulnerabilities + + {data.cveId && ( + + )} + + + + + {/* Product Name | Product Version */} + + + + - + + + + + {/* Severity */} + + - - - {VULNERABILITY_DETAILS_COMPONENT_NAME_LABEL} - - - {data.componentName || "--"} - - - - - {VULNERABILITY_DETAILS_COMPONENT_VERSION_LABEL} - - - {data.version || "--"} - - - - - {VULNERABILITY_DETAILS_COMPONENT_TYPE_LABEL} - + Severity + + + + {statusLabel && ( - - {data.updateLevel && ( - - - {VULNERABILITY_DETAILS_UPDATE_LEVEL_LABEL} - - - {data.updateLevel} - - )} - - - + - {/* Resolution & Mitigation card */} - - - - - - - - - {VULNERABILITY_DETAILS_RESOLUTION_SECTION_TITLE} - - + {/* Component Name | Component Type */} + + + + - - - - {VULNERABILITY_DETAILS_USE_CASE_LABEL} - - - {data.useCase ?? VULNERABILITY_DETAILS_NOT_APPLICABLE_LABEL} - - - - - {VULNERABILITY_DETAILS_COMPONENT_TYPE_LABEL} - - - - + {/* Component Version | Update Level */} + + + + - {data.justification && ( - - - {VULNERABILITY_DETAILS_JUSTIFICATION_LABEL} - - - - {data.justification} - - - - )} + {/* WSO2 Resolution (full width) */} + - {data.resolution && ( - - - {VULNERABILITY_DETAILS_RESOLUTION_LABEL} - - - - {data.resolution} - - - - )} + {/* Justification (full width, multi-line) */} + - {!data.justification && !data.resolution && ( - - {VULNERABILITY_DETAILS_NO_RESOLUTION_MESSAGE} - - )} - - - - + {/* Usecase (full width, multi-line) */} + + + {/* Resolution (full width, multi-line) */} + + + + + )} diff --git a/apps/customer-portal/webapp/src/features/security/constants/securityConstants.ts b/apps/customer-portal/webapp/src/features/security/constants/securityConstants.ts index e8efc23e10..2c394e1df1 100644 --- a/apps/customer-portal/webapp/src/features/security/constants/securityConstants.ts +++ b/apps/customer-portal/webapp/src/features/security/constants/securityConstants.ts @@ -72,7 +72,7 @@ export const PRODUCT_VULNERABILITIES_SEARCH_DEBOUNCE_MS = 350; export const PRODUCT_VULNERABILITIES_DEFAULT_ROWS_PER_PAGE = 10; -export const PRODUCT_VULNERABILITIES_TABLE_COLUMN_COUNT = 6; +export const PRODUCT_VULNERABILITIES_TABLE_COLUMN_COUNT = 10; export const PRODUCT_VULNERABILITIES_TABLE_PAGINATION_OPTIONS = [5, 10, 25, 50] as const; @@ -96,6 +96,20 @@ export const PRODUCT_VULNERABILITIES_SEVERITY_LABEL = "Severity"; export const PRODUCT_VULNERABILITIES_SEVERITY_ALL_LABEL = "All Severity"; +export const PRODUCT_VULNERABILITIES_PRODUCT_LABEL = "Product"; + +export const PRODUCT_VULNERABILITIES_PRODUCT_PLACEHOLDER = "Select a Product"; + +export const PRODUCT_VULNERABILITIES_PRODUCT_VERSION_LABEL = "Product Version"; + +export const PRODUCT_VULNERABILITIES_PRODUCT_VERSION_PLACEHOLDER = "Select a Product Version"; + +export const PRODUCT_VULNERABILITIES_ALL_PRODUCTS_LABEL = "All Products"; + +export const PRODUCT_VULNERABILITIES_ALL_VERSIONS_LABEL = "All Versions"; + +export const PRODUCT_VULNERABILITIES_OPTIONS_FETCH_LIMIT = 500; + export const SECURITY_REPORT_ANALYSIS_PAGE_SIZE = 10; export const SECURITY_REPORT_ANALYSIS_TITLE = "Security Report Analysis"; @@ -138,6 +152,12 @@ export const VULNERABILITY_DETAILS_NVD_BUTTON_LABEL = "NVD"; export const VULNERABILITY_DETAILS_HEADER_SUBTITLE = "Vulnerability details"; +export const VULNERABILITY_DETAILS_PRODUCT_NAME_LABEL = "Product Name"; + +export const VULNERABILITY_DETAILS_PRODUCT_VERSION_LABEL = "Product Version"; + +export const VULNERABILITY_DETAILS_WSO2_RESOLUTION_LABEL = "WSO2 Resolution"; + export const VULNERABILITY_DETAILS_COMPONENT_SECTION_TITLE = "Component Information"; export const VULNERABILITY_DETAILS_RESOLUTION_SECTION_TITLE = diff --git a/apps/customer-portal/webapp/src/features/security/types/security.ts b/apps/customer-portal/webapp/src/features/security/types/security.ts index e1a1eeab4f..4bf16db3a3 100644 --- a/apps/customer-portal/webapp/src/features/security/types/security.ts +++ b/apps/customer-portal/webapp/src/features/security/types/security.ts @@ -80,6 +80,8 @@ export type ProductVulnerability = { cveId: string; vulnerabilityId: string; severity: MetadataItem; + productName?: string | null; + productVersion?: string | null; componentName: string; version: string; type: string; @@ -100,7 +102,10 @@ export type ProductVulnerabilitiesTableProps = { export type ProductVulnerabilitiesFiltersProps = { filters: Record; severityOptions?: VulnerabilitySelectOption[]; + productOptions?: VulnerabilitySelectOption[]; + productVersionOptions?: VulnerabilitySelectOption[]; onFilterChange: (field: string, value: string | number) => void; + onClearFilters: () => void; }; export type ProductVulnerabilitiesListData = { @@ -149,6 +154,8 @@ export type ProductVulnerabilitiesSearchFilters = { searchQuery?: string; severityId?: number; statusId?: number; + productName?: string; + productVersion?: string; }; // Request type for searching product vulnerabilities. diff --git a/apps/customer-portal/webapp/src/features/security/utils/productVulnerabilitiesTable.ts b/apps/customer-portal/webapp/src/features/security/utils/productVulnerabilitiesTable.ts index 36e72b4901..c2f42e8080 100644 --- a/apps/customer-portal/webapp/src/features/security/utils/productVulnerabilitiesTable.ts +++ b/apps/customer-portal/webapp/src/features/security/utils/productVulnerabilitiesTable.ts @@ -31,7 +31,9 @@ export function countProductVulnerabilityTableActiveFilters( ): number { const searchCount = searchInput.trim() ? 1 : 0; const severityCount = filters.severityId ? 1 : 0; - return searchCount + severityCount; + const productCount = filters.productName ? 1 : 0; + const productVersionCount = filters.productVersion ? 1 : 0; + return searchCount + severityCount + productCount + productVersionCount; } /** From b50112fbcec39bb565ccac0665d1667e8f9b8ee2 Mon Sep 17 00:00:00 2001 From: Shayan Malinda Date: Thu, 23 Apr 2026 18:38:35 +0530 Subject: [PATCH 2/4] Fixes in search --- .../ProductVulnerabilitiesTable.tsx | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx index 3a0cca6bb3..60f31182d3 100644 --- a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx @@ -74,6 +74,28 @@ const ProductVulnerabilitiesTable = ({ OPTIONS_FETCH_REQUEST, ); + // --- Main paginated search request --- + const searchRequest = useMemo( + () => ({ + filters: { + searchQuery: (debouncedSearch?.trim() || undefined) as + | string + | undefined, + severityId: filters.severityId ? Number(filters.severityId) : undefined, + productName: (filters.productName as string) || undefined, + productVersion: (filters.productVersion as string) || undefined, + }, + pagination: { + offset: page * rowsPerPage, + limit: rowsPerPage, + }, + }), + [debouncedSearch, filters, page, rowsPerPage], + ); + + const { data, isLoading, isError } = + usePostProductVulnerabilitiesSearch(searchRequest); + // Combine the bulk-fetch records with the current page so that options are // immediately available from the first paginated response, even while the // larger OPTIONS query is still in-flight. @@ -107,28 +129,6 @@ const ProductVulnerabilitiesTable = ({ .map((v) => ({ value: v, label: v })); }, [allKnownVulnerabilities, filters.productName]); - // --- Main paginated search request --- - const searchRequest = useMemo( - () => ({ - filters: { - searchQuery: (debouncedSearch?.trim() || undefined) as - | string - | undefined, - severityId: filters.severityId ? Number(filters.severityId) : undefined, - productName: (filters.productName as string) || undefined, - productVersion: (filters.productVersion as string) || undefined, - }, - pagination: { - offset: page * rowsPerPage, - limit: rowsPerPage, - }, - }), - [debouncedSearch, filters, page, rowsPerPage], - ); - - const { data, isLoading, isError } = - usePostProductVulnerabilitiesSearch(searchRequest); - useEffect(() => { if (data?.totalRecords !== undefined) { onTotalRecordsChange?.(data.totalRecords); From 485c0b161cbc99b3d28fbbd2ffecff2dca145e2b Mon Sep 17 00:00:00 2001 From: Shayan Malinda Date: Thu, 23 Apr 2026 23:50:45 +0530 Subject: [PATCH 3/4] Add product/version filters and client-side pagination to Component Analysis - Add Product Name and Product Version filter dropdowns to the Component Analysis table; filter state resets version when product changes - BFF transparently batches entity service calls (50/batch) when limit > 50, allowing the frontend to fetch all ~1500 records in a single request - All filtering, pagination, and sorting now done client-side for instant UX - 12-hour in-memory BFF cache for bulk vulnerability fetches (shared across users; keyed by server-side filter params) - Remove maxValue: 50 constraint on Pagination.limit in entity types so the BFF can accept large-limit requests from the frontend - Fix truncation in Use Case, Justification, and Resolution table columns - Fix WSO2 Resolution field copy-paste bug in VulnerabilityDetailsContent - Extract hardcoded strings to constants throughout the security feature - Add productName/productVersion to search filter types (backend + frontend) Co-Authored-By: Claude Sonnet 4.6 --- .../customer-portal/backend/Dependencies.toml | 4 + .../backend/modules/entity/types.bal | 3 +- .../backend/modules/types/types.bal | 4 - apps/customer-portal/backend/openapi.yaml | 14 +- apps/customer-portal/backend/service.bal | 126 ++++++++++++++ .../components/ProductVulnerabilitiesList.tsx | 31 +++- .../ProductVulnerabilitiesTable.tsx | 155 ++++++++++-------- .../VulnerabilityDetailsContent.tsx | 14 +- .../security/constants/securityConstants.ts | 12 +- .../src/features/security/types/security.ts | 4 +- .../utils/productVulnerabilitiesTable.ts | 2 +- 11 files changed, 268 insertions(+), 101 deletions(-) diff --git a/apps/customer-portal/backend/Dependencies.toml b/apps/customer-portal/backend/Dependencies.toml index f6b9c3eeaf..01550bfbc1 100644 --- a/apps/customer-portal/backend/Dependencies.toml +++ b/apps/customer-portal/backend/Dependencies.toml @@ -29,6 +29,9 @@ dependencies = [ {org = "ballerina", name = "task"}, {org = "ballerina", name = "time"} ] +modules = [ + {org = "ballerina", packageName = "cache", moduleName = "cache"} +] [[package]] org = "ballerina" @@ -347,6 +350,7 @@ org = "wso2" name = "customer_portal" version = "1.0.0-rc.3" dependencies = [ + {org = "ballerina", name = "cache"}, {org = "ballerina", name = "constraint"}, {org = "ballerina", name = "http"}, {org = "ballerina", name = "jwt"}, diff --git a/apps/customer-portal/backend/modules/entity/types.bal b/apps/customer-portal/backend/modules/entity/types.bal index 3a438c7ad0..51c3122817 100644 --- a/apps/customer-portal/backend/modules/entity/types.bal +++ b/apps/customer-portal/backend/modules/entity/types.bal @@ -42,8 +42,7 @@ public type Pagination record {| int offset = DEFAULT_OFFSET; # Limit for pagination @constraint:Int { - minValue: 1, - maxValue: 50 + minValue: 1 } int 'limit = DEFAULT_LIMIT; json...; diff --git a/apps/customer-portal/backend/modules/types/types.bal b/apps/customer-portal/backend/modules/types/types.bal index bb3e47402c..d448705a4c 100644 --- a/apps/customer-portal/backend/modules/types/types.bal +++ b/apps/customer-portal/backend/modules/types/types.bal @@ -856,10 +856,6 @@ public type ProductVulnerability record {| # Product vulnerability information. public type ProductVulnerabilityResponse record {| *ProductVulnerability; - # Type of the component - string componentType?; - # Update level for the vulnerability - string updateLevel?; |}; # Product vulnerabilities response with pagination. diff --git a/apps/customer-portal/backend/openapi.yaml b/apps/customer-portal/backend/openapi.yaml index ac29ed69ff..66b0ce877b 100644 --- a/apps/customer-portal/backend/openapi.yaml +++ b/apps/customer-portal/backend/openapi.yaml @@ -4686,14 +4686,6 @@ components: description: Product vulnerability information. allOf: - $ref: '#/components/schemas/ProductVulnerability' - - type: object - properties: - componentType: - type: string - description: Type of the component - updateLevel: - type: string - description: Update level for the vulnerability ProductVulnerabilitySearchPayload: type: object properties: @@ -4712,6 +4704,12 @@ components: type: integer description: Severity ID format: int64 + productName: + type: string + description: Product name + productVersion: + type: string + description: Product version description: Filter criteria sortBy: $ref: '#/components/schemas/SortBy' diff --git a/apps/customer-portal/backend/service.bal b/apps/customer-portal/backend/service.bal index 4cedc88a1d..484db350cf 100644 --- a/apps/customer-portal/backend/service.bal +++ b/apps/customer-portal/backend/service.bal @@ -24,6 +24,7 @@ import customer_portal.types; import customer_portal.updates; import customer_portal.user_management; +import ballerina/cache; import ballerina/http; import ballerina/log; import ballerina/time; @@ -52,6 +53,15 @@ service class ErrorInterceptor { } } +// In-memory cache for bulk product-vulnerability fetches (limit > 50). +// The dataset is org-wide — identical for every authenticated user — so a single +// shared cache is safe. TTL: 12 hours. +final cache:Cache productVulnerabilityCache = new ({ + capacity: 20, + evictionFactor: 0.2, + defaultMaxAge: 12 * 60 * 60 +}); + configurable int wsPort = 9091; http:ListenerConfiguration listenerConf = { @@ -2971,6 +2981,122 @@ service http:InterceptableService / on new http:Listener(9090, listenerConf) { }; } + int reqLimit = payload.pagination?.'limit ?: 10; + + // The entity service has a hard limit of 50 records per request. + // When the caller requests more than 50 (e.g. a "fetch all" call from the frontend), + // we transparently batch through the entity service and aggregate the results here. + if reqLimit > 50 { + int reqOffset = payload.pagination?.offset ?: 0; + + // Cache key is derived from the server-side filter fields so that different + // filter combinations get independent cache entries. + string cacheKey = string `sq=${payload.filters?.searchQuery ?: ""},` + + string `sv=${payload.filters?.severityId ?: ""},` + + string `st=${payload.filters?.statusId ?: ""}`; + + // Return cached result if available (TTL: 12 hours). + any|cache:Error cachedEntry = productVulnerabilityCache.get(cacheKey); + if cachedEntry is types:ProductVulnerability[] { + log:printDebug(string `[vulnerabilities] Cache hit for key: ${cacheKey}`); + return { + body: { + productVulnerabilities: cachedEntry, + totalRecords: cachedEntry.length(), + 'limit: reqLimit, + offset: reqOffset + } + }; + } + + // First call with limit=1 to get the total number of available records. + entity:ProductVulnerabilitySearchPayload countPayload = { + filters: payload.filters, + sortBy: payload.sortBy, + pagination: {offset: 0, 'limit: 1} + }; + + entity:ProductVulnerabilitySearchResponse|error countResponse = + entity:searchProductVulnerabilities(userInfo.idToken, countPayload); + if countResponse is error { + if getStatusCode(countResponse) == http:STATUS_FORBIDDEN { + log:printWarn(string `Access to product vulnerabilities information is forbidden for user: ${ + userInfo.userId}`); + return { + body: { + message: "Access to product vulnerabilities information is forbidden for the user!" + } + }; + } + string customError = "Failed to search product vulnerabilities."; + log:printError(customError, countResponse); + return {body: {message: customError}}; + } + + int totalFromEntity = countResponse.totalRecords; + if totalFromEntity == 0 { + return { + body: { + productVulnerabilities: [], + totalRecords: 0, + 'limit: reqLimit, + offset: reqOffset + } + }; + } + + // Fetch all records in batches of 50. + int batchSize = 50; + int batchCount = (totalFromEntity + batchSize - 1) / batchSize; + types:ProductVulnerability[] allVulnerabilities = []; + + foreach int batchIndex in 0 ..< batchCount { + entity:ProductVulnerabilitySearchPayload fetchPayload = { + filters: payload.filters, + sortBy: payload.sortBy, + pagination: {offset: batchIndex * batchSize, 'limit: batchSize} + }; + + entity:ProductVulnerabilitySearchResponse|error batchResponse = + entity:searchProductVulnerabilities(userInfo.idToken, fetchPayload); + if batchResponse is error { + if getStatusCode(batchResponse) == http:STATUS_FORBIDDEN { + log:printWarn(string `Access to product vulnerabilities information is forbidden for user: ${ + userInfo.userId}`); + return { + body: { + message: "Access to product vulnerabilities information is forbidden for the user!" + } + }; + } + string customError = "Failed to search product vulnerabilities."; + log:printError(customError, batchResponse); + return {body: {message: customError}}; + } + + types:ProductVulnerabilitySearchResponse mappedBatch = + mapProductVulnerabilitySearchResponse(batchResponse); + foreach var v in mappedBatch.productVulnerabilities { + allVulnerabilities.push(v); + } + } + + // Store aggregated results in cache for 12 hours. + cache:Error? putErr = productVulnerabilityCache.put(cacheKey, allVulnerabilities); + if putErr is cache:Error { + log:printWarn("Failed to store product vulnerabilities in cache.", putErr); + } + + return { + body: { + productVulnerabilities: allVulnerabilities, + totalRecords: allVulnerabilities.length(), + 'limit: reqLimit, + offset: reqOffset + } + }; + } + entity:ProductVulnerabilitySearchResponse|error response = entity:searchProductVulnerabilities(userInfo.idToken, payload); if response is error { diff --git a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx index 03d3f731c2..9a32bc2e0b 100644 --- a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx @@ -67,8 +67,8 @@ const ProductVulnerabilitiesList = ({ Component Name Component Version Component Type - WSO2 Resolution - Usecase + Update Level + Use Case Justification Resolution @@ -175,18 +175,33 @@ const ProductVulnerabilitiesList = ({ {row.updateLevel ?? "--"} - - + + {row.useCase ?? "--"} - - + + {row.justification ?? "--"} - - + + {row.resolution ?? "--"} diff --git a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx index 60f31182d3..69b862e135 100644 --- a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx @@ -23,25 +23,31 @@ import ProductVulnerabilitiesTableHeader from "@features/security/components/Pro import ProductVulnerabilitiesFilters from "@features/security/components/ProductVulnerabilitiesFilters"; import ProductVulnerabilitiesList from "@features/security/components/ProductVulnerabilitiesList"; import { + PRODUCT_VULNERABILITIES_ALL_FETCH_LIMIT, PRODUCT_VULNERABILITIES_DEFAULT_ROWS_PER_PAGE, - PRODUCT_VULNERABILITIES_OPTIONS_FETCH_LIMIT, PRODUCT_VULNERABILITIES_SEARCH_DEBOUNCE_MS, } from "@features/security/constants/securityConstants"; -import type { ProductVulnerabilitiesTableProps } from "@features/security/types/security"; -import { - countProductVulnerabilityTableActiveFilters, -} from "@features/security/utils/productVulnerabilitiesTable"; - -// Stable request object for fetching all vulnerabilities to populate dropdown options. -// Kept outside the component to avoid creating a new object reference on every render. -const OPTIONS_FETCH_REQUEST = { - pagination: { offset: 0, limit: PRODUCT_VULNERABILITIES_OPTIONS_FETCH_LIMIT }, +import type { + ProductVulnerabilitiesTableProps, + ProductVulnerability, +} from "@features/security/types/security"; +import { countProductVulnerabilityTableActiveFilters } from "@features/security/utils/productVulnerabilitiesTable"; + +// Stable request object — fetches the full dataset once. +// The BFF transparently paginates through the entity service in batches of 50. +// Kept at module level so the React Query cache key is stable across re-renders. +const FETCH_ALL_REQUEST = { + filters: {}, + pagination: { offset: 0, limit: PRODUCT_VULNERABILITIES_ALL_FETCH_LIMIT }, }; /** - * Product Vulnerabilities table using the same structure as the dashboard outstanding cases table. - * Fetches data via POST /products/vulnerabilities/search. - * @returns {JSX.Element} + * Product Vulnerabilities table. + * + * Architecture note: + * - All ~1500 records are fetched once via a single "fetch all" request. + * - The BFF handles batching to the entity service (which is capped at 50/request). + * - All filtering, sorting, and pagination are performed client-side for instant response. */ const ProductVulnerabilitiesTable = ({ onTotalRecordsChange, @@ -60,7 +66,7 @@ const ProductVulnerabilitiesTable = ({ PRODUCT_VULNERABILITIES_DEFAULT_ROWS_PER_PAGE, ); - // --- Metadata for severity options --- + // ── Severity dropdown metadata ────────────────────────────────────────────── const { data: metaData } = useGetVulnerabilitiesMetaData(); const severityOptions = useMemo( () => @@ -68,58 +74,33 @@ const ProductVulnerabilitiesTable = ({ [metaData?.severities], ); - // --- All-records fetch for product name / version dropdown options --- - // Uses the stable OPTIONS_FETCH_REQUEST constant to avoid re-fetching on every render. - const { data: allVulnerabilitiesData } = usePostProductVulnerabilitiesSearch( - OPTIONS_FETCH_REQUEST, - ); + // ── Full dataset (fetched once, all filtering/pagination done client-side) ─── + const { + data: allData, + isLoading, + isError, + } = usePostProductVulnerabilitiesSearch(FETCH_ALL_REQUEST); - // --- Main paginated search request --- - const searchRequest = useMemo( - () => ({ - filters: { - searchQuery: (debouncedSearch?.trim() || undefined) as - | string - | undefined, - severityId: filters.severityId ? Number(filters.severityId) : undefined, - productName: (filters.productName as string) || undefined, - productVersion: (filters.productVersion as string) || undefined, - }, - pagination: { - offset: page * rowsPerPage, - limit: rowsPerPage, - }, - }), - [debouncedSearch, filters, page, rowsPerPage], + const allVulnerabilities = useMemo( + (): ProductVulnerability[] => allData?.productVulnerabilities ?? [], + [allData], ); - const { data, isLoading, isError } = - usePostProductVulnerabilitiesSearch(searchRequest); - - // Combine the bulk-fetch records with the current page so that options are - // immediately available from the first paginated response, even while the - // larger OPTIONS query is still in-flight. - const allKnownVulnerabilities = useMemo(() => { - const seen = new Map(); - allVulnerabilitiesData?.productVulnerabilities.forEach((v) => seen.set(v.id, v)); - data?.productVulnerabilities.forEach((v) => seen.set(v.id, v)); - return Array.from(seen.values()); - }, [allVulnerabilitiesData, data]); - + // ── Dropdown options ───────────────────────────────────────────────────────── const productOptions = useMemo(() => { const names = new Set(); - allKnownVulnerabilities.forEach((v) => { + allVulnerabilities.forEach((v) => { if (v.productName) names.add(v.productName); }); return Array.from(names) .sort() .map((name) => ({ value: name, label: name })); - }, [allKnownVulnerabilities]); + }, [allVulnerabilities]); const productVersionOptions = useMemo(() => { if (!filters.productName) return []; const versions = new Set(); - allKnownVulnerabilities + allVulnerabilities .filter((v) => v.productName === filters.productName) .forEach((v) => { if (v.productVersion) versions.add(v.productVersion); @@ -127,28 +108,65 @@ const ProductVulnerabilitiesTable = ({ return Array.from(versions) .sort() .map((v) => ({ value: v, label: v })); - }, [allKnownVulnerabilities, filters.productName]); + }, [allVulnerabilities, filters.productName]); - useEffect(() => { - if (data?.totalRecords !== undefined) { - onTotalRecordsChange?.(data.totalRecords); + // ── Client-side filtering ──────────────────────────────────────────────────── + const filteredVulnerabilities = useMemo((): ProductVulnerability[] => { + let items = allVulnerabilities; + + if (filters.severityId) { + items = items.filter( + (v) => String(v.severity?.id) === String(filters.severityId), + ); + } + if (filters.productName) { + items = items.filter( + (v) => v.productName === (filters.productName as string), + ); + } + if (filters.productVersion) { + items = items.filter( + (v) => v.productVersion === (filters.productVersion as string), + ); + } + if (debouncedSearch?.trim()) { + const q = debouncedSearch.trim().toLowerCase(); + items = items.filter( + (v) => + v.cveId?.toLowerCase().includes(q) || + v.componentName?.toLowerCase().includes(q) || + v.vulnerabilityId?.toLowerCase().includes(q), + ); } - }, [data?.totalRecords, onTotalRecordsChange]); - useEffect(() => { - if (isError) onError?.(true); - }, [isError, onError]); + return items; + }, [allVulnerabilities, filters, debouncedSearch]); + // ── Client-side pagination ─────────────────────────────────────────────────── const paginatedData = useMemo(() => { - if (!data) return undefined; + const total = filteredVulnerabilities.length; + const offset = page * rowsPerPage; return { - vulnerabilities: data.productVulnerabilities, - totalRecords: data.totalRecords, + vulnerabilities: filteredVulnerabilities.slice(offset, offset + rowsPerPage), + totalRecords: total, }; - }, [data]); + }, [filteredVulnerabilities, page, rowsPerPage]); + + useEffect(() => { + onTotalRecordsChange?.(paginatedData.totalRecords); + }, [paginatedData.totalRecords, onTotalRecordsChange]); + useEffect(() => { + if (isError) onError?.(true); + }, [isError, onError]); + + // ── Handlers ──────────────────────────────────────────────────────────────── const handleUpdateFilter = (field: string, value: string | number) => { - setFilters((prev) => ({ ...prev, [field]: value })); + setFilters((prev) => ({ + ...prev, + [field]: value, + ...(field === "productName" ? { productVersion: "" } : {}), + })); setPage(0); }; @@ -170,11 +188,11 @@ const ProductVulnerabilitiesTable = ({ }; const activeFilterCount = useMemo( - () => - countProductVulnerabilityTableActiveFilters(searchInput, filters), + () => countProductVulnerabilityTableActiveFilters(searchInput, filters), [filters, searchInput], ); + // ── Render ─────────────────────────────────────────────────────────────────── return ( - {/* Filter dropdowns section */} {isFilterOpen && ( <> @@ -212,7 +229,7 @@ const ProductVulnerabilitiesTable = ({ )} - Product Vulnerabilities + {VULNERABILITY_DETAILS_CARD_TITLE} {data.cveId && (