diff --git a/apps/ocp-plugin/console-extensions.json b/apps/ocp-plugin/console-extensions.json index 8a04fe03d..d81d2157b 100644 --- a/apps/ocp-plugin/console-extensions.json +++ b/apps/ocp-plugin/console-extensions.json @@ -105,6 +105,14 @@ "component": { "$codeRef": "CatalogEditFleetWizard" } } }, + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": ["/edge/security-overview"], + "component": { "$codeRef": "SecurityOverviewPage" } + } + }, { "type": "console.page/route", "properties": { diff --git a/apps/ocp-plugin/package.json b/apps/ocp-plugin/package.json index b43915026..656da1c1d 100644 --- a/apps/ocp-plugin/package.json +++ b/apps/ocp-plugin/package.json @@ -29,6 +29,7 @@ "EnrollmentRequestDetailsPage": "./src/components/EnrollmentRequests/EnrollmentRequestDetailsPage.tsx", "appContext": "./src/components/AppContext/AppContext.tsx", "OverviewTab": "./src/components/OverviewTab/OverviewTab.tsx", + "SecurityOverviewPage": "./src/components/SecurityOverview/SecurityOverviewPage.tsx", "CatalogPage": "./src/components/Catalog/CatalogPage.tsx", "AddCatalogItemWizard": "./src/components/Catalog/AddCatalogItemWizard.tsx", "CatalogInstallWizard": "./src/components/Catalog/CatalogInstallWizard.tsx", diff --git a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx index 4a3ab9664..357d6742f 100644 --- a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx +++ b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx @@ -71,6 +71,7 @@ const appRoutes = { [ROUTE.CATALOG_INSTALL]: '/edge/catalog/install', [ROUTE.CATALOG_FLEET_EDIT]: '/edge/fleets/catalog', [ROUTE.CATALOG_DEVICE_EDIT]: '/edge/devices/catalog', + [ROUTE.SECURITY_OVERVIEW]: '/edge/security-overview', }; export const useValuesAppContext = (): AppContextProps => { diff --git a/apps/ocp-plugin/src/components/SecurityOverview/SecurityOverviewPage.tsx b/apps/ocp-plugin/src/components/SecurityOverview/SecurityOverviewPage.tsx new file mode 100644 index 000000000..8f5cc1d95 --- /dev/null +++ b/apps/ocp-plugin/src/components/SecurityOverview/SecurityOverviewPage.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import SecurityOverviewPage from '@flightctl/ui-components/src/components/SecurityOverview/SecurityOverviewPage'; +import WithPageLayout from '../common/WithPageLayout'; + +const OcpSecurityOverviewPage = () => { + return ( + + + + ); +}; + +export default OcpSecurityOverviewPage; diff --git a/apps/ocp-plugin/src/utils/apiCalls.ts b/apps/ocp-plugin/src/utils/apiCalls.ts index 24768bf83..9bde59fb8 100644 --- a/apps/ocp-plugin/src/utils/apiCalls.ts +++ b/apps/ocp-plugin/src/utils/apiCalls.ts @@ -16,7 +16,7 @@ declare global { } } -type Api = 'flightctl' | 'imagebuilder' | 'alerts' | 'catalog'; +type Api = 'flightctl' | 'imagebuilder' | 'alerts' | 'catalog' | 'vulnerability'; const addRequiredHeaders = (options: RequestInit, api?: Api): RequestInit => { const token = getCSRFToken(); @@ -49,6 +49,7 @@ export const apiProxy = `${uiProxy}/api`; const alertsAPI = `${apiProxy}/alerts`; const imageBuilderPathRegex = /^image(builds|exports)/; const catalogPathRegex = /^(catalogs|catalogitems)/; +const vulnerabilityPathRegex = /^vulnerabilities/; export const wsEndpoint = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${apiServer}`; @@ -65,13 +66,14 @@ const getFullApiUrl = (path: string): { api: Api; url: string } => { if (imageBuilderPathRegex.test(path)) { return { api: 'imagebuilder', url: `${apiProxy}/imagebuilder/api/v1/${path}` }; } + + let apiName: Api = 'flightctl'; if (catalogPathRegex.test(path)) { - return { - api: 'catalog', - url: `${apiProxy}/flightctl/api/v1/${path}`, - }; + apiName = 'catalog'; + } else if (vulnerabilityPathRegex.test(path)) { + apiName = 'vulnerability'; } - return { api: 'flightctl', url: `${apiProxy}/flightctl/api/v1/${path}` }; + return { api: apiName, url: `${apiProxy}/flightctl/api/v1/${path}` }; }; const handleAlertsJSONResponse = async (response: Response): Promise => { @@ -94,7 +96,28 @@ const handleAlertsJSONResponse = async (response: Response): Promise => { throw new Error(await getErrorMsgFromAlertsApiResponse(response)); }; -export const handleApiJSONResponse = async (response: Response): Promise => { +const handleVulnerabilityJSONResponse = async (response: Response): Promise => { + if (response.ok) { + const data = (await response.json()) as R; + return data; + } + + if (response.status === 404) { + throw new Error(`Error ${response.status}: ${response.statusText}`); + } + + // API returns 501 for disabled vulnerabilities API. + if (response.status === 501) { + throw new Error(`${response.status}`); + } + + throw new Error(await getErrorMsgFromApiResponse(response)); +}; + +export const handleApiJSONResponse = async (api: Api, response: Response): Promise => { + if (api === 'vulnerability') { + return handleVulnerabilityJSONResponse(response); + } if (response.ok) { const data = (await response.json()) as R; return data; @@ -125,7 +148,7 @@ const putOrPostData = async ( const options = addRequiredHeaders(baseOptions, api); try { const response = await fetch(url, options); - return handleApiJSONResponse(response); + return handleApiJSONResponse(api, response); } catch (error) { console.error(`Error making ${method} request for ${kind}:`, error); throw error; @@ -148,7 +171,7 @@ export const deleteData = async (kind: string, abortSignal?: AbortSignal): Pr const options = addRequiredHeaders(baseOptions, api); try { const response = await fetch(url, options); - return handleApiJSONResponse(response); + return handleApiJSONResponse(api, response); } catch (error) { console.error('Error making DELETE request:', error); throw error; @@ -169,7 +192,7 @@ export const patchData = async (kind: string, data: PatchRequest, abortSignal const options = addRequiredHeaders(baseOptions, api); try { const response = await fetch(url, options); - return handleApiJSONResponse(response); + return handleApiJSONResponse(api, response); } catch (error) { console.error('Error making PATCH request:', error); throw error; @@ -190,7 +213,7 @@ export const fetchData = async (path: string, abortSignal?: AbortSignal): Pro if (api === 'alerts') { return handleAlertsJSONResponse(response); } - return handleApiJSONResponse(response); + return handleApiJSONResponse(api, response); } catch (error) { console.error('Error making GET request:', error); throw error; diff --git a/apps/standalone/src/app/routes.tsx b/apps/standalone/src/app/routes.tsx index 87bfcd815..7f035a950 100644 --- a/apps/standalone/src/app/routes.tsx +++ b/apps/standalone/src/app/routes.tsx @@ -61,6 +61,9 @@ const FleetDetails = React.lazy( ); const OverviewPage = React.lazy(() => import('@flightctl/ui-components/src/components/OverviewPage/OverviewPage')); +const SecurityOverviewPage = React.lazy( + () => import('@flightctl/ui-components/src/components/SecurityOverview/SecurityOverviewPage'), +); const PendingEnrollmentRequestsBadge = React.lazy( () => import('@flightctl/ui-components/src/components/EnrollmentRequest/PendingEnrollmentRequestsBadge'), ); @@ -172,6 +175,15 @@ const getAppRoutes = (t: TFunction): ExtendedRouteObject[] => [ ), }, + { + path: '/security-overview', + title: t('Security overview'), + element: ( + + + + ), + }, { // Route is only exposed for the standalone app path: '/command-line-tools', diff --git a/apps/standalone/src/app/utils/apiCalls.ts b/apps/standalone/src/app/utils/apiCalls.ts index 7bc865940..1c12c39c7 100644 --- a/apps/standalone/src/app/utils/apiCalls.ts +++ b/apps/standalone/src/app/utils/apiCalls.ts @@ -17,6 +17,7 @@ export const apiProxy = `${uiProxy}/api`; const imageBuilderPathRegex = /^image(builds|exports)/; const catalogPathRegex = /^(catalogs|catalogitems)/; +const vulnerabilityPathRegex = /^vulnerabilities/; export const wsEndpoint = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${apiServer}`; @@ -45,20 +46,23 @@ export const fetchUiProxy = async (endpoint: string, requestInit: RequestInit): return await fetch(`${apiProxy}/${endpoint}`, options); }; -const getFullApiUrl = (path: string): { api: 'flightctl' | 'imagebuilder' | 'alerts' | 'catalog'; url: string } => { +type Api = 'flightctl' | 'imagebuilder' | 'alerts' | 'catalog' | 'vulnerability'; + +const getFullApiUrl = (path: string): { api: Api; url: string } => { if (path.startsWith('alerts')) { return { api: 'alerts', url: `${apiProxy}/alerts/api/v2/${path}` }; } if (imageBuilderPathRegex.test(path)) { return { api: 'imagebuilder', url: `${apiProxy}/imagebuilder/api/v1/${path}` }; } + + let apiName: Api = 'flightctl'; if (catalogPathRegex.test(path)) { - return { - api: 'catalog', - url: `${apiProxy}/flightctl/api/v1/${path}`, - }; + apiName = 'catalog'; + } else if (vulnerabilityPathRegex.test(path)) { + apiName = 'vulnerability'; } - return { api: 'flightctl', url: `${apiProxy}/flightctl/api/v1/${path}` }; + return { api: apiName, url: `${apiProxy}/flightctl/api/v1/${path}` }; }; export const logout = async () => { @@ -78,7 +82,10 @@ export const redirectToLogin = () => { window.location.href = '/login'; }; -const handleApiJSONResponse = async (response: Response): Promise => { +const handleApiJSONResponse = async (api: Api, response: Response): Promise => { + if (api === 'vulnerability') { + return handleVulnerabilityJSONResponse(response); + } if (response.ok) { const data = (await response.json()) as R; return data; @@ -116,6 +123,24 @@ const handleAlertsJSONResponse = async (response: Response): Promise => { throw new Error(await getErrorMsgFromAlertsApiResponse(response)); }; +const handleVulnerabilityJSONResponse = async (response: Response): Promise => { + if (response.ok) { + const data = (await response.json()) as R; + return data; + } + + if (response.status === 404) { + throw new Error(`Error ${response.status}: ${response.statusText}`); + } + + // API returns 501 for disabled vulnerabilities API. + if (response.status === 501) { + throw new Error(`${response.status}`); + } + + throw new Error(await getErrorMsgFromApiResponse(response)); +}; + const fetchWithRetry = async (path: string, init?: RequestInit): Promise => { const { api, url } = getFullApiUrl(path); @@ -136,7 +161,7 @@ const fetchWithRetry = async (path: string, init?: RequestInit): Promise = if (api === 'alerts') { return handleAlertsJSONResponse(response); } - return handleApiJSONResponse(response); + return handleApiJSONResponse(api, response); }; const putOrPostData = async ( diff --git a/eslint.config.js b/eslint.config.js index 1b589598f..e0e9a1980 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -101,6 +101,11 @@ module.exports = defineConfig([ importNames: ['WizardFooterWrapper', 'WizardFooter'], message: 'Use FlightCtlWizardFooter wrapper', }, + { + name: '@patternfly/react-core', + importNames: ['Drawer', 'DrawerPanelContent'], + message: 'Use FlightCtlPageDrawer wrapper', + }, { name: 'react-i18next', importNames: ['useTranslation'], diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 1860499a0..6f81b00d7 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -25,6 +25,7 @@ "404 Page Not Found": "404 Page Not Found", "Error page - details should be displayed here": "Error page - details should be displayed here", "Overview": "Overview", + "Security overview": "Security overview", "Command line tools": "Command line tools", "Enrollment Request Details": "Enrollment Request Details", "Enrollment Request": "Enrollment Request", @@ -289,7 +290,6 @@ "You do not have permission to deploy": "You do not have permission to deploy", "A channel must be selected": "A channel must be selected", "A version must be selected": "A version must be selected", - "Resize panel": "Resize panel", "Restore": "Restore", "This catalog item is managed by a resource sync and cannot be directly restored. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.": "This catalog item is managed by a resource sync and cannot be directly restored. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.", "Deprecate": "Deprecate", @@ -520,6 +520,7 @@ "Add label": "Add label", "Unexpected error occurred": "Unexpected error occurred", "Please reload the page and try again.": "Please reload the page and try again.", + "Resize panel": "Resize panel", "Next": "Next", "Back": "Back", "Show less": "Show less", @@ -632,13 +633,12 @@ "You can add devices and label them to match fleets, or you can <2>start with a fleet and add devices into it.": "You can add devices and label them to match fleets, or you can <2>start with a fleet and add devices into it.", "You can add devices and label them to match fleets": "You can add devices and label them to match fleets", "No decommissioning or decommissioned devices here!": "No decommissioning or decommissioned devices here!", - "Name / Alias": "Name / Alias", "Clear all filters": "Clear all filters", "Searching...": "Searching...", "No results": "No results", "Fleet and label filter toggle": "Fleet and label filter toggle", "Clear filter text": "Clear filter text", - "Name and alias": "Name and alias", + "Enter a valid CVE ID in the form CVE-YYYY-sequence, with sequence containing at least 4 digits (for example, CVE-2024-12345).": "Enter a valid CVE ID in the form CVE-YYYY-sequence, with sequence containing at least 4 digits (for example, CVE-2024-12345).", "Labels and fleets": "Labels and fleets", "Filter by labels and fleets": "Filter by labels and fleets", "Decommission devices": "Decommission devices", @@ -1205,6 +1205,7 @@ "Failed": "Failed", "Canceling": "Canceling", "Canceled": "Canceled", + "Scanning for vulnerabilities": "Scanning for vulnerabilities", "Converting": "Converting", "Image built successfully": "Image built successfully", "Export images": "Export images", @@ -1388,6 +1389,8 @@ "This area displays current notifications about your monitored devices and fleets.": "This area displays current notifications about your monitored devices and fleets.", "Alerts will appear here if an issue is detected.": "Alerts will appear here if an issue is detected.", "View devices": "View devices", + "Security risks across your devices. Resolve critical vulnerabilities immediately to prevent migration failure and protect your infrastructure.": "Security risks across your devices. Resolve critical vulnerabilities immediately to prevent migration failure and protect your infrastructure.", + "View all CVEs": "View all CVEs", "{{count}} Devices_one": "{{count}} Device", "{{count}} Devices_other": "{{count}} Devices", "Review pending devices_one": "Review pending device", @@ -1500,6 +1503,48 @@ "Resource sync {{rsId}} could not be found": "Resource sync {{rsId}} could not be found", "Resource sync {{rsId}}": "Resource sync {{rsId}}", "Could not find the details for the resource sync <1>{rsId}": "Could not find the details for the resource sync <1>{rsId}", + "Vulnerability reporting is not enabled in this environment.": "Vulnerability reporting is not enabled in this environment.", + "Total active vulnerabilities.": "Total active vulnerabilities.", + "CVEs affecting images deployed across your managed fleet and devices.": "CVEs affecting images deployed across your managed fleet and devices.", + "Vulnerability counts by severity": "Vulnerability counts by severity", + "No CVEs detected": "No CVEs detected", + "All managed devices have been scanned. No CVEs were found affecting images currently deployed across your fleets and devices.": "All managed devices have been scanned. No CVEs were found affecting images currently deployed across your fleets and devices.", + "No vulnerability data to display.": "No vulnerability data to display.", + "There are currently no deployed devices. Scan results will be available once devices have been added.": "There are currently no deployed devices. Scan results will be available once devices have been added.", + "Severity": "Severity", + "Affected devices": "Affected devices", + "Affected images": "Affected images", + "Published": "Published", + "Filter by severity": "Filter by severity", + "Find by name": "Find by name", + "Vulnerabilities table": "Vulnerabilities table", + "Show more": "Show more", + "CVE record - {{ cveId }}": "CVE record - {{ cveId }}", + "{{ advisoryId }} - Red Hat Security Advisory": "{{ advisoryId }} - Red Hat Security Advisory", + "Published: {{ date }}": "Published: {{ date }}", + "Scanner name": "Scanner name", + "<0>{deviceCount} devices in this fleet are running images affected by this vulnerability. Update or replace the affected images to remediate._one": "<0>{deviceCount} device in this fleet is running images affected by this vulnerability. Update or replace the affected images to remediate.", + "<0>{deviceCount} devices in this fleet are running images affected by this vulnerability. Update or replace the affected images to remediate._other": "<0>{deviceCount} devices in this fleet are running images affected by this vulnerability. Update or replace the affected images to remediate.", + "<0>{deviceCount} devices are running images affected by this vulnerability. Update or replace the affected images to remediate._one": "<0>{deviceCount} device is running an image affected by this vulnerability. Update or replace the affected image to remediate.", + "<0>{deviceCount} devices are running images affected by this vulnerability. Update or replace the affected images to remediate._other": "<0>{deviceCount} devices are running images affected by this vulnerability. Update or replace the affected images to remediate.", + "<0>{deviceCount} devices in <2>1 fleet are running images affected by this vulnerability. Update or replace the affected images to remediate._one": "<0>{deviceCount} device in <2>1 fleet is running an image affected by this vulnerability. Update or replace the affected image to remediate.", + "<0>{deviceCount} devices in <2>1 fleet are running images affected by this vulnerability. Update or replace the affected images to remediate._other": "<0>{deviceCount} devices in <2>1 fleet are running images affected by this vulnerability. Update or replace the affected images to remediate.", + "<0>{deviceCount} devices across <2>{fleetCount} fleets are running images affected by this vulnerability. Update or replace the affected images to remediate._one": "<0>{deviceCount} device is running an image affected by this vulnerability. Update or replace the affected image to remediate.", + "<0>{deviceCount} devices across <2>{fleetCount} fleets are running images affected by this vulnerability. Update or replace the affected images to remediate._other": "<0>{deviceCount} devices across <2>{fleetCount} fleets are running images affected by this vulnerability. Update or replace the affected images to remediate.", + "This device is running an image affected by this vulnerability. Update or replace the affected image to remediate.": "This device is running an image affected by this vulnerability. Update or replace the affected image to remediate.", + "Vulnerability impact table": "Vulnerability impact table", + "Total affected fleets": "Total affected fleets", + "Total {{ count }} fleets_one": "Total {{ count }} fleet", + "Total {{ count }} fleets_other": "Total {{ count }} fleets", + "Total affected devices": "Total affected devices", + "Total {{ count }} devices_one": "Total {{ count }} device", + "Total {{ count }} devices_other": "Total {{ count }} devices", + "Total affected images": "Total affected images", + "Total {{ count }} images_one": "Total {{ count }} image", + "Total {{ count }} images_other": "Total {{ count }} images", + "Unable to load vulnerability impact data": "Unable to load vulnerability impact data", + "Impact data could not be loaded. Try again later.": "Impact data could not be loaded. Try again later.", + "Impact summary": "Impact summary", "CPU": "CPU", "Memory": "Memory", "Disk": "Disk", @@ -1586,6 +1631,8 @@ "Online": "Online", "Pending sync": "Pending sync", "Suspended": "Suspended", + "Name and alias": "Name and alias", + "CVE ID": "CVE ID", "Decommissioned": "Decommissioned", "Decommissioning": "Decommissioning", "Enrolled": "Enrolled", @@ -1628,5 +1675,10 @@ "Reloading": "Reloading", "Refreshing": "Refreshing", "Maintenance": "Maintenance", + "Undefined": "Undefined", + "Critical": "Critical", + "Important": "Important", + "Moderate": "Moderate", + "Low": "Low", "{{ count }} devices matching the labels were selected._zero": "There are no devices matching these labels." } diff --git a/libs/types/alpha/index.ts b/libs/types/alpha/index.ts index b740f9810..4c8480df9 100644 --- a/libs/types/alpha/index.ts +++ b/libs/types/alpha/index.ts @@ -3,6 +3,7 @@ /* tslint:disable */ /* eslint-disable */ +export type { AffectedFleet } from './models/AffectedFleet'; export { ApiVersion } from './models/ApiVersion'; export type { Catalog } from './models/Catalog'; export type { CatalogItem } from './models/CatalogItem'; @@ -19,4 +20,20 @@ export type { CatalogItemVersion } from './models/CatalogItemVersion'; export type { CatalogList } from './models/CatalogList'; export type { CatalogSpec } from './models/CatalogSpec'; export type { CatalogStatus } from './models/CatalogStatus'; +export type { CveCountsBySeverity } from './models/CveCountsBySeverity'; +export type { DeviceCountsBySeverity } from './models/DeviceCountsBySeverity'; +export type { DeviceVulnerabilitySummaryResponse } from './models/DeviceVulnerabilitySummaryResponse'; +export type { FleetVulnerabilitySummary } from './models/FleetVulnerabilitySummary'; +export type { FleetVulnerabilitySummaryResponse } from './models/FleetVulnerabilitySummaryResponse'; +export type { SemVer } from './models/SemVer'; +export type { SemVerRange } from './models/SemVerRange'; export type { Status } from './models/Status'; +export { Vulnerability } from './models/Vulnerability'; +export { VulnerabilityGroup } from './models/VulnerabilityGroup'; +export { VulnerabilityGroupItem } from './models/VulnerabilityGroupItem'; +export type { VulnerabilityGroupList } from './models/VulnerabilityGroupList'; +export type { VulnerabilityImageRef } from './models/VulnerabilityImageRef'; +export { VulnerabilityImpact } from './models/VulnerabilityImpact'; +export type { VulnerabilityList } from './models/VulnerabilityList'; +export type { VulnerabilitySeveritySummary } from './models/VulnerabilitySeveritySummary'; +export type { VulnerabilitySummaryResponse } from './models/VulnerabilitySummaryResponse'; diff --git a/libs/types/alpha/models/AffectedFleet.ts b/libs/types/alpha/models/AffectedFleet.ts new file mode 100644 index 000000000..699071352 --- /dev/null +++ b/libs/types/alpha/models/AffectedFleet.ts @@ -0,0 +1,27 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { VulnerabilityGroupItem } from './VulnerabilityGroupItem'; +/** + * One fleet or the fleetless aggregate with device and image counts for a CVE. + */ +export type AffectedFleet = { + /** + * Fleet metadata.name, empty for the fleetless aggregate. + */ + fleetName: string; + /** + * True when this row represents devices not owned by a fleet. + */ + fleetless: boolean; + /** + * Devices in this fleet or fleetless group affected by the CVE. + */ + affectedDevices: number; + /** + * Per-digest findings within this fleet or fleetless group. + */ + findings: Array; +}; + diff --git a/libs/types/alpha/models/CatalogItemVersion.ts b/libs/types/alpha/models/CatalogItemVersion.ts index d20cc3734..f45ba9f0d 100644 --- a/libs/types/alpha/models/CatalogItemVersion.ts +++ b/libs/types/alpha/models/CatalogItemVersion.ts @@ -4,6 +4,8 @@ /* eslint-disable */ import type { CatalogItemConfigurable } from './CatalogItemConfigurable'; import type { CatalogItemDeprecation } from './CatalogItemDeprecation'; +import type { SemVer } from './SemVer'; +import type { SemVerRange } from './SemVerRange'; /** * A version of a catalog item following the Cincinnati model where versions * are nodes in an upgrade graph and channels are labels on those nodes. @@ -16,7 +18,7 @@ export type CatalogItemVersion = (CatalogItemConfigurable & { /** * Semantic version identifier (e.g., 1.2.3, 2.0.0-rc1). Required for version ordering and upgrade graph. */ - version: string; + version: SemVer; /** * Map of artifact type to image tag or digest. Keys must match a type in spec.artifacts. Only keyed artifacts are available for this version. */ @@ -28,15 +30,15 @@ export type CatalogItemVersion = (CatalogItemConfigurable & { /** * The single version this one replaces, defining the primary upgrade edge. */ - replaces?: string; + replaces?: SemVer; /** * Additional versions that can upgrade directly to this one. Use when stable channel skips intermediate fast-only versions. */ - skips?: Array; + skips?: Array; /** * Semver range of versions that can upgrade directly to this one. Use for z-stream updates or hotfixes. */ - skipRange?: string; + skipRange?: SemVerRange; deprecation?: CatalogItemDeprecation; }); diff --git a/libs/types/alpha/models/CveCountsBySeverity.ts b/libs/types/alpha/models/CveCountsBySeverity.ts new file mode 100644 index 000000000..534c0e311 --- /dev/null +++ b/libs/types/alpha/models/CveCountsBySeverity.ts @@ -0,0 +1,38 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Counts of distinct CVEs in the organization by highest severity. + */ +export type CveCountsBySeverity = { + /** + * Total distinct CVEs across the organization. + */ + total: number; + /** + * Count of distinct Critical CVEs. + */ + critical: number; + /** + * Count of distinct High CVEs. + */ + high: number; + /** + * Count of distinct Medium CVEs. + */ + medium: number; + /** + * Count of distinct Low CVEs. + */ + low: number; + /** + * Count of distinct CVEs with no exploitable impact (CVSS score 0). + */ + none: number; + /** + * Count of distinct CVEs with unknown or unscored severity. + */ + unknown: number; +}; + diff --git a/libs/types/alpha/models/DeviceCountsBySeverity.ts b/libs/types/alpha/models/DeviceCountsBySeverity.ts new file mode 100644 index 000000000..5e6783742 --- /dev/null +++ b/libs/types/alpha/models/DeviceCountsBySeverity.ts @@ -0,0 +1,38 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Counts of distinct devices affected in the organization, grouped by vulnerability severity. + */ +export type DeviceCountsBySeverity = { + /** + * Number of distinct devices with at least one vulnerability finding in the organization. + */ + total: number; + /** + * Number of devices whose highest severity finding is critical. + */ + critical: number; + /** + * Number of devices whose highest severity finding is high. + */ + high: number; + /** + * Number of devices whose highest severity finding is medium. + */ + medium: number; + /** + * Number of devices whose highest severity finding is low. + */ + low: number; + /** + * Number of devices whose highest severity finding is none. + */ + none: number; + /** + * Number of devices whose highest severity finding is unknown. + */ + unknown: number; +}; + diff --git a/libs/types/alpha/models/DeviceVulnerabilitySummaryResponse.ts b/libs/types/alpha/models/DeviceVulnerabilitySummaryResponse.ts new file mode 100644 index 000000000..3262fd4a3 --- /dev/null +++ b/libs/types/alpha/models/DeviceVulnerabilitySummaryResponse.ts @@ -0,0 +1,26 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +import type { VulnerabilitySeveritySummary } from './VulnerabilitySeveritySummary'; +/** + * Severity summary for a single device. + */ +export type DeviceVulnerabilitySummaryResponse = { + apiVersion: ApiVersion; + /** + * Resource kind; always DeviceVulnerabilitySummary. + */ + kind: string; + /** + * Image reference from device status. Absent when the device has no rendered OS image. + */ + image?: string; + /** + * Image digest from device status. Absent when the device has no rendered OS image. + */ + imageDigest?: string; + summary: VulnerabilitySeveritySummary; +}; + diff --git a/libs/types/alpha/models/FleetVulnerabilitySummary.ts b/libs/types/alpha/models/FleetVulnerabilitySummary.ts new file mode 100644 index 000000000..175fb28f2 --- /dev/null +++ b/libs/types/alpha/models/FleetVulnerabilitySummary.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { VulnerabilitySeveritySummary } from './VulnerabilitySeveritySummary'; +/** + * Fleet vulnerability totals including unique digest count. + */ +export type FleetVulnerabilitySummary = (VulnerabilitySeveritySummary & { + /** + * Distinct image digests observed in the fleet. + */ + uniqueDigests: number; +}); + diff --git a/libs/types/alpha/models/FleetVulnerabilitySummaryResponse.ts b/libs/types/alpha/models/FleetVulnerabilitySummaryResponse.ts new file mode 100644 index 000000000..e93bccb9b --- /dev/null +++ b/libs/types/alpha/models/FleetVulnerabilitySummaryResponse.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +import type { FleetVulnerabilitySummary } from './FleetVulnerabilitySummary'; +/** + * Severity summary for a single fleet. + */ +export type FleetVulnerabilitySummaryResponse = { + apiVersion: ApiVersion; + /** + * Resource kind; always FleetVulnerabilitySummary. + */ + kind: string; + summary: FleetVulnerabilitySummary; +}; + diff --git a/libs/types/alpha/models/SemVer.ts b/libs/types/alpha/models/SemVer.ts new file mode 100644 index 000000000..034f1ad5c --- /dev/null +++ b/libs/types/alpha/models/SemVer.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Semantic version identifier (e.g., 1.2.3, 2.0.0-rc1) + */ +export type SemVer = string; diff --git a/libs/types/alpha/models/SemVerRange.ts b/libs/types/alpha/models/SemVerRange.ts new file mode 100644 index 000000000..95f7f7c4b --- /dev/null +++ b/libs/types/alpha/models/SemVerRange.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Semver range constraint (e.g., >=1.0.0 <2.0.0). Space-separated terms, each with optional operator (>=, <=, >, <, =, ~, ^) followed by a version. + */ +export type SemVerRange = string; diff --git a/libs/types/alpha/models/Vulnerability.ts b/libs/types/alpha/models/Vulnerability.ts new file mode 100644 index 000000000..a9d03a3a8 --- /dev/null +++ b/libs/types/alpha/models/Vulnerability.ts @@ -0,0 +1,65 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +/** + * A single vulnerability (CVE) finding. Vulnerabilities are global CVE records from Trustify; they are not tenant-owned resources. Tenancy is determined by the device or fleet context through which they are queried. + */ +export type Vulnerability = { + apiVersion: ApiVersion; + /** + * Kind is a string value representing the REST resource this object represents. Always Vulnerability. + */ + kind: string; + /** + * CVE identifier (e.g. CVE-2024-1234). + */ + cveId: string; + /** + * Vendor advisory identifier when available. + */ + advisoryId?: string; + /** + * Normalized severity label. + */ + severity: Vulnerability.severity; + /** + * CVSS base score when available. + */ + cvssScore?: number; + /** + * Short summary of the vulnerability. + */ + description?: string; + /** + * Advisory publish time when known. + */ + publishedAt?: string; + /** + * Image reference (name or URL) from the device context. + */ + image?: string; + /** + * Immutable image digest from the device context. + */ + imageDigest?: string; + /** + * Distinct devices affected by this CVE. For device context this is always 1; for fleet context it is the number of devices in the fleet running an image with this CVE; for organization-wide context it is the count across the whole organization. + */ + affectedDevices?: number; +}; +export namespace Vulnerability { + /** + * Normalized severity label. + */ + export enum severity { + CRITICAL = 'Critical', + HIGH = 'High', + MEDIUM = 'Medium', + LOW = 'Low', + NONE = 'None', + UNKNOWN = 'Unknown', + } +} + diff --git a/libs/types/alpha/models/VulnerabilityGroup.ts b/libs/types/alpha/models/VulnerabilityGroup.ts new file mode 100644 index 000000000..5d757ce04 --- /dev/null +++ b/libs/types/alpha/models/VulnerabilityGroup.ts @@ -0,0 +1,55 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +import type { VulnerabilityGroupItem } from './VulnerabilityGroupItem'; +/** + * A CVE grouped across one or more images, as returned by fleet-scoped and organization-wide vulnerability list endpoints. Each finding represents one image in which the CVE was detected. + * + */ +export type VulnerabilityGroup = { + apiVersion: ApiVersion; + /** + * Kind is a string value representing the REST resource. Always VulnerabilityGroup. + */ + kind: string; + /** + * CVE identifier (e.g. CVE-2024-1234). + */ + cveId: string; + /** + * Worst severity across all findings in this group. + */ + severity: VulnerabilityGroup.severity; + /** + * Highest CVSS base score across all findings. + */ + maxCvssScore?: number; + /** + * Latest advisory publish time across all findings. + */ + maxPublishedAt?: string; + /** + * Total distinct devices affected by this CVE within scope. + */ + affectedDevices?: number; + /** + * Per-digest findings for this CVE within the query scope. + */ + findings: Array; +}; +export namespace VulnerabilityGroup { + /** + * Worst severity across all findings in this group. + */ + export enum severity { + CRITICAL = 'Critical', + HIGH = 'High', + MEDIUM = 'Medium', + LOW = 'Low', + NONE = 'None', + UNKNOWN = 'Unknown', + } +} + diff --git a/libs/types/alpha/models/VulnerabilityGroupItem.ts b/libs/types/alpha/models/VulnerabilityGroupItem.ts new file mode 100644 index 000000000..87418a50d --- /dev/null +++ b/libs/types/alpha/models/VulnerabilityGroupItem.ts @@ -0,0 +1,59 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A single CVE finding for a specific image digest, including all image refs that resolve to that digest. + */ +export type VulnerabilityGroupItem = { + /** + * Immutable image digest. + */ + imageDigest: string; + /** + * Image references observed for this digest within the query scope. + */ + imageRefs: Array; + /** + * Severity of this CVE for this digest. + */ + severity: VulnerabilityGroupItem.severity; + /** + * CVSS base score when available. + */ + cvssScore?: number; + /** + * Vendor advisory identifier when available. + */ + advisoryId?: string; + /** + * Short summary of the vulnerability. + */ + description?: string; + /** + * Advisory publish time when known. + */ + publishedAt?: string; + /** + * Number of devices in scope running this digest. + */ + affectedDevices?: number; + /** + * When this CVE was first observed in this digest. + */ + firstSeenAt?: string; +}; +export namespace VulnerabilityGroupItem { + /** + * Severity of this CVE for this digest. + */ + export enum severity { + CRITICAL = 'Critical', + HIGH = 'High', + MEDIUM = 'Medium', + LOW = 'Low', + NONE = 'None', + UNKNOWN = 'Unknown', + } +} + diff --git a/libs/types/alpha/models/VulnerabilityGroupList.ts b/libs/types/alpha/models/VulnerabilityGroupList.ts new file mode 100644 index 000000000..92358e9b0 --- /dev/null +++ b/libs/types/alpha/models/VulnerabilityGroupList.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +import type { ListMeta } from '../../models/ListMeta'; +import type { VulnerabilityGroup } from './VulnerabilityGroup'; +/** + * Paginated list of VulnerabilityGroup resources (fleet-scoped or organization-wide). + */ +export type VulnerabilityGroupList = { + apiVersion: ApiVersion; + /** + * Kind is a string value representing the REST resource. Always VulnerabilityGroupList. + */ + kind: string; + metadata: ListMeta; + /** + * Paginated VulnerabilityGroup resources for this page. + */ + items: Array; +}; + diff --git a/libs/types/alpha/models/VulnerabilityImageRef.ts b/libs/types/alpha/models/VulnerabilityImageRef.ts new file mode 100644 index 000000000..33ff15f1b --- /dev/null +++ b/libs/types/alpha/models/VulnerabilityImageRef.ts @@ -0,0 +1,22 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Reference to an OS or workload image by name and digest. + */ +export type VulnerabilityImageRef = { + /** + * Image reference (name or URL). + */ + image: string; + /** + * Immutable image digest. + */ + imageDigest: string; + /** + * Devices running this exact image digest within the parent scope (fleet or blast radius row). + */ + affectedDevices?: number; +}; + diff --git a/libs/types/alpha/models/VulnerabilityImpact.ts b/libs/types/alpha/models/VulnerabilityImpact.ts new file mode 100644 index 000000000..34d73236d --- /dev/null +++ b/libs/types/alpha/models/VulnerabilityImpact.ts @@ -0,0 +1,52 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AffectedFleet } from './AffectedFleet'; +import type { ApiVersion } from './ApiVersion'; +import type { ListMeta } from '../../models/ListMeta'; +/** + * Blast radius for a single CVE across fleets and fleetless devices. + */ +export type VulnerabilityImpact = { + apiVersion: ApiVersion; + /** + * Resource kind; always VulnerabilityImpact. + */ + kind: string; + metadata: ListMeta; + /** + * CVE identifier for this blast radius response. + */ + cveId: string; + /** + * Worst severity for this CVE in the fleet or fleetless group. + */ + severity: VulnerabilityImpact.severity; + /** + * Highest CVSS base score for this CVE in the fleet or fleetless group. + */ + maxCvssScore?: number; + /** + * When this CVE was first observed in the fleet or fleetless group. + */ + maxPublishedAt?: string; + /** + * Per-fleet or fleetless rows with device and image counts. + */ + items: Array; +}; +export namespace VulnerabilityImpact { + /** + * Worst severity for this CVE in the fleet or fleetless group. + */ + export enum severity { + CRITICAL = 'Critical', + HIGH = 'High', + MEDIUM = 'Medium', + LOW = 'Low', + NONE = 'None', + UNKNOWN = 'Unknown', + } +} + diff --git a/libs/types/alpha/models/VulnerabilityList.ts b/libs/types/alpha/models/VulnerabilityList.ts new file mode 100644 index 000000000..ca9b60d76 --- /dev/null +++ b/libs/types/alpha/models/VulnerabilityList.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +import type { ListMeta } from '../../models/ListMeta'; +import type { Vulnerability } from './Vulnerability'; +/** + * Paginated list of Vulnerability resources. + */ +export type VulnerabilityList = { + apiVersion: ApiVersion; + /** + * Kind is a string value representing the REST resource this object represents. Always VulnerabilityList. + */ + kind: string; + metadata: ListMeta; + /** + * Paginated Vulnerability resources for this page. + */ + items: Array; +}; + diff --git a/libs/types/alpha/models/VulnerabilitySeveritySummary.ts b/libs/types/alpha/models/VulnerabilitySeveritySummary.ts new file mode 100644 index 000000000..e26221f4b --- /dev/null +++ b/libs/types/alpha/models/VulnerabilitySeveritySummary.ts @@ -0,0 +1,38 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Total vulnerability counts by severity for the full result set (not only the current page). + */ +export type VulnerabilitySeveritySummary = { + /** + * Total vulnerabilities across all severities for the full result set. + */ + total: number; + /** + * Count of Critical severity findings in the full result set. + */ + critical: number; + /** + * Count of High severity findings in the full result set. + */ + high: number; + /** + * Count of Medium severity findings in the full result set. + */ + medium: number; + /** + * Count of Low severity findings in the full result set. + */ + low: number; + /** + * Count of findings with no exploitable impact (CVSS score 0). + */ + none: number; + /** + * Count of findings with unknown or unscored severity. + */ + unknown: number; +}; + diff --git a/libs/types/alpha/models/VulnerabilitySummaryResponse.ts b/libs/types/alpha/models/VulnerabilitySummaryResponse.ts new file mode 100644 index 000000000..fa21e31d5 --- /dev/null +++ b/libs/types/alpha/models/VulnerabilitySummaryResponse.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +import type { CveCountsBySeverity } from './CveCountsBySeverity'; +/** + * Estate-wide vulnerability summary counts. + */ +export type VulnerabilitySummaryResponse = { + apiVersion: ApiVersion; + /** + * Resource kind; always VulnerabilitySummary. + */ + kind: string; + cvesBySeverity: CveCountsBySeverity; +}; + diff --git a/libs/types/imagebuilder/models/ImageBuildConditionReason.ts b/libs/types/imagebuilder/models/ImageBuildConditionReason.ts index 5ac875629..5c020bf79 100644 --- a/libs/types/imagebuilder/models/ImageBuildConditionReason.ts +++ b/libs/types/imagebuilder/models/ImageBuildConditionReason.ts @@ -9,6 +9,7 @@ export enum ImageBuildConditionReason { ImageBuildConditionReasonPending = 'Pending', ImageBuildConditionReasonBuilding = 'Building', ImageBuildConditionReasonPushing = 'Pushing', + ImageBuildConditionReasonGeneratingSBOM = 'GeneratingSBOM', ImageBuildConditionReasonCompleted = 'Completed', ImageBuildConditionReasonFailed = 'Failed', ImageBuildConditionReasonCanceling = 'Canceling', diff --git a/libs/types/models/K8sProviderSpec.ts b/libs/types/models/K8sProviderSpec.ts index 4897751eb..4a2aa07f0 100644 --- a/libs/types/models/K8sProviderSpec.ts +++ b/libs/types/models/K8sProviderSpec.ts @@ -28,14 +28,8 @@ export type K8sProviderSpec = { * Whether this K8s provider is enabled. */ enabled?: boolean; - /** - * How users from this auth provider are assigned to organizations. - */ - organizationAssignment: AuthOrganizationAssignment | null; - /** - * How users from this auth provider are assigned roles. - */ - roleAssignment: AuthRoleAssignment | null; + organizationAssignment?: AuthOrganizationAssignment; + roleAssignment?: AuthRoleAssignment; /** * Optional suffix to strip from ClusterRole names when normalizing role names. Used for multi-release deployments where ClusterRoles have namespace-specific names (e.g., flightctl-admin-). */ diff --git a/libs/ui-components/src/components/AuthProvider/AuthProvidersPage.tsx b/libs/ui-components/src/components/AuthProvider/AuthProvidersPage.tsx index efef2f0e2..afaccc99e 100644 --- a/libs/ui-components/src/components/AuthProvider/AuthProvidersPage.tsx +++ b/libs/ui-components/src/components/AuthProvider/AuthProvidersPage.tsx @@ -19,7 +19,7 @@ import { TFunction } from 'i18next'; import { CubesIcon } from '@patternfly/react-icons/dist/js/icons/cubes-icon'; import ListPageBody from '../ListPage/ListPageBody'; -import Table, { ApiSortTableColumn } from '../Table/Table'; +import Table from '../Table/Table'; import { useTranslation } from '../../hooks/useTranslation'; import AuthProviderRow from './AuthProviderRow'; import { useAuthProviders } from './useAuthProviders'; @@ -30,7 +30,7 @@ import { RESOURCE, VERB } from '../../types/rbac'; import { ROUTE, useNavigate } from '../../hooks/useNavigate'; import DeleteAuthProviderModal from './AuthProviderDetails/DeleteAuthProviderModal'; -const getColumns = (t: TFunction): ApiSortTableColumn[] => [ +const getColumns = (t: TFunction) => [ { name: t('Name'), }, diff --git a/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx b/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx index 9c0fcd6aa..92bee499d 100644 --- a/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx +++ b/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx @@ -9,14 +9,10 @@ import { DescriptionListGroup, DescriptionListTerm, Divider, - Drawer, DrawerActions, DrawerCloseButton, - DrawerContent, - DrawerContentBody, DrawerHead, DrawerPanelBody, - DrawerPanelContent, Grid, GridItem, Spinner, @@ -27,7 +23,6 @@ import { Title, } from '@patternfly/react-core'; import * as React from 'react'; -import { createPortal } from 'react-dom'; import * as semver from 'semver'; import ReactMarkdown from 'react-markdown'; import { Formik, useFormikContext } from 'formik'; @@ -43,6 +38,7 @@ import { getCatalogItemIcon, getFullContainerURI } from './utils'; import DeleteModal from '../modals/DeleteModal/DeleteModal'; import { useFetchPeriodically } from '../../hooks/useFetchPeriodically'; import WithTooltip from '../common/WithTooltip'; +import FlightCtlPageDrawer from '../common/FlightCtlPageDrawer'; import { InstallSpecFormik } from './InstallWizard/types'; import './CatalogItemDetails.css'; @@ -61,42 +57,6 @@ type CatalogItemDetailsProps = CatalogItemDetailsPanelProps & { onInstall: (installItem: { item: CatalogItem; channel: string; version: string }) => void; }; -const getPageContentTop = () => { - // Try multiple selectors to find the masthead - const masthead = - document.getElementById('stack-inline-masthead') || // Standalone masthead - document.getElementById('page-main-header'); // OCP Console masthead - - const pageTop = document.getElementById('fctl-cmd-panel'); - - return masthead?.getBoundingClientRect()?.bottom || pageTop?.getBoundingClientRect()?.top || 60; -}; - -const usePageContentTop = () => { - const [topOffset, setTopOffset] = React.useState(() => getPageContentTop()); - - React.useEffect(() => { - const measureTop = () => { - setTopOffset(getPageContentTop()); - }; - - // Measure immediately - measureTop(); - - // Also measure after a short delay in case layout isn't complete - const timeoutId = setTimeout(measureTop, 50); - - window.addEventListener('resize', measureTop); - - return () => { - clearTimeout(timeoutId); - window.removeEventListener('resize', measureTop); - }; - }, []); - - return topOffset; -}; - type CatalogItemDetailsHeaderProps = { item: CatalogItem; }; @@ -130,7 +90,6 @@ const CatalogItemDetailsPanel = ({ targetSet, }: CatalogItemDetailsPanelProps) => { const { t } = useTranslation(); - const topOffset = usePageContentTop(); const navigate = useNavigate(); const { patch, remove } = useFetch(); const [isDeprecateModalOpen, setIsDeprecateModalOpen] = React.useState(false); @@ -171,14 +130,7 @@ const CatalogItemDetailsPanel = ({ const isManaged = !!item.metadata.owner; const panelContent = ( - + <> @@ -272,37 +224,12 @@ const CatalogItemDetailsPanel = ({ - - ); - - const drawerWrapper = ( -
- - - - - -
+ ); return ( <> - {createPortal(drawerWrapper, document.body)} + {isDeprecateModalOpen && ( ) => { return !errors.device && !errors.fleet; @@ -51,7 +52,9 @@ const DeviceTarget = () => { isUpdating: devicesUpdating, pagination: devicePagination, } = useDevicesPaginated({ - nameOrAlias: deviceNameFilter || undefined, + textFilters: { + [FilterSearchParams.NameOrAlias]: deviceNameFilter, + }, onlyDecommissioned: false, onlyFleetless: true, }); diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsTab.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsTab.tsx index ef8eef0eb..f301f438a 100644 --- a/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsTab.tsx +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsTab.tsx @@ -18,6 +18,7 @@ import { isDeviceEnrolled } from '../../../utils/devices'; import { useTranslation } from '../../../hooks/useTranslation'; import { useDeviceSpecSystemInfo } from '../../../hooks/useDeviceSpecSystemInfo'; +import { useVulnerabilitiesEnabled } from '../../../hooks/useServicesEnabled'; import EditLabelsForm, { ViewLabels } from '../../modals/EditLabelsModal/EditLabelsForm'; import ResourceLink from '../../common/ResourceLink'; import LabelWithHelperText from '../../common/WithHelperText'; @@ -28,6 +29,7 @@ import DeviceFleet from './DeviceFleet'; import DeviceOs from './DeviceOs'; import DeviceApplications from './DeviceApplications'; import DeviceSystemdUnits from './DeviceSystemdUnits'; +import DeviceVulnerabilities from './DeviceVulnerabilities'; import StatusContent from './DeviceDetailsTabContent/StatusContent'; import SystemResourcesContent from './DeviceDetailsTabContent/SystemResourcesContent'; @@ -42,13 +44,15 @@ type DeviceDetailsTabProps = { const EnrolledDeviceDetails = ({ device, refetch, - children, canEdit, + children, }: React.PropsWithChildren) => { const { t } = useTranslation(); const devSystemInfo = useDeviceSpecSystemInfo(device.status.systemInfo, t); - const hasExtraColumn = !!children; + const [vulnerabilitiesEnabled, canListVulnerabilities] = useVulnerabilitiesEnabled(); + const showVulnerabilities = vulnerabilitiesEnabled && canListVulnerabilities; + const hasExtraColumn = !!children; return ( @@ -158,6 +162,11 @@ const EnrolledDeviceDetails = ({ + {showVulnerabilities && ( + + + + )} diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceVulnerabilities.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceVulnerabilities.tsx new file mode 100644 index 000000000..4f65c0db7 --- /dev/null +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceVulnerabilities.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; + +import { CardBody, CardTitle } from '@patternfly/react-core'; +import { VulnerabilityList } from '@flightctl/types/alpha'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import { useVulnerabilities } from '../../../hooks/useVulnerabilities'; +import ListPageBody from '../../ListPage/ListPageBody'; +import DetailsPageCard from '../../DetailsPage/DetailsPageCard'; +import VulnerabilitiesTable from '../../SecurityOverview/VulnerabilitiesTable'; + +const DeviceVulnerabilities = ({ deviceId }: { deviceId: string }) => { + const { t } = useTranslation(); + const { + vulnerabilities, + currentPage, + setCurrentPage, + itemCount, + search, + setSearch, + selectedSeverities, + setSelectedSeverities, + sortBy, + sortDirection, + onSort, + isLoading, + isUpdating, + error, + } = useVulnerabilities({ + endpoint: `vulnerabilities/devices/${deviceId}`, + }); + + return ( + + {t('Security overview')} + + + + + + + ); +}; + +export default DeviceVulnerabilities; diff --git a/libs/ui-components/src/components/Device/DevicesPage/DecommissionedDevicesTable.tsx b/libs/ui-components/src/components/Device/DevicesPage/DecommissionedDevicesTable.tsx index 74198d4bd..56b74d306 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/DecommissionedDevicesTable.tsx +++ b/libs/ui-components/src/components/Device/DevicesPage/DecommissionedDevicesTable.tsx @@ -11,8 +11,9 @@ import { useTableSelect } from '../../../hooks/useTableSelect'; import { usePermissionsContext } from '../../common/PermissionsContext'; import { useFetch } from '../../../hooks/useFetch'; import { RESOURCE, VERB } from '../../../types/rbac'; +import { DeviceTextFilterKey, FilterSearchParams } from '../../../utils/status/devices'; -import Table, { ApiSortTableColumn } from '../../Table/Table'; +import Table from '../../Table/Table'; import { useDeleteListAction } from '../../ListPage/ListPageActions'; import TablePagination from '../../Table/TablePagination'; import MassDeleteDeviceModal from '../../modals/massModals/MassDeleteDeviceModal/MassDeleteDeviceModal'; @@ -24,15 +25,14 @@ import DeviceNameOnlyToolbarFilter from './DeviceNameOnlyToolbarFilter'; interface DecommissionedDevicesTableProps { devices: Array; refetch: VoidFunction; - nameOrAlias: string | undefined; + setTextFilter: (key: DeviceTextFilterKey, value: string) => void; setOnlyDecommissioned: (check: boolean) => void; - setNameOrAlias: (text: string) => void; hasFiltersEnabled: boolean; isFilterUpdating: boolean; pagination: Pick, 'currentPage' | 'setCurrentPage' | 'itemCount'>; } -const getDeviceColumns = (t: TFunction): ApiSortTableColumn[] => [ +const getDeviceColumns = (t: TFunction) => [ { name: t('Name'), }, @@ -49,8 +49,7 @@ const decommissionedDevicesPermissions = [ const DecommissionedDevicesTable = ({ devices, refetch, - nameOrAlias, - setNameOrAlias, + setTextFilter, hasFiltersEnabled, setOnlyDecommissioned, isFilterUpdating, @@ -81,7 +80,7 @@ const DecommissionedDevicesTable = ({ - + + )} + + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +}; + +export default SecurityOverviewCard; diff --git a/libs/ui-components/src/components/OverviewPage/Cards/Tasks/TasksCard.tsx b/libs/ui-components/src/components/OverviewPage/Cards/Tasks/TasksCard.tsx index 7db2341c6..2bcca4196 100644 --- a/libs/ui-components/src/components/OverviewPage/Cards/Tasks/TasksCard.tsx +++ b/libs/ui-components/src/components/OverviewPage/Cards/Tasks/TasksCard.tsx @@ -55,7 +55,7 @@ const TasksCard = () => { } return ( - + {t('Tasks')} {content} diff --git a/libs/ui-components/src/components/OverviewPage/Overview.tsx b/libs/ui-components/src/components/OverviewPage/Overview.tsx index de3d4c7f7..c02acd278 100644 --- a/libs/ui-components/src/components/OverviewPage/Overview.tsx +++ b/libs/ui-components/src/components/OverviewPage/Overview.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Grid, GridItem } from '@patternfly/react-core'; import { usePermissionsContext } from '../common/PermissionsContext'; -import { useAlertsEnabled } from '../../hooks/useAlertsEnabled'; +import { useAlertsEnabled, useVulnerabilitiesEnabled } from '../../hooks/useServicesEnabled'; import { RESOURCE, VERB } from '../../types/rbac'; import PageWithPermissions from '../common/PageWithPermissions'; import { GlobalSystemRestoreBanners } from '../SystemRestore/SystemRestoreBanners'; @@ -10,6 +10,7 @@ import { GlobalSystemRestoreBanners } from '../SystemRestore/SystemRestoreBanner import AlertsCard from './Cards/Alerts/AlertsCard'; import StatusCard from './Cards/Status/StatusCard'; import TasksCard from './Cards/Tasks/TasksCard'; +import SecurityOverviewCard from './Cards/SecurityOverview/SecurityOverviewCard'; const overviewPermissions = [ { kind: RESOURCE.DEVICE, verb: VERB.LIST }, @@ -17,12 +18,17 @@ const overviewPermissions = [ ]; const Overview = () => { - const alertsEnabled = useAlertsEnabled(); + const [alertsEnabled] = useAlertsEnabled(); + const [vulnerabilitiesEnabled, canListVulnerabilities, vulnerabilitiesLoading] = useVulnerabilitiesEnabled(); const { checkPermissions, loading } = usePermissionsContext(); const [canListDevices, canListErs] = checkPermissions(overviewPermissions); + const vulnColumns = vulnerabilitiesEnabled && canListErs ? 6 : 12; return ( - + @@ -33,8 +39,13 @@ const Overview = () => { )} + {vulnerabilitiesEnabled && ( + + + + )} {canListErs && ( - + )} diff --git a/libs/ui-components/src/components/OverviewPage/OverviewPage.tsx b/libs/ui-components/src/components/OverviewPage/OverviewPage.tsx index b8889666a..ca52f12e0 100644 --- a/libs/ui-components/src/components/OverviewPage/OverviewPage.tsx +++ b/libs/ui-components/src/components/OverviewPage/OverviewPage.tsx @@ -9,7 +9,7 @@ const OverviewPage = () => { return ( <> - + <Title headingLevel="h1" size="3xl" role="heading" aria-level={1}> {t('Overview')} diff --git a/libs/ui-components/src/components/Repository/RepositoryList.tsx b/libs/ui-components/src/components/Repository/RepositoryList.tsx index 51cf5a321..620e0f877 100644 --- a/libs/ui-components/src/components/Repository/RepositoryList.tsx +++ b/libs/ui-components/src/components/Repository/RepositoryList.tsx @@ -20,7 +20,7 @@ import { getLastTransitionTimeText } from '../../utils/status/repository'; import { useTableTextSearch } from '../../hooks/useTableTextSearch'; import DeleteRepositoryModal from './RepositoryDetails/DeleteRepositoryModal'; import TableTextSearch from '../Table/TableTextSearch'; -import Table, { TableColumn } from '../Table/Table'; +import Table from '../Table/Table'; import { useTableSelect } from '../../hooks/useTableSelect'; import MassDeleteRepositoryModal from '../modals/massModals/MassDeleteRepositoryModal/MassDeleteRepositoryModal'; import ResourceListEmptyState from '../common/ResourceListEmptyState'; @@ -68,7 +68,7 @@ const RepositoryEmptyState = () => { ); }; -const getColumns = (t: TFunction): TableColumn[] => [ +const getColumns = (t: TFunction) => [ { name: t('Name'), }, diff --git a/libs/ui-components/src/components/ResourceSync/RepositoryResourceSyncList.tsx b/libs/ui-components/src/components/ResourceSync/RepositoryResourceSyncList.tsx index 5473623f4..f8360f6d9 100644 --- a/libs/ui-components/src/components/ResourceSync/RepositoryResourceSyncList.tsx +++ b/libs/ui-components/src/components/ResourceSync/RepositoryResourceSyncList.tsx @@ -26,7 +26,7 @@ import { useFetch } from '../../hooks/useFetch'; import { ResourceSync, ResourceSyncList, ResourceSyncType } from '@flightctl/types'; import { getObservedHash } from '../../utils/status/repository'; import { useDeleteListAction } from '../ListPage/ListPageActions'; -import Table, { TableColumn } from '../Table/Table'; +import Table from '../Table/Table'; import { useTableTextSearch } from '../../hooks/useTableTextSearch'; import TableTextSearch from '../Table/TableTextSearch'; import { useTableSelect } from '../../hooks/useTableSelect'; @@ -58,7 +58,7 @@ const getResourceSyncType = (t: TFunction, type?: ResourceSyncType) => { return t('Fleet'); }; -const getColumns = (t: TFunction): TableColumn[] => [ +const getColumns = (t: TFunction) => [ { name: t('Name'), }, diff --git a/libs/ui-components/src/components/SecurityOverview/SecurityOverviewPage.tsx b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewPage.tsx new file mode 100644 index 000000000..2e6139cd9 --- /dev/null +++ b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewPage.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { Alert, Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { Link, ROUTE } from '../../hooks/useNavigate'; +import { useVulnerabilities } from '../../hooks/useVulnerabilities'; +import { useVulnerabilitiesEnabled } from '../../hooks/useServicesEnabled'; +import PageWithPermissions from '../common/PageWithPermissions'; +import ListPage from '../ListPage/ListPage'; +import ListPageBody from '../ListPage/ListPageBody'; + +import VulnerabilitiesTable from './VulnerabilitiesTable'; +import SecurityOverviewEmptyState from './VulnerabilitiesEmptyState'; + +const SecurityOverviewPageContent = () => { + const { t } = useTranslation(); + + const { + vulnerabilities, + currentPage, + setCurrentPage, + itemCount, + search, + setSearch, + selectedSeverities, + setSelectedSeverities, + sortBy, + sortDirection, + onSort, + isLoading, + isUpdating, + error, + } = useVulnerabilities(); + + const hasFiltersEnabled = selectedSeverities.length > 0 || search.trim() !== ''; + if (itemCount === 0 && !hasFiltersEnabled && !isLoading && !isUpdating) { + // The link to this page is only shown when some vulnerabilities are present. + // If the user navigates to it directly, we show the empty state assuming there are some devices + return ; + } + + return ( + + + + {t('Overview')} + + {t('Security overview')} + + + + + + ); +}; + +const SecurityOverviewPageWithPermissions = () => { + const { t } = useTranslation(); + + const [vulnerabilitiesEnabled, canListVulnerabilities, isLoading] = useVulnerabilitiesEnabled(); + if (!vulnerabilitiesEnabled) { + return ; + } + + return ( + + + + ); +}; + +export default SecurityOverviewPageWithPermissions; diff --git a/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.css b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.css new file mode 100644 index 000000000..cc4df582d --- /dev/null +++ b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.css @@ -0,0 +1,29 @@ +.fctl-security-overview-summary-box { + padding: var(--pf-t--global--spacer--sm); + border-radius: var(--pf-t--global--border--radius--medium); + font-weight: var(--pf-t--global--font--weight--body--bold); +} + +.fctl-security-overview-summary-box.critical { + background-color: color-mix(in srgb, var(--pf-t--global--icon--color--severity--critical--default) 14%, white); +} + +.fctl-security-overview-summary-box.high { + background-color: color-mix(in srgb, var(--pf-t--global--icon--color--severity--important--default) 14%, white); +} + +.fctl-security-overview-summary-box.medium { + background-color: color-mix(in srgb, var(--pf-t--global--icon--color--severity--moderate--default) 14%, white); +} + +.fctl-security-overview-summary-box.low { + background-color: color-mix(in srgb, var(--pf-t--global--icon--color--severity--minor--default) 14%, white); +} + +.fctl-security-overview-summary-box.none { + background-color: color-mix(in srgb, var(--pf-t--global--icon--color--severity--none--default) 14%, white); +} + +.fctl-security-overview-summary-box.unknown { + background-color: color-mix(in srgb, var(--pf-t--global--icon--color--severity--minor--default) 14%, white); +} diff --git a/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.tsx b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.tsx new file mode 100644 index 000000000..af835fb1b --- /dev/null +++ b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import { Divider, Flex, FlexItem, Icon, Stack, StackItem } from '@patternfly/react-core'; +import { Vulnerability } from '@flightctl/types/alpha'; +import SeverityUndefinedIcon from '@patternfly/react-icons/dist/js/icons/severity-undefined-icon'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { useVulnerabilitySummary } from '../../hooks/useVulnerabilitySummary'; +import { StatusItem } from '../../utils/status/common'; +import { VULNERABILITY_SEVERITY_ORDER, getSeverityCountValue, getSeverityLabel } from '../../utils/vulnerabilities'; +import { useDevicesSummary } from '../Device/DevicesPage/useDevices'; +import { + defaultVulnerabilitySeverityStatusItem, + getVulnerabilitySeverityStatusItems, +} from '../../utils/status/vulnerabilities'; +import VulnerabilitiesEmptyState from './VulnerabilitiesEmptyState'; + +import './SecurityOverviewSummary.css'; + +type SeverityStatProps = { + severity: Vulnerability.severity; + item: StatusItem; + count: number; +}; + +const SeverityStat = ({ count, severity, item }: SeverityStatProps) => { + const { t } = useTranslation(); + + const SeverityIcon = item.customIcon || SeverityUndefinedIcon; + const iconColor = item.customColor; + + // Background color is only set for severities with count > 0 + const statusClass = count === 0 ? '' : severity.toLowerCase(); + return ( + + + + + + + + + + {count} + + + + {getSeverityLabel(severity, t)} + + ); +}; + +const SecurityOverviewSummary = () => { + const { t } = useTranslation(); + const { counts } = useVulnerabilitySummary(); + const [devicesSummary, isLoadingDevices] = useDevicesSummary({}); + + const hasVulnerabilities = counts.total > 0; + const hasAllSeverities = counts.none > 0 || counts.unknown > 0; + const hasDevices = !isLoadingDevices && (devicesSummary?.total || 0) > 0; + + const statusItems = getVulnerabilitySeverityStatusItems(t); + const severityThresholdIndex = VULNERABILITY_SEVERITY_ORDER.indexOf(Vulnerability.severity.LOW); + + return ( + + + + {hasDevices ? counts.total : '--'} + {t('Total active vulnerabilities.')} + {hasVulnerabilities ? ( + {t('CVEs affecting images deployed across your managed fleet and devices.')} + ) : ( + + )} + + + + + + +
+ {VULNERABILITY_SEVERITY_ORDER.map((severity, index) => { + // If none/unknown severities are present, we show all severities. + // Otherwise, we only show severities of the main categories (Critical to Low) + if (!hasAllSeverities && index > severityThresholdIndex) { + return null; + } + + const item = statusItems.find((item) => item.id === severity) || defaultVulnerabilitySeverityStatusItem(t); + return ( + + ); + })} +
+
+
+ ); +}; + +export default SecurityOverviewSummary; diff --git a/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesEmptyState.tsx b/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesEmptyState.tsx new file mode 100644 index 000000000..ee845954b --- /dev/null +++ b/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesEmptyState.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; + +import { Alert } from '@patternfly/react-core'; + +import { useTranslation } from '../../hooks/useTranslation'; + +const VulnerabilitiesEmptyState = ({ hasDevices }: { hasDevices: boolean }) => { + const { t } = useTranslation(); + + if (hasDevices) { + return ( + + {t( + 'All managed devices have been scanned. No CVEs were found affecting images currently deployed across your fleets and devices.', + )} + + ); + } + + return ( + + {t('There are currently no deployed devices. Scan results will be available once devices have been added.')} + + ); +}; + +export default VulnerabilitiesEmptyState; diff --git a/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesTable.tsx b/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesTable.tsx new file mode 100644 index 000000000..f6848d525 --- /dev/null +++ b/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesTable.tsx @@ -0,0 +1,246 @@ +import * as React from 'react'; +import { SelectList, SelectOption, Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem } from '@patternfly/react-core'; +import type { OnSort } from '@patternfly/react-table'; +import type { + Vulnerability, + VulnerabilityGroup, + VulnerabilityGroupList, + VulnerabilityList, +} from '@flightctl/types/alpha'; + +import { PaginationDetails } from '../../hooks/useTablePagination'; +import { useTranslation } from '../../hooks/useTranslation'; +import { VulnerabilitySortDirection, VulnerabilitySortField } from '../../hooks/useVulnerabilities'; +import { useAffectedImagesExpand } from '../../hooks/useAffectedImagesExpand'; +import { VULNERABILITY_SEVERITY_ORDER } from '../../utils/vulnerabilities'; +import { getVulnerabilitySeverityStatusItems } from '../../utils/status/vulnerabilities'; +import FilterSelect from '../form/FilterSelect'; +import Table, { ApiTableColumn } from '../Table/Table'; +import TableTextSearch from '../Table/TableTextSearch'; +import TablePagination from '../Table/TablePagination'; +import FlightCtlPageDrawer from '../common/FlightCtlPageDrawer'; +import StatusDisplay from '../Status/StatusDisplay'; +import { VulnerabilitiesTableCompactRow, VulnerabilitiesTableFullRow } from './VulnerabilitiesTableRow'; +import VulnerabilityDetailsDrawer from './VulnerabilityDetailsDrawer'; + +type VulnerabilitySeverity = Vulnerability['severity']; + +type VulnerabilitiesTableCommonProps = { + isUpdating?: boolean; + selectedSeverities: VulnerabilitySeverity[]; + setSelectedSeverities: React.Dispatch>; + search: string; + setSearch: React.Dispatch>; + sortBy: VulnerabilitySortField; + sortDirection: VulnerabilitySortDirection; + onSort: OnSort; + fleetName?: string; + pagination: Pick< + PaginationDetails, + 'currentPage' | 'setCurrentPage' | 'itemCount' + >; +}; + +type VulnerabilitiesTableSingleDeviceProps = VulnerabilitiesTableCommonProps & { + isSingleDevice: true; + vulnerabilities: Vulnerability[]; +}; + +type VulnerabilitiesTableGroupedProps = VulnerabilitiesTableCommonProps & { + isSingleDevice: false; + vulnerabilities: VulnerabilityGroup[]; +}; + +type VulnerabilitiesTableRowsProps = { + vulnerabilities: T[]; + setSelectedRow: (vulnerability: T) => void; +}; + +const VulnerabilitiesTableCompactRows = ({ + vulnerabilities, + setSelectedRow, +}: VulnerabilitiesTableRowsProps) => + vulnerabilities.map((vuln) => ( + setSelectedRow(vuln)} /> + )); + +const VulnerabilitiesTableFullRows = ({ + vulnerabilities, + setSelectedRow, +}: VulnerabilitiesTableRowsProps) => { + const { getCompoundExpand, isExpandedForRowKey } = useAffectedImagesExpand({ + columnIndex: 3, + }); + + return vulnerabilities.map((vuln, rowIndex) => ( + setSelectedRow(vuln)} + imagesExpanded={isExpandedForRowKey(vuln.cveId)} + compoundExpand={getCompoundExpand(vuln.cveId, rowIndex)} + /> + )); +}; + +const VulnerabilitiesTable = ({ + isUpdating = false, + vulnerabilities, + isSingleDevice, + selectedSeverities, + setSelectedSeverities, + search, + setSearch, + sortBy, + sortDirection, + onSort, + pagination, + fleetName, +}: VulnerabilitiesTableSingleDeviceProps | VulnerabilitiesTableGroupedProps) => { + const { t } = useTranslation(); + + const hasFiltersEnabled = selectedSeverities.length > 0 || search.trim() !== ''; + const clearAllFilters = React.useCallback(() => { + setSearch(''); + setSelectedSeverities([]); + }, [setSearch, setSelectedSeverities]); + + const [selectedRow, setSelectedRow] = React.useState(undefined); + + const toggleSeverityFilter = React.useCallback( + (severity: VulnerabilitySeverity) => { + setSelectedSeverities((currentFilters) => { + if (currentFilters.includes(severity)) { + return currentFilters.filter((selectedSeverity) => selectedSeverity !== severity); + } + + return currentFilters.concat(severity); + }); + }, + [setSelectedSeverities], + ); + + React.useEffect(() => { + if (!selectedRow) { + return; + } + + const current = vulnerabilities.find((row) => row.cveId === selectedRow.cveId); + setSelectedRow(current ?? undefined); + }, [selectedRow, vulnerabilities]); + + const activeSortIndex = sortBy === 'name' ? 0 : 1; + const sortByState = React.useMemo( + () => ({ + index: activeSortIndex, + direction: sortDirection, + }), + [activeSortIndex, sortDirection], + ); + + const tableColumns = React.useMemo(() => { + const baseColumns: ApiTableColumn[] = [ + { + name: t('Name'), + thProps: { + sort: { + sortBy: sortByState, + onSort, + columnIndex: 0, + }, + }, + }, + { + name: t('Severity'), + thProps: { + sort: { + sortBy: sortByState, + onSort, + columnIndex: 1, + }, + }, + }, + ]; + if (!isSingleDevice) { + baseColumns.push({ name: t('Affected devices') }); + baseColumns.push({ name: t('Affected images') }); + } + baseColumns.push({ name: t('Published') }); + return baseColumns; + }, [t, isSingleDevice, sortByState, onSort]); + + const emptyData = vulnerabilities.length === 0; + + const severityItems = getVulnerabilitySeverityStatusItems(t); + + return ( + <> + {selectedRow && ( + setSelectedRow(undefined)} + /> + } + /> + )} + + + + + + + + {VULNERABILITY_SEVERITY_ORDER.map((severity) => { + // The status displayed in the filter is different that the normal Severity status + // (in the filter, the outline border is not shown) + const item = severityItems.find((item) => item.id === severity); + return ( + toggleSeverityFilter(severity)} + > + + + ); + })} + + + + + + + + + + + {isSingleDevice ? ( + + ) : ( + + )} +
+ + + ); +}; + +export default VulnerabilitiesTable; diff --git a/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesTableRow.tsx b/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesTableRow.tsx new file mode 100644 index 000000000..7aa5ed6cb --- /dev/null +++ b/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesTableRow.tsx @@ -0,0 +1,131 @@ +import * as React from 'react'; +import { Button } from '@patternfly/react-core'; +import { ActionsColumn, ExpandableRowContent, Tbody, Td, type TdProps, Tr, TrProps } from '@patternfly/react-table'; + +import { Vulnerability, VulnerabilityGroup } from '@flightctl/types/alpha'; +import { useTranslation } from '../../hooks/useTranslation'; +import { Link, ROUTE } from '../../hooks/useNavigate'; +import { getDateNoTimeDisplay } from '../../utils/dates'; +import { isVulnerabilityGroup } from '../../utils/vulnerabilities'; +import VulnerabilitySeverityStatus from '../Status/VulnerabilitySeverityStatus'; +import VulnerabilityAffectedImages, { getAffectedImages } from './VulnerabilityAffectedImages'; + +type VulnerabilitiesTableCompactRowProps = { + vulnerability: Vulnerability | VulnerabilityGroup; + setSelectedRow: VoidFunction; +}; + +type VulnerabilitiesTableFullRowProps = { + vulnerability: VulnerabilityGroup; + setSelectedRow: VoidFunction; + imagesExpanded: boolean; + compoundExpand: TdProps['compoundExpand']; +}; + +const VulnerabilitiesBaseTr = ({ + vulnerability, + setSelectedRow, + isControlRow, + isContentExpanded, + children, +}: React.PropsWithChildren) => { + const { t } = useTranslation(); + const isGroupItem = isVulnerabilityGroup(vulnerability); + + return ( + + + + + + + + {children} + + {getDateNoTimeDisplay(isGroupItem ? vulnerability.maxPublishedAt : vulnerability.publishedAt)} + + + + + + ); +}; + +const VulnerabilitiesFullTr = ({ + vulnerability, + setSelectedRow, + imagesExpanded, + compoundExpand, +}: VulnerabilitiesTableFullRowProps) => { + const { t } = useTranslation(); + + const affectedImagesCount = getAffectedImages(vulnerability.findings).length; + return ( + + + + {vulnerability.affectedDevices} + + + + {affectedImagesCount} + + + ); +}; + +export const VulnerabilitiesTableCompactRow = ({ + vulnerability, + setSelectedRow, +}: VulnerabilitiesTableCompactRowProps) => { + return ( + + + + ); +}; + +export const VulnerabilitiesTableFullRow = ({ + vulnerability, + setSelectedRow, + imagesExpanded, + compoundExpand, +}: VulnerabilitiesTableFullRowProps) => { + const { t } = useTranslation(); + return ( + + + + + + + + + + + ); +}; diff --git a/libs/ui-components/src/components/SecurityOverview/VulnerabilityAffectedImages.tsx b/libs/ui-components/src/components/SecurityOverview/VulnerabilityAffectedImages.tsx new file mode 100644 index 000000000..4e58c3c21 --- /dev/null +++ b/libs/ui-components/src/components/SecurityOverview/VulnerabilityAffectedImages.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + List, + ListItem, +} from '@patternfly/react-core'; + +import type { VulnerabilityGroupItem } from '@flightctl/types/alpha'; +import { useTranslation } from '../../hooks/useTranslation'; + +export type VulnerabilityAffectedImagesProps = { + findings: VulnerabilityGroupItem[]; +}; + +// Returns the unique image references for a list of findings +// In this way, we count each image only once, for any number of digests for which the vulnerability is present. +export const getAffectedImages = (findings: VulnerabilityGroupItem[]) => { + const uniqueImages = new Set(); + for (const finding of findings) { + for (const ref of finding.imageRefs) { + uniqueImages.add(ref); + } + } + + return Array.from(uniqueImages); +}; + +const VulnerabilityAffectedImages = ({ findings }: VulnerabilityAffectedImagesProps) => { + const { t } = useTranslation(); + + const affectedImages = getAffectedImages(findings); + if (affectedImages.length === 0) { + return null; + } + return ( + + + {t('Affected images')} + + + {affectedImages.map((image) => { + return {image}; + })} + + + + + ); +}; + +export default VulnerabilityAffectedImages; diff --git a/libs/ui-components/src/components/SecurityOverview/VulnerabilityDescription.tsx b/libs/ui-components/src/components/SecurityOverview/VulnerabilityDescription.tsx new file mode 100644 index 000000000..c04d8d4a2 --- /dev/null +++ b/libs/ui-components/src/components/SecurityOverview/VulnerabilityDescription.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { Button, Content } from '@patternfly/react-core'; + +import { useTranslation } from '../../hooks/useTranslation'; + +const TRUNCATE_LENGTH = 150; + +type VulnerabilityDescriptionProps = { + description: string; +}; + +const VulnerabilityDescription = ({ description }: VulnerabilityDescriptionProps) => { + const { t } = useTranslation(); + const [isExpanded, setIsExpanded] = React.useState(false); + + const isTruncated = description.length > TRUNCATE_LENGTH; + const displayText = isTruncated && !isExpanded ? `${description.slice(0, TRUNCATE_LENGTH)}…` : description; + + return ( + <> + {displayText} + {isTruncated && ( + + )} + + ); +}; + +export default VulnerabilityDescription; diff --git a/libs/ui-components/src/components/SecurityOverview/VulnerabilityDetailsDrawer.tsx b/libs/ui-components/src/components/SecurityOverview/VulnerabilityDetailsDrawer.tsx new file mode 100644 index 000000000..5db03ca41 --- /dev/null +++ b/libs/ui-components/src/components/SecurityOverview/VulnerabilityDetailsDrawer.tsx @@ -0,0 +1,155 @@ +import * as React from 'react'; +import { Vulnerability, type VulnerabilityGroup } from '@flightctl/types/alpha'; +import { isVulnerabilityGroup } from '../../utils/vulnerabilities'; + +import { + Content, + ContentVariants, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Divider, + DrawerActions, + DrawerCloseButton, + DrawerHead, + DrawerPanelBody, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import VulnerabilitySeverityStatus from '../Status/VulnerabilitySeverityStatus'; +import { getDateDisplay } from '../../utils/dates'; +import { useTranslation } from '../../hooks/useTranslation'; +import LearnMoreLink from '../common/LearnMoreLink'; + +import VulnerabilityDescription from './VulnerabilityDescription'; +import VulnerabilityAffectedImages from './VulnerabilityAffectedImages'; +import VulnerabilityImpact, { VulnerabilityImpactMessageSingleDevice } from './VulnerabilityImpact'; + +const VulnerabilityDetailsReferences = ({ cveId, advisoryId }: { cveId: string; advisoryId?: string }) => { + const { t } = useTranslation(); + return ( + + + + {t('References')} + + + + + {advisoryId && ( + + + + )} + + + ); +}; + +type VulnerabilityDetailsDrawerProps = { + vulnerability: Vulnerability | VulnerabilityGroup; + isSingleDevice: boolean; + fleetName?: string; + onClose: VoidFunction; +}; + +const VulnerabilityDetailsDrawer = ({ + vulnerability, + isSingleDevice, + fleetName, + onClose, +}: VulnerabilityDetailsDrawerProps) => { + const { t } = useTranslation(); + + const isGrouped = isVulnerabilityGroup(vulnerability); + + let publishedAt: string | undefined; + let advisoryId: string | undefined; + let description: string | undefined; + if (isGrouped) { + publishedAt = vulnerability.maxPublishedAt; + advisoryId = vulnerability.findings[0].advisoryId; + description = vulnerability.findings[0].description; + } else { + publishedAt = vulnerability.publishedAt; + advisoryId = vulnerability.advisoryId; + description = vulnerability.description; + } + + return ( + <> + + {vulnerability.cveId} + + + + + + + {t('Published: {{ date }}', { date: getDateDisplay(publishedAt) })} + {description && ( + + + + {t('Description')} + + + + + )} + + + + {t('Severity')} + + + + + + {t('Scanner name')} + Trustify + + + + + {isGrouped && } + + + + + + {isGrouped && ( + + + + )} + {isSingleDevice && ( + + + + )} + + + + + + + + + + ); +}; + +export default VulnerabilityDetailsDrawer; diff --git a/libs/ui-components/src/components/SecurityOverview/VulnerabilityImpact.tsx b/libs/ui-components/src/components/SecurityOverview/VulnerabilityImpact.tsx new file mode 100644 index 000000000..78e330f28 --- /dev/null +++ b/libs/ui-components/src/components/SecurityOverview/VulnerabilityImpact.tsx @@ -0,0 +1,292 @@ +import * as React from 'react'; +import { TFunction, Trans } from 'react-i18next'; +import { + Alert, + Bullseye, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Icon, + Spinner, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { ExpandableRowContent, Table as PFTable, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import SeverityUndefinedIcon from '@patternfly/react-icons/dist/js/icons/severity-undefined-icon'; + +import { AffectedFleet, Vulnerability } from '@flightctl/types/alpha'; +import { useAffectedImagesExpand } from '../../hooks/useAffectedImagesExpand'; +import { useTranslation } from '../../hooks/useTranslation'; +import { Link, ROUTE } from '../../hooks/useNavigate'; +import VulnerabilityAffectedImages from './VulnerabilityAffectedImages'; +import { useVulnerabilityImpact } from '../../hooks/useVulnerabilityImpact'; +import { getSeverityLabel } from '../../utils/vulnerabilities'; +import { getVulnerabilitySeverityStatusItems } from '../../utils/status/vulnerabilities'; +import { FilterSearchParams } from '../../utils/status/devices'; + +type ImpactTotals = { totalFleets: number; totalDevices: number; totalImageDigests: number }; +type VulnerabilityImpactTableProps = { + cveId: string; + rows: AffectedFleet[]; + totals: ImpactTotals; +}; + +const VulnerabilityImpactMessageContent = ({ + severity, + message, +}: { + severity: Vulnerability.severity; + message: React.ReactNode; +}) => { + const { t } = useTranslation(); + const title = getSeverityLabel(severity, t); + const statusItems = getVulnerabilitySeverityStatusItems(t); + const statusItem = statusItems.find((item) => item.id === severity); + + const IconComponent = statusItem?.customIcon || SeverityUndefinedIcon; + const color = statusItem?.customColor; + + return ( + + + + } + > + {message} + + ); +}; + +const getVulnerabilityImpactMessage = (t: TFunction, totals: ImpactTotals, isSingleFleet?: boolean) => { + // Counts must be transformed to strings to be actually displayed in the translation + const deviceCount = `${totals.totalDevices}`; + const fleetCount = `${totals.totalFleets}`; + + if (isSingleFleet) { + return ( + + {deviceCount} devices in this fleet are running images affected by this vulnerability. Update + or replace the affected images to remediate. + + ); + } + + if (totals.totalFleets === 0) { + return ( + + {deviceCount} devices are running images affected by this vulnerability. Update or replace the + affected images to remediate. + + ); + } + if (totals.totalFleets === 1) { + return ( + + {deviceCount} devices in 1 fleet are running images affected by this + vulnerability. Update or replace the affected images to remediate. + + ); + } + // We can't combine this message with the one for 1 fleet as accepts a single "count" parameter + return ( + + {deviceCount} devices across {fleetCount} fleets are running images affected by + this vulnerability. Update or replace the affected images to remediate. + + ); +}; + +export const VulnerabilityImpactMessageSingleDevice = ({ severity }: { severity: Vulnerability.severity }) => { + const { t } = useTranslation(); + return ( + + ); +}; + +const VulnerabilityImpactTable = ({ cveId, rows, totals }: VulnerabilityImpactTableProps) => { + const { t } = useTranslation(); + + const { getCompoundExpand, isExpandedForRowKey } = useAffectedImagesExpand({ + columnIndex: 2, + }); + + return ( + + + + {t('Fleet name')} + {t('Affected devices')} + {t('Affected images')} + + + {rows.map((fleet, rowIndex) => { + const rowKey = fleet.fleetless ? 'fleetless' : fleet.fleetName; + const imagesExpanded = isExpandedForRowKey(rowKey); + const affectedImagesExpand = getCompoundExpand(rowKey, rowIndex); + + const query = new URLSearchParams({ [FilterSearchParams.CveId]: cveId }); + if (fleet.fleetless) { + query.set(FilterSearchParams.OnlyFleetless, 'true'); + } else { + query.set(FilterSearchParams.Fleet, fleet.fleetName); + } + + return ( + + + + {fleet.fleetless ? ( + t('None') + ) : ( + {fleet.fleetName} + )} + + + + {fleet.affectedDevices} + + + + {fleet.findings.length} + + + + + + + + + + + ); + })} + + + {t('Total {{ count }} fleets', { count: totals.totalFleets })} + + + {t('Total {{ count }} devices', { count: totals.totalDevices })} + + + + {t('Total {{ count }} images', { + count: totals.totalImageDigests, + })} + + + + + ); +}; + +const VulnerabilityImpact = ({ + cveId, + severity, + fleetName, +}: { + cveId: string; + severity: Vulnerability.severity; + fleetName?: string; +}) => { + const { t } = useTranslation(); + const { impact, isLoading, error } = useVulnerabilityImpact(cveId, fleetName); + + const { impactItems, totals } = React.useMemo(() => { + const impactItems = impact?.items ?? []; + return { + impactItems, + totals: impactItems.reduce( + (sum, row) => { + sum.totalFleets += row.fleetless ? 0 : 1; + sum.totalDevices += row.affectedDevices; + sum.totalImageDigests += row.findings.length; + return sum; + }, + { totalFleets: 0, totalDevices: 0, totalImageDigests: 0 }, + ), + }; + }, [impact]); + + let content: React.ReactNode; + + if (isLoading) { + content = ( + + + + ); + } else if (error) { + content = ( + + {t('Impact data could not be loaded. Try again later.')} + + ); + } else { + const msg = getVulnerabilityImpactMessage(t, totals, !!fleetName); + if (fleetName) { + // There should be only one result given the request was filtered by CVE + fleetId + const fleetImpact = impact?.items[0]; + return ( + + + + + + + + + {t('Affected devices')} + + + {fleetImpact?.affectedDevices || '0'} + + + + + + + ); + } else { + content = ( + <> + + + + + + + + ); + } + } + + return ( + + + {t('Impact summary')} + + {content} + + ); +}; + +export default VulnerabilityImpact; diff --git a/libs/ui-components/src/components/Status/VulnerabilitySeverityStatus.tsx b/libs/ui-components/src/components/Status/VulnerabilitySeverityStatus.tsx new file mode 100644 index 000000000..3853484f9 --- /dev/null +++ b/libs/ui-components/src/components/Status/VulnerabilitySeverityStatus.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +import { Icon, Label } from '@patternfly/react-core'; +import { Vulnerability } from '@flightctl/types/alpha'; + +import { VULNERABILITY_SEVERITY_COLOR } from '../../utils/vulnerabilities'; +import { + defaultVulnerabilitySeverityStatusItem, + getVulnerabilitySeverityStatusItems, +} from '../../utils/status/vulnerabilities'; +import { useTranslation } from '../../hooks/useTranslation'; + +type VulnerabilitySeverityStatusProps = { + severity: Vulnerability.severity; +}; + +const VulnerabilitySeverityStatus = ({ severity }: VulnerabilitySeverityStatusProps) => { + const { t } = useTranslation(); + + const statusItems = getVulnerabilitySeverityStatusItems(t); + const item = statusItems.find((item) => item.id === severity) || defaultVulnerabilitySeverityStatusItem(t); + + const IconComponent = item.customIcon; + const color = VULNERABILITY_SEVERITY_COLOR[item.id]; + return ( + + ); +}; + +export default VulnerabilitySeverityStatus; diff --git a/libs/ui-components/src/components/Table/Table.tsx b/libs/ui-components/src/components/Table/Table.tsx index c10c3512c..e623cc802 100644 --- a/libs/ui-components/src/components/Table/Table.tsx +++ b/libs/ui-components/src/components/Table/Table.tsx @@ -7,36 +7,23 @@ import { useTranslation } from '../../hooks/useTranslation'; import LabelWithHelperText from '../common/WithHelperText'; import './Table.css'; -export type ApiSortTableColumn = { +export type ApiTableColumn = { id?: string; name: string; - sortableField?: string; - defaultSort?: boolean; helperText?: string; - thProps?: Omit & { + thProps?: ThProps & { ref?: React.Ref | undefined; }; }; -export type TableColumn = { - name: string; - onSort?: (data: D[]) => D[]; - defaultSort?: boolean; - helperText?: string; - thProps?: Omit & { - ref?: React.Ref | undefined; - }; -}; - -type TableProps = Pick & { - columns: TableColumn[]; +type TableProps = Pick & { + columns: ApiTableColumn[]; children: React.ReactNode; loading: boolean; hasFilters?: boolean; emptyData?: boolean; clearFilters?: VoidFunction; 'aria-label': string; - // getSortParams: (columnIndex: number) => ThProps['sort']; onSelectAll?: (isSelected: boolean) => void; isAllSelected?: boolean; isExpandable?: boolean; @@ -44,9 +31,7 @@ type TableProps = Pick & { singleSelect?: boolean; }; -type TableFC = (props: TableProps) => JSX.Element; - -const Table: TableFC = ({ +export const Table = ({ columns, children, loading, @@ -58,7 +43,7 @@ const Table: TableFC = ({ isExpandable, singleSelect, ...rest -}) => { +}: TableProps) => { const { t } = useTranslation(); if (emptyData && hasFilters) { return loading ? ( diff --git a/libs/ui-components/src/components/common/FlightCtlPageDrawer.tsx b/libs/ui-components/src/components/common/FlightCtlPageDrawer.tsx new file mode 100644 index 000000000..ca909edc8 --- /dev/null +++ b/libs/ui-components/src/components/common/FlightCtlPageDrawer.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +// eslint-disable-next-line no-restricted-imports +import { Drawer, DrawerContent, DrawerContentBody, DrawerPanelContent } from '@patternfly/react-core'; +import { createPortal } from 'react-dom'; + +import { useTranslation } from '../../hooks/useTranslation'; + +type PageDrawerProps = { + panelContent: React.ReactNode; + isExpanded: boolean; +}; + +const getPageContentTop = () => { + const masthead = + document.getElementById('stack-inline-masthead') || // Standalone masthead + document.getElementById('page-main-header'); // OCP Console masthead + const pageTop = document.getElementById('fctl-cmd-panel'); + + return masthead?.getBoundingClientRect()?.bottom || pageTop?.getBoundingClientRect()?.top || 60; +}; + +const usePageContentTop = () => { + const [topOffset, setTopOffset] = React.useState(() => getPageContentTop()); + + React.useEffect(() => { + const measureTop = () => { + setTopOffset(getPageContentTop()); + }; + + measureTop(); + const timeoutId = setTimeout(measureTop, 50); + + window.addEventListener('resize', measureTop); + return () => { + clearTimeout(timeoutId); + window.removeEventListener('resize', measureTop); + }; + }, []); + + return topOffset; +}; + +const drawerWidths = { + '2xl': 'width_25', + xl: 'width_33', + lg: 'width_50', + default: 'width_100', +} as const; + +// Wraps the content to re-enable pointer events so that "close" button works. +const FlightCtlPageDrawerContent = ({ children }: React.PropsWithChildren) => { + const { t } = useTranslation(); + return ( + + {children} + + ); +}; + +const FlightCtlPageDrawer = ({ isExpanded, panelContent }: PageDrawerProps) => { + const topOffset = usePageContentTop(); + + const drawerOverlay = isExpanded ? ( +
+ + {panelContent}}> + + + +
+ ) : null; + + return <>{typeof document !== 'undefined' && drawerOverlay ? createPortal(drawerOverlay, document.body) : null}; +}; + +export default FlightCtlPageDrawer; diff --git a/libs/ui-components/src/constants.ts b/libs/ui-components/src/constants.ts index 04169de0f..75885390a 100644 --- a/libs/ui-components/src/constants.ts +++ b/libs/ui-components/src/constants.ts @@ -8,14 +8,17 @@ export const EVENT_PAGE_SIZE = 200; // It's 500 in OCP console export const CERTIFICATE_VALIDITY_IN_YEARS = 1; -export const getApiVersion = (api: 'flightctl' | 'imagebuilder' | 'alerts' | 'catalog'): string | undefined => { +export const getApiVersion = ( + api: 'flightctl' | 'imagebuilder' | 'alerts' | 'catalog' | 'vulnerability', +): string | undefined => { switch (api) { case 'flightctl': return ApiVersion.ApiVersionV1beta1; - case 'imagebuilder': - return ImageBuilderApiVersion.ApiVersionV1alpha1; case 'catalog': + case 'vulnerability': return AlphaVersion.ApiVersionV1alpha1; + case 'imagebuilder': + return ImageBuilderApiVersion.ApiVersionV1alpha1; case 'alerts': default: return undefined; diff --git a/libs/ui-components/src/hooks/useAffectedImagesExpand.ts b/libs/ui-components/src/hooks/useAffectedImagesExpand.ts new file mode 100644 index 000000000..fced0f556 --- /dev/null +++ b/libs/ui-components/src/hooks/useAffectedImagesExpand.ts @@ -0,0 +1,43 @@ +import * as React from 'react'; + +import type { TdProps } from '@patternfly/react-table'; + +export type UseAffectedImagesExpandArgs = { + columnIndex: number; +}; + +export type UseAffectedImagesExpandResult = { + expandedRowKey: string | null; + isExpandedForRowKey: (rowExpansionKey: string) => boolean; + getCompoundExpand: (rowExpansionKey: string, rowIndex: number) => TdProps['compoundExpand'] | undefined; +}; + +export const useAffectedImagesExpand = ({ + columnIndex, +}: UseAffectedImagesExpandArgs): UseAffectedImagesExpandResult => { + const [expandedRowKey, setExpandedRowKey] = React.useState(null); + + const getCompoundExpand = React.useCallback( + (rowExpansionKey: string, rowIndex: number) => { + return { + isExpanded: expandedRowKey === rowExpansionKey, + onToggle: () => setExpandedRowKey((current) => (current === rowExpansionKey ? null : rowExpansionKey)), + expandId: `vuln-images-${rowExpansionKey}`, + rowIndex, + columnIndex, + }; + }, + [columnIndex, expandedRowKey], + ); + + const isExpandedForRowKey = React.useCallback( + (rowExpansionKey: string) => expandedRowKey === rowExpansionKey, + [expandedRowKey], + ); + + return { + expandedRowKey, + getCompoundExpand, + isExpandedForRowKey, + }; +}; diff --git a/libs/ui-components/src/hooks/useAlertsEnabled.ts b/libs/ui-components/src/hooks/useAlertsEnabled.ts deleted file mode 100644 index c37955645..000000000 --- a/libs/ui-components/src/hooks/useAlertsEnabled.ts +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from 'react'; -import { RESOURCE, VERB } from '../types/rbac'; -import { usePermissionsContext } from '../components/common/PermissionsContext'; -import { useFetch } from './useFetch'; - -// Alerts are considered disabled if: -// - Service returns 501 (Not Implemented) or 500 (Internal Server Error) -// - Service returns 401 (Unauthorized) - authentication issue with alerts service -const isDisabledAlertManagerService = (error: Error): boolean => { - const errorCode = Number(error.message); - return errorCode === 501 || errorCode === 500 || errorCode === 401; -}; - -export const useAlertsEnabled = (): boolean => { - const { get } = useFetch(); - const [alertsEnabled, setAlertsEnabled] = React.useState(false); - - const { checkPermissions, loading: alertsLoading } = usePermissionsContext(); - const [canListAlerts] = checkPermissions([{ kind: RESOURCE.ALERTS, verb: VERB.LIST }]); - - React.useEffect(() => { - let abortController: AbortController; - - const checkAlertServiceEnabled = async () => { - try { - abortController = new AbortController(); - await get('alerts', abortController.signal); - setAlertsEnabled(true); - } catch (err) { - if (!abortController.signal.aborted) { - if (isDisabledAlertManagerService(err as Error)) { - setAlertsEnabled(false); - } else { - // For other errors, assume alerts are enabled but there's a temporary issue - setAlertsEnabled(true); - } - } - } - }; - - if (!alertsLoading && canListAlerts) { - // Check only if we know that the user has permissions to read the alerts - checkAlertServiceEnabled(); - } else { - // If user doesn't have permissions, set to disabled - setAlertsEnabled(false); - } - - return () => { - abortController?.abort(); - }; - }, [get, alertsLoading, canListAlerts]); - - return alertsEnabled; -}; diff --git a/libs/ui-components/src/hooks/useAppContext.tsx b/libs/ui-components/src/hooks/useAppContext.tsx index 6a567e51c..b71db582c 100644 --- a/libs/ui-components/src/hooks/useAppContext.tsx +++ b/libs/ui-components/src/hooks/useAppContext.tsx @@ -48,6 +48,7 @@ export const appRoutes = { [ROUTE.CATALOG_INSTALL]: '/catalog/install', [ROUTE.CATALOG_FLEET_EDIT]: '/devicemanagement/fleets/catalog', [ROUTE.CATALOG_DEVICE_EDIT]: '/devicemanagement/devices/catalog', + [ROUTE.SECURITY_OVERVIEW]: '/security-overview', }; export type NavLinkFC = React.FC<{ to: string; children: (props: { isActive: boolean }) => React.ReactNode }>; diff --git a/libs/ui-components/src/hooks/useNavigate.tsx b/libs/ui-components/src/hooks/useNavigate.tsx index e97febf91..5a5aab568 100644 --- a/libs/ui-components/src/hooks/useNavigate.tsx +++ b/libs/ui-components/src/hooks/useNavigate.tsx @@ -40,6 +40,7 @@ export enum ROUTE { CATALOG_INSTALL = 'CATALOG_INSTALL', CATALOG_FLEET_EDIT = 'CATALOG_FLEET_EDIT', CATALOG_DEVICE_EDIT = 'CATALOG_DEVICE_EDIT', + SECURITY_OVERVIEW = 'SECURITY_OVERVIEW', } export type RouteWithPostfix = diff --git a/libs/ui-components/src/hooks/useServicesEnabled.ts b/libs/ui-components/src/hooks/useServicesEnabled.ts new file mode 100644 index 000000000..578c85def --- /dev/null +++ b/libs/ui-components/src/hooks/useServicesEnabled.ts @@ -0,0 +1,86 @@ +import * as React from 'react'; + +import { RESOURCE, VERB } from '../types/rbac'; +import { usePermissionsContext } from '../components/common/PermissionsContext'; +import { useFetch } from './useFetch'; + +const alertManagerDisabledStatusCodes = [501, 500, 401]; +const vulnerabilityDisabledStatusCodes = [501]; + +type ServiceEnabledConfig = { + canList: boolean; + loading: boolean; + endpoint: string; + disabledStatusCodes: number[]; +}; + +// [isEnabled, canList, isLoading] +type ServiceEnabledResult = [boolean, boolean, boolean]; + +const useServiceEnabled = ({ + canList, + loading, + endpoint, + disabledStatusCodes, +}: ServiceEnabledConfig): ServiceEnabledResult => { + const { get } = useFetch(); + const [isEnabled, setIsEnabled] = React.useState(false); + + React.useEffect(() => { + let abortController: AbortController; + + const checkServiceEnabled = async () => { + try { + abortController = new AbortController(); + await get(endpoint, abortController.signal); + setIsEnabled(true); + } catch (err) { + if (!abortController.signal.aborted) { + const errorMessage = err instanceof Error ? err.message : undefined; + if (errorMessage && disabledStatusCodes.includes(Number(errorMessage))) { + setIsEnabled(false); + } else { + // For unknown errors, assume service is enabled but temporarily unavailable. + setIsEnabled(true); + } + } + } + }; + + if (!loading && canList) { + checkServiceEnabled(); + } else { + setIsEnabled(false); + } + + return () => { + abortController?.abort(); + }; + }, [canList, endpoint, get, disabledStatusCodes, loading]); + + return [isEnabled, canList, loading]; +}; + +export const useAlertsEnabled = (): ServiceEnabledResult => { + const { checkPermissions, loading } = usePermissionsContext(); + const [canList] = checkPermissions([{ kind: RESOURCE.ALERTS, verb: VERB.LIST }]); + + return useServiceEnabled({ + canList, + loading, + endpoint: 'alerts', + disabledStatusCodes: alertManagerDisabledStatusCodes, + }); +}; + +export const useVulnerabilitiesEnabled = (): ServiceEnabledResult => { + const { checkPermissions, loading } = usePermissionsContext(); + const [canListVulnerabilities] = checkPermissions([{ kind: RESOURCE.VULNERABILITY, verb: VERB.LIST }]); + + return useServiceEnabled({ + canList: canListVulnerabilities, + loading, + endpoint: 'vulnerabilities/summary', + disabledStatusCodes: vulnerabilityDisabledStatusCodes, + }); +}; diff --git a/libs/ui-components/src/hooks/useVulnerabilities.ts b/libs/ui-components/src/hooks/useVulnerabilities.ts new file mode 100644 index 000000000..e8ec05c3d --- /dev/null +++ b/libs/ui-components/src/hooks/useVulnerabilities.ts @@ -0,0 +1,161 @@ +import * as React from 'react'; +import { OnSort, SortByDirection } from '@patternfly/react-table'; +import { useDebounce } from 'use-debounce'; +import type { Vulnerability, VulnerabilityGroupList, VulnerabilityList } from '@flightctl/types/alpha'; + +import { useFetchPeriodically } from './useFetchPeriodically'; +import { useTablePagination } from './useTablePagination'; +import { PAGE_SIZE } from '../constants'; + +export type VulnerabilitySortField = 'name' | 'severity'; +export type VulnerabilitySortDirection = 'asc' | 'desc'; + +type VulnerabilityApiList = VulnerabilityGroupList | VulnerabilityList; + +type UseVulnerabilitiesResult = { + vulnerabilities: TList['items']; + currentPage: number; + setCurrentPage: (page: number) => void; + itemCount: number; + search: string; + setSearch: React.Dispatch>; + selectedSeverities: Vulnerability.severity[]; + setSelectedSeverities: React.Dispatch>; + sortBy: VulnerabilitySortField; + onSort: OnSort; + sortDirection: VulnerabilitySortDirection; + error: unknown; + isLoading: boolean; + isUpdating: boolean; +}; + +type UseVulnerabilitiesArgs = { + endpoint?: string; +}; + +const FILTER_DEBOUNCE_MS = 1000; + +const severitySelectionKey = (severities: Vulnerability.severity[]): string => [...severities].sort().join('\0'); + +type VulnerabilitiesListEndpointArgs = { + endpoint: string; + nextContinue: string; + search: string; + selectedSeverities: Vulnerability.severity[]; + sortBy: VulnerabilitySortField; + sortDirection: VulnerabilitySortDirection; +}; + +const getVulnerabilityListFieldSelector = (search: string, severities: Vulnerability.severity[]) => { + const selectors: string[] = []; + + // CVE ID is stripped of special characters that would make the query invalid. + const cveIdSearch = search.trim().replace(/%/g, '').replace(/,/g, ' ').replace(/\s+/g, ' '); + if (cveIdSearch.length > 0) { + selectors.push(`cveId contains ${cveIdSearch}`); + } + if (severities.length === 1) { + selectors.push(`severity=${severities[0]}`); + } else if (severities.length > 1) { + selectors.push(`severity in (${severities.join(',')})`); + } + return selectors.length === 0 ? undefined : selectors.join(','); +}; + +const getVulnerabilitiesListEndpoint = ({ + endpoint, + nextContinue, + search, + selectedSeverities, + sortBy, + sortDirection, +}: VulnerabilitiesListEndpointArgs): string => { + const params = new URLSearchParams(); + params.set('limit', String(PAGE_SIZE)); + params.set('sortBy', sortBy === 'name' ? 'cveId' : 'severity'); + params.set('order', sortDirection); + + const fieldSelector = getVulnerabilityListFieldSelector(search, selectedSeverities); + if (fieldSelector) { + params.set('fieldSelector', fieldSelector); + } + if (nextContinue) { + params.set('continue', nextContinue); + } + return `${endpoint}?${params.toString()}`; +}; + +export const useVulnerabilities = ({ + endpoint = 'vulnerabilities', +}: UseVulnerabilitiesArgs = {}): UseVulnerabilitiesResult => { + const [search, setSearch] = React.useState(''); + const [debouncedSearch] = useDebounce(search.trim(), FILTER_DEBOUNCE_MS); + const [selectedSeverities, setSelectedSeverities] = React.useState([]); + const [debouncedSelectedSeverities] = useDebounce(selectedSeverities, FILTER_DEBOUNCE_MS); + + const vulnerabilitiesDebouncing = + search.trim() !== debouncedSearch || + severitySelectionKey(selectedSeverities) !== severitySelectionKey(debouncedSelectedSeverities); + const [sortBy, setSortField] = React.useState('severity'); + const [sortDirection, setSortDirection] = React.useState('desc'); + + const { currentPage, setCurrentPage, nextContinue, onPageFetched, itemCount } = useTablePagination(); + + React.useLayoutEffect(() => { + setCurrentPage(1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearch, debouncedSelectedSeverities, sortBy, sortDirection]); + + const resolvedEndpoint = React.useMemo( + () => + getVulnerabilitiesListEndpoint({ + endpoint, + nextContinue, + search: debouncedSearch, + selectedSeverities: debouncedSelectedSeverities, + sortBy, + sortDirection, + }), + [endpoint, nextContinue, debouncedSearch, debouncedSelectedSeverities, sortBy, sortDirection], + ); + + const [listRes, listLoading, listError, , updating] = useFetchPeriodically( + { + endpoint: resolvedEndpoint, + }, + onPageFetched, + ); + + const onSort = React.useCallback( + (_event, columnIndex, sortByDirection) => { + const field: VulnerabilitySortField = columnIndex === 0 ? 'name' : 'severity'; + const direction: VulnerabilitySortDirection = sortByDirection === SortByDirection.asc ? 'asc' : 'desc'; + + if (sortBy !== field) { + setSortField(field); + setSortDirection(field === 'severity' ? 'desc' : 'asc'); + return; + } + + setSortDirection(direction); + }, + [sortBy], + ); + + return { + vulnerabilities: listRes?.items ?? [], + currentPage, + setCurrentPage, + itemCount, + search, + setSearch, + selectedSeverities, + setSelectedSeverities, + sortBy, + sortDirection, + onSort, + isLoading: listLoading, + error: listError, + isUpdating: updating || vulnerabilitiesDebouncing, + }; +}; diff --git a/libs/ui-components/src/hooks/useVulnerabilityImpact.ts b/libs/ui-components/src/hooks/useVulnerabilityImpact.ts new file mode 100644 index 000000000..18a8cb1b6 --- /dev/null +++ b/libs/ui-components/src/hooks/useVulnerabilityImpact.ts @@ -0,0 +1,61 @@ +import * as React from 'react'; +import type { VulnerabilityImpact } from '@flightctl/types/alpha'; + +import { useFetch } from './useFetch'; + +export type UseVulnerabilityImpactResult = { + impact: VulnerabilityImpact | undefined; + isLoading: boolean; + error: unknown; +}; + +const getImpactEndpoint = (cveId: string, fleetName?: string) => { + const baseEndpoint = `vulnerabilities/cves/${encodeURIComponent(cveId)}/impact`; + return fleetName ? `${baseEndpoint}?fieldSelector=owner=Fleet/${fleetName}` : baseEndpoint; +}; + +export const useVulnerabilityImpact = (cveId: string, fleetName?: string): UseVulnerabilityImpactResult => { + const { get } = useFetch(); + const [impact, setImpact] = React.useState(); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(); + + const endpoint = getImpactEndpoint(cveId, fleetName); + + React.useEffect(() => { + const abortController = new AbortController(); + setIsLoading(true); + setError(undefined); + setImpact(undefined); + + const load = async () => { + try { + const res = await get(endpoint, abortController.signal); + if (abortController.signal.aborted) { + return; + } + setImpact(res); + } catch (e) { + if (!abortController.signal.aborted) { + setError(e); + setImpact(undefined); + } + } + if (!abortController.signal.aborted) { + setIsLoading(false); + } + }; + + void load(); + + return () => { + abortController.abort(); + }; + }, [endpoint, get]); + + return { + impact, + isLoading, + error, + }; +}; diff --git a/libs/ui-components/src/hooks/useVulnerabilitySummary.ts b/libs/ui-components/src/hooks/useVulnerabilitySummary.ts new file mode 100644 index 000000000..45d5a01c1 --- /dev/null +++ b/libs/ui-components/src/hooks/useVulnerabilitySummary.ts @@ -0,0 +1,47 @@ +import * as React from 'react'; +import type { CveCountsBySeverity, VulnerabilitySummaryResponse } from '@flightctl/types/alpha'; + +import { useFetch } from './useFetch'; + +export type UseVulnerabilitySummaryResult = { + counts: CveCountsBySeverity; + isLoading: boolean; +}; + +const emptySeverityCounts = { total: 0, critical: 0, high: 0, medium: 0, low: 0, none: 0, unknown: 0 }; + +export const useVulnerabilitySummary = (): UseVulnerabilitySummaryResult => { + const { get } = useFetch(); + const [counts, setCounts] = React.useState(null); + const isLoading = counts === null; + + React.useEffect(() => { + const abortController = new AbortController(); + + const load = async () => { + setCounts(null); + try { + const summaryRes = await get('vulnerabilities/summary', abortController.signal); + if (abortController.signal.aborted) { + return; + } + setCounts(summaryRes.cvesBySeverity); + } catch { + if (!abortController.signal.aborted) { + setCounts(emptySeverityCounts); + } + } + }; + + void load(); + + return () => { + abortController.abort(); + }; + }, [get]); + + return { + counts: counts ?? emptySeverityCounts, + isLoading, + }; +}; diff --git a/libs/ui-components/src/types/rbac.ts b/libs/ui-components/src/types/rbac.ts index 7a1fd77ee..8d8a81649 100644 --- a/libs/ui-components/src/types/rbac.ts +++ b/libs/ui-components/src/types/rbac.ts @@ -13,6 +13,8 @@ export enum RESOURCE { CATALOG_ITEM = 'catalogitems', FLEET = 'fleets', DEVICE = 'devices', + // CELIA-WIP: confirm vulnerabilities resource path with backend RBAC before release. + VULNERABILITY = 'vulnerabilities', DEVICE_CONSOLE = 'devices/console', DEVICE_DECOMMISSION = 'devices/decommission', DEVICE_RESUME = 'devices/resume', diff --git a/libs/ui-components/src/utils/api.ts b/libs/ui-components/src/utils/api.ts index 665868211..fb780b2c0 100644 --- a/libs/ui-components/src/utils/api.ts +++ b/libs/ui-components/src/utils/api.ts @@ -8,7 +8,7 @@ import { ResourceSyncList, } from '@flightctl/types'; import { ImageBuildList } from '@flightctl/types/imagebuilder'; -import { CatalogItemList } from '@flightctl/types/alpha'; +import { CatalogItemList, VulnerabilityGroupList, VulnerabilityList } from '@flightctl/types/alpha'; import { AnnotationType, GenericCondition, GenericConditionType } from '../types/extraTypes'; @@ -19,7 +19,9 @@ export type ApiList = | RepositoryList | ResourceSyncList | ImageBuildList - | CatalogItemList; + | CatalogItemList + | VulnerabilityGroupList + | VulnerabilityList; const getApiListCount = (listResponse: ApiList | undefined): number | undefined => { if (listResponse === undefined) { diff --git a/libs/ui-components/src/utils/dates.ts b/libs/ui-components/src/utils/dates.ts index 8e59c80b2..293644fc8 100644 --- a/libs/ui-components/src/utils/dates.ts +++ b/libs/ui-components/src/utils/dates.ts @@ -12,6 +12,13 @@ const dateTimeFormatter = () => year: 'numeric', }); +const dateFormatter = () => + new Intl.DateTimeFormat(defaultLang, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + const getDateDisplay = (timestamp?: string) => { if (!timestamp) { return 'N/A'; @@ -20,6 +27,14 @@ const getDateDisplay = (timestamp?: string) => { return dateTimeFormatter().format(new Date(timestamp)); }; +const getDateNoTimeDisplay = (timestamp?: string) => { + if (!timestamp) { + return 'N/A'; + } + + return dateFormatter().format(new Date(timestamp)); +}; + // https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site export const timeSinceEpochText = (t: TFunction, epochOffset: number) => { const seconds = Math.floor((Date.now() - epochOffset) / 1000); @@ -56,4 +71,4 @@ const timeSinceText = (t: TFunction, timestampStr?: string) => { return timeSinceEpochText(t, new Date(timestampStr).getTime()); }; -export { getDateDisplay, timeSinceText }; +export { getDateDisplay, getDateNoTimeDisplay, timeSinceText }; diff --git a/libs/ui-components/src/utils/imageBuilds.ts b/libs/ui-components/src/utils/imageBuilds.ts index 0039618a4..f96ae8c88 100644 --- a/libs/ui-components/src/utils/imageBuilds.ts +++ b/libs/ui-components/src/utils/imageBuilds.ts @@ -95,6 +95,7 @@ export const isImageBuildActiveReason = (reason: ImageBuildConditionReason): boo reason === ImageBuildConditionReason.ImageBuildConditionReasonPending || reason === ImageBuildConditionReason.ImageBuildConditionReasonBuilding || reason === ImageBuildConditionReason.ImageBuildConditionReasonPushing || + reason === ImageBuildConditionReason.ImageBuildConditionReasonGeneratingSBOM || reason === ImageBuildConditionReason.ImageBuildConditionReasonCanceling ); }; @@ -103,6 +104,7 @@ export const isImageBuildCancelable = (reason: ImageBuildConditionReason): boole return ( reason === ImageBuildConditionReason.ImageBuildConditionReasonPending || reason === ImageBuildConditionReason.ImageBuildConditionReasonBuilding || + reason === ImageBuildConditionReason.ImageBuildConditionReasonGeneratingSBOM || reason === ImageBuildConditionReason.ImageBuildConditionReasonPushing ); }; diff --git a/libs/ui-components/src/utils/status/devices.ts b/libs/ui-components/src/utils/status/devices.ts index c658f5e4a..b2ca4ceb3 100644 --- a/libs/ui-components/src/utils/status/devices.ts +++ b/libs/ui-components/src/utils/status/devices.ts @@ -19,13 +19,31 @@ import { StatusItem } from './common'; export enum FilterSearchParams { Fleet = 'fleetId', + OnlyFleetless = 'onlyFleetless', DeviceStatus = 'devSt', AppStatus = 'appSt', UpdatedStatus = 'updSt', Label = 'label', NameOrAlias = 'nameOrAlias', + CveId = 'cveId', } +// Filters that require the user to enter some free-text +export const DEVICE_TEXT_FILTER_KEYS = [FilterSearchParams.NameOrAlias, FilterSearchParams.CveId]; + +export type DeviceTextFilterKey = (typeof DEVICE_TEXT_FILTER_KEYS)[number]; +export type DeviceFilterTypes = DeviceTextFilterKey | FilterSearchParams.Label; +const CVE_ID_FILTER_PATTERN = /^CVE-\d{4}-\d{4,}$/i; + +// Attempting to search for an invalid CVE ID will result in a 400 error from the backend. +export const isValidCveIdFilterValue = (value: string | undefined): boolean => { + const trimmed = value?.trim() ?? ''; + if (trimmed.length === 0) { + return true; + } + return CVE_ID_FILTER_PATTERN.test(trimmed); +}; + export type DeviceSummaryStatus = | ApplicationsSummaryStatusType | DeviceUpdatedStatusType @@ -97,6 +115,19 @@ export const getDeviceStatusItems = (t: TFunction): StatusItem { + switch (key) { + case FilterSearchParams.NameOrAlias: + return t('Name and alias'); + case FilterSearchParams.CveId: + return t('CVE ID'); + case FilterSearchParams.Label: + return t('Labels and fleets'); + default: + return key; + } +}; + /** * Returns device status items for the Overview page, allowing to exclude statuses. * If "AwaitingReconnect" or "ConflictPaused" statuses are present, they are ordered at the beginning diff --git a/libs/ui-components/src/utils/status/vulnerabilities.ts b/libs/ui-components/src/utils/status/vulnerabilities.ts new file mode 100644 index 000000000..359d34aa4 --- /dev/null +++ b/libs/ui-components/src/utils/status/vulnerabilities.ts @@ -0,0 +1,60 @@ +import { TFunction } from 'react-i18next'; + +import { SeverityCriticalIcon } from '@patternfly/react-icons/dist/js/icons/severity-critical-icon'; +import SeverityImportantIcon from '@patternfly/react-icons/dist/js/icons/severity-important-icon'; +import SeverityModerateIcon from '@patternfly/react-icons/dist/js/icons/severity-moderate-icon'; +import SeverityMinorIcon from '@patternfly/react-icons/dist/js/icons/severity-minor-icon'; +import SeverityNoneIcon from '@patternfly/react-icons/dist/js/icons/severity-none-icon'; +import SeverityUndefinedIcon from '@patternfly/react-icons/dist/js/icons/severity-undefined-icon'; + +import { Vulnerability } from '@flightctl/types/alpha'; +import { StatusItem } from './common'; +import { VULNERABILITY_SEVERITY_COLOR } from '../vulnerabilities'; + +export const defaultVulnerabilitySeverityStatusItem = (t: TFunction): StatusItem => ({ + id: Vulnerability.severity.UNKNOWN, + label: t('Undefined'), + level: 'unknown', + customIcon: SeverityUndefinedIcon, + customColor: VULNERABILITY_SEVERITY_COLOR[Vulnerability.severity.UNKNOWN], +}); + +export const getVulnerabilitySeverityStatusItems = (t: TFunction): StatusItem[] => [ + { + id: Vulnerability.severity.CRITICAL, + label: t('Critical'), + level: 'danger', + customIcon: SeverityCriticalIcon, + customColor: VULNERABILITY_SEVERITY_COLOR[Vulnerability.severity.CRITICAL], + }, + { + id: Vulnerability.severity.HIGH, + label: t('Important'), + level: 'custom', + customIcon: SeverityImportantIcon, + customColor: VULNERABILITY_SEVERITY_COLOR[Vulnerability.severity.HIGH], + }, + { + id: Vulnerability.severity.MEDIUM, + label: t('Moderate'), + level: 'custom', + customIcon: SeverityModerateIcon, + customColor: VULNERABILITY_SEVERITY_COLOR[Vulnerability.severity.MEDIUM], + }, + { + id: Vulnerability.severity.LOW, + label: t('Low'), + level: 'custom', + customIcon: SeverityMinorIcon, + customColor: VULNERABILITY_SEVERITY_COLOR[Vulnerability.severity.LOW], + }, + { + id: Vulnerability.severity.NONE, + label: t('None'), + level: 'custom', + customIcon: SeverityNoneIcon, + customColor: VULNERABILITY_SEVERITY_COLOR[Vulnerability.severity.NONE], + }, + // Unknown + defaultVulnerabilitySeverityStatusItem(t), +]; diff --git a/libs/ui-components/src/utils/vulnerabilities.ts b/libs/ui-components/src/utils/vulnerabilities.ts new file mode 100644 index 000000000..49efcba24 --- /dev/null +++ b/libs/ui-components/src/utils/vulnerabilities.ts @@ -0,0 +1,69 @@ +import { TFunction } from 'react-i18next'; +import { CveCountsBySeverity, Vulnerability, VulnerabilityGroup } from '@flightctl/types/alpha'; + +const SeverityColorCritical = 'var(--pf-t--global--icon--color--severity--critical--default)'; +const SeverityColorImportant = 'var(--pf-t--global--icon--color--severity--important--default)'; +const SeverityColorModerate = 'var(--pf-t--global--icon--color--severity--moderate--default)'; +const SeverityColorMinor = 'var(--pf-t--global--icon--color--severity--minor--default)'; +const SeverityColorNone = 'var(--pf-t--global--icon--color--severity--none--default)'; + +type Severity = Vulnerability.severity; + +export const VULNERABILITY_SEVERITY_ORDER: Severity[] = [ + Vulnerability.severity.CRITICAL, + Vulnerability.severity.HIGH, + Vulnerability.severity.MEDIUM, + Vulnerability.severity.LOW, + Vulnerability.severity.NONE, + Vulnerability.severity.UNKNOWN, +]; + +export const VULNERABILITY_SEVERITY_COLOR: Record = { + [Vulnerability.severity.CRITICAL]: SeverityColorCritical, + [Vulnerability.severity.HIGH]: SeverityColorImportant, + [Vulnerability.severity.MEDIUM]: SeverityColorModerate, + [Vulnerability.severity.LOW]: SeverityColorMinor, // grey in PF6 + [Vulnerability.severity.NONE]: SeverityColorNone, // blue in PF6 + [Vulnerability.severity.UNKNOWN]: SeverityColorMinor, // grey in PF6 +}; + +export const VULNERABILITY_FILTER_QUERY_PARAM = 'cveId'; + +export const isVulnerabilityGroup = ( + vulnerability: Vulnerability | VulnerabilityGroup, +): vulnerability is VulnerabilityGroup => 'findings' in vulnerability; + +export const getSeverityCountValue = (severity: Severity, counts: CveCountsBySeverity) => { + switch (severity) { + case Vulnerability.severity.CRITICAL: + return counts.critical; + case Vulnerability.severity.HIGH: + return counts.high; + case Vulnerability.severity.MEDIUM: + return counts.medium; + case Vulnerability.severity.LOW: + return counts.low; + case Vulnerability.severity.NONE: + return counts.none; + case Vulnerability.severity.UNKNOWN: + return counts.unknown; + } +}; + +export const getSeverityLabel = (severity: Severity, t: TFunction): string => { + switch (severity) { + case Vulnerability.severity.CRITICAL: + return t('Critical'); + case Vulnerability.severity.HIGH: + return t('Important'); + case Vulnerability.severity.MEDIUM: + return t('Moderate'); + case Vulnerability.severity.LOW: + return t('Low'); + case Vulnerability.severity.NONE: + return t('None'); + case Vulnerability.severity.UNKNOWN: + default: + return t('Undefined'); + } +};