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 fleet2> and add devices into it.": "You can add devices and label them to match fleets, or you can <2>start with a fleet2> 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}1>": "Could not find the details for the resource sync <1>{rsId}1>", + "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}0> devices in this fleet are running images affected by this vulnerability. Update or replace the affected images to remediate._one": "<0>{deviceCount}0> device in this fleet is running images affected by this vulnerability. Update or replace the affected images to remediate.", + "<0>{deviceCount}0> devices in this fleet are running images affected by this vulnerability. Update or replace the affected images to remediate._other": "<0>{deviceCount}0> devices in this fleet are running images affected by this vulnerability. Update or replace the affected images to remediate.", + "<0>{deviceCount}0> devices are running images affected by this vulnerability. Update or replace the affected images to remediate._one": "<0>{deviceCount}0> device is running an image affected by this vulnerability. Update or replace the affected image to remediate.", + "<0>{deviceCount}0> devices are running images affected by this vulnerability. Update or replace the affected images to remediate._other": "<0>{deviceCount}0> devices are running images affected by this vulnerability. Update or replace the affected images to remediate.", + "<0>{deviceCount}0> devices in <2>12> fleet are running images affected by this vulnerability. Update or replace the affected images to remediate._one": "<0>{deviceCount}0> device in <2>12> fleet is running an image affected by this vulnerability. Update or replace the affected image to remediate.", + "<0>{deviceCount}0> devices in <2>12> fleet are running images affected by this vulnerability. Update or replace the affected images to remediate._other": "<0>{deviceCount}0> devices in <2>12> fleet are running images affected by this vulnerability. Update or replace the affected images to remediate.", + "<0>{deviceCount}0> devices across <2>{fleetCount}2> fleets are running images affected by this vulnerability. Update or replace the affected images to remediate._one": "<0>{deviceCount}0> device is running an image affected by this vulnerability. Update or replace the affected image to remediate.", + "<0>{deviceCount}0> devices across <2>{fleetCount}2> fleets are running images affected by this vulnerability. Update or replace the affected images to remediate._other": "<0>{deviceCount}0> devices across <2>{fleetCount}2> 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 = ({ - + setAddDeviceModal(true)}> @@ -121,7 +120,7 @@ const DecommissionedDevicesTable = ({ loading={isFilterUpdating} columns={deviceColumns} hasFilters={hasFiltersEnabled} - clearFilters={() => setNameOrAlias('')} + clearFilters={() => setTextFilter(FilterSearchParams.NameOrAlias, '')} emptyData={devices.length === 0} isAllSelected={isAllSelected} onSelectAll={setAllSelected} diff --git a/libs/ui-components/src/components/Device/DevicesPage/DeviceNameOnlyToolbarFilter.tsx b/libs/ui-components/src/components/Device/DevicesPage/DeviceNameOnlyToolbarFilter.tsx index 6d8559d12..41d98206b 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/DeviceNameOnlyToolbarFilter.tsx +++ b/libs/ui-components/src/components/Device/DevicesPage/DeviceNameOnlyToolbarFilter.tsx @@ -1,28 +1,28 @@ import * as React from 'react'; import debounce from 'lodash/debounce'; -import TableTextSearch, { TableTextSearchProps } from '../../Table/TableTextSearch'; +import { DeviceTextFilterKey, FilterSearchParams } from '../../../utils/status/devices'; +import TableTextSearch from '../../Table/TableTextSearch'; import './DeviceToolbarFilters.css'; type DeviceNameOnlyToolbarFilterProps = { - nameOrAlias?: TableTextSearchProps['value']; - setNameOrAlias: TableTextSearchProps['setValue']; + setTextFilter: (key: DeviceTextFilterKey, value: string) => void; }; -const DeviceNameOnlyToolbarFilter = ({ setNameOrAlias }: DeviceNameOnlyToolbarFilterProps) => { +const DeviceNameOnlyToolbarFilter = ({ setTextFilter }: DeviceNameOnlyToolbarFilterProps) => { const [typingText, setTypingText] = React.useState(''); - const debouncedSetParam = React.useMemo( + const debouncedSetNameOrAlias = React.useMemo( () => - debounce((setValue: TableTextSearchProps['setValue'], value: string) => { - setValue(value || ''); + debounce((value: string) => { + setTextFilter?.(FilterSearchParams.NameOrAlias, value || ''); }, 500), - [], + [setTextFilter], ); React.useEffect(() => { - debouncedSetParam(setNameOrAlias, typingText); - }, [typingText, setNameOrAlias, debouncedSetParam]); + debouncedSetNameOrAlias(typingText); + }, [typingText, debouncedSetNameOrAlias]); return ; }; diff --git a/libs/ui-components/src/components/Device/DevicesPage/DeviceTableToolbar.tsx b/libs/ui-components/src/components/Device/DevicesPage/DeviceTableToolbar.tsx index cf5e8ecd2..d19275848 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/DeviceTableToolbar.tsx +++ b/libs/ui-components/src/components/Device/DevicesPage/DeviceTableToolbar.tsx @@ -11,19 +11,22 @@ import { ToolbarItem, } from '@patternfly/react-core'; -import { TableTextSearchProps } from '../../Table/TableTextSearch'; import { useTranslation } from '../../../hooks/useTranslation'; +import { DEVICE_TEXT_FILTER_KEYS, DeviceTextFilterKey, getDeviceFilterLabel } from '../../../utils/status/devices'; +import { labelToString } from '../../../utils/labels'; import DeviceStatusFilter, { getStatusItem } from './DeviceFilterSelect'; import { FilterStatusMap, UpdateStatus } from './types'; import { FlightCtlLabel } from '../../../types/extraTypes'; -import { labelToString } from '../../../utils/labels'; import DeviceTableToolbarFilters from './DeviceToolbarFilters'; type DeviceTableToolbarProps = { - nameOrAlias: TableTextSearchProps['value']; - setNameOrAlias: TableTextSearchProps['setValue']; + textFilters: Partial>; + setTextFilter: (key: DeviceTextFilterKey, value: string) => void; + clearTextFilters: VoidFunction; ownerFleets: string[]; setOwnerFleets: (ownerFleets: string[]) => void; + onlyFleetless: boolean; + setOnlyFleetless: (enabled: boolean) => void; activeStatuses: FilterStatusMap; setActiveStatuses: (statuses: FilterStatusMap) => void; selectedLabels: FlightCtlLabel[]; @@ -35,8 +38,8 @@ const DeviceTableToolbar: React.FC @@ -105,15 +108,27 @@ type DeviceToolbarChipsProps = Omit { const { t } = useTranslation(); const statusKeys = Object.keys(activeStatuses).filter((k) => !!activeStatuses[k as keyof FilterStatusMap].length); + const activeTextFilterKeys = DEVICE_TEXT_FILTER_KEYS.filter((key) => !!textFilters[key]); + + const hasAnyFilter = + !!statusKeys.length || + !!ownerFleets.length || + onlyFleetless || + !!activeTextFilterKeys.length || + !!selectedLabels.length; + return ( {statusKeys.map((k) => { @@ -146,15 +161,31 @@ const DeviceToolbarChips = ({ )} - {nameOrAlias && ( + {onlyFleetless && ( - setNameOrAlias('')}> - setNameOrAlias('')}> - {nameOrAlias} + setOnlyFleetless(false)}> + setOnlyFleetless(false)}> + {t('N/A')} )} + {activeTextFilterKeys.map((filterKey) => { + const value = textFilters[filterKey]; + return ( + + setTextFilter(filterKey, '')} + > + setTextFilter(filterKey, '')}> + {value} + + + + ); + })} {!!selectedLabels.length && ( setSelectedLabels([])}> @@ -173,14 +204,15 @@ const DeviceToolbarChips = ({ )} - {(!!statusKeys.length || !!ownerFleets.length || !!nameOrAlias || !!selectedLabels.length) && ( + {hasAnyFilter && ( { updateStatus(); setOwnerFleets([]); - setNameOrAlias(''); + setOnlyFleetless(false); + clearTextFilters(); setSelectedLabels([]); }} > diff --git a/libs/ui-components/src/components/Device/DevicesPage/DeviceToolbarFilters.tsx b/libs/ui-components/src/components/Device/DevicesPage/DeviceToolbarFilters.tsx index aade9f05f..61838ad4d 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/DeviceToolbarFilters.tsx +++ b/libs/ui-components/src/components/Device/DevicesPage/DeviceToolbarFilters.tsx @@ -2,6 +2,10 @@ import * as React from 'react'; import debounce from 'lodash/debounce'; import { Button, + Flex, + FlexItem, + HelperText, + HelperTextItem, Icon, MenuToggle, MenuToggleElement, @@ -10,6 +14,8 @@ import { SelectList, SelectOption, Spinner, + Stack, + StackItem, TextInputGroup, TextInputGroupMain, TextInputGroupUtilities, @@ -21,18 +27,23 @@ import { Fleet, FleetList, LabelList } from '@flightctl/types'; import { FlightCtlLabel } from '../../../types/extraTypes'; import { isPromiseFulfilled } from '../../../types/typeUtils'; -import TableTextSearch, { TableTextSearchProps } from '../../Table/TableTextSearch'; +import TableTextSearch from '../../Table/TableTextSearch'; import { useTranslation } from '../../../hooks/useTranslation'; import { useFetch } from '../../../hooks/useFetch'; import { commonQueries as queries } from '../../../utils/query'; import { MAX_TOTAL_SEARCH_RESULTS, getEmptyFleetSearch, getSearchResultsCount } from '../../../utils/search'; import { labelToString, stringToLabel } from '../../../utils/labels'; +import { + DEVICE_TEXT_FILTER_KEYS, + DeviceFilterTypes, + DeviceTextFilterKey, + FilterSearchParams, + getDeviceFilterLabel, + isValidCveIdFilterValue, +} from '../../../utils/status/devices'; import './DeviceToolbarFilters.css'; -const NAME_SEARCH = 'NameAndAlias'; -const LABEL_SEARCH = 'LabelsAndFleets'; - type LabelFleetSelectorProps = { selectedFleetNames: string[]; selectedLabels: FlightCtlLabel[]; @@ -301,8 +312,8 @@ type DeviceToolbarFilterProps = { setSelectedFleets: (ownerFleets: string[]) => void; selectedLabels: FlightCtlLabel[]; setSelectedLabels: (labels: FlightCtlLabel[]) => void; - nameOrAlias?: TableTextSearchProps['value']; - setNameOrAlias?: TableTextSearchProps['setValue']; + textFilters?: Partial>; + setTextFilter?: (key: DeviceTextFilterKey, value: string) => void; }; const DeviceToolbarFilter = ({ @@ -310,35 +321,59 @@ const DeviceToolbarFilter = ({ setSelectedFleets, selectedLabels, setSelectedLabels, - nameOrAlias, - setNameOrAlias, + textFilters = {}, + setTextFilter, }: DeviceToolbarFilterProps) => { const { t } = useTranslation(); const [isSearchTypeExpanded, setIsSearchTypeExpanded] = React.useState(false); - const [selectedSearchType, setSelectedSearchType] = React.useState(LABEL_SEARCH); + const [selectedSearchType, setSelectedSearchType] = React.useState(FilterSearchParams.Label); + const [freeTextFilterError, setFreeTextFilterError] = React.useState(undefined); const [typingText, setTypingText] = React.useState(''); - const debouncedSetParam = React.useMemo( + const debouncedSetTextFilter = React.useMemo( () => - debounce((setValue: TableTextSearchProps['setValue'], value: string) => { - setValue(value || ''); + debounce((key: DeviceTextFilterKey, value: string) => { + if (key === FilterSearchParams.CveId) { + const isValid = isValidCveIdFilterValue(value); + setFreeTextFilterError( + isValid + ? undefined + : t( + 'Enter a valid CVE ID in the form CVE-YYYY-sequence, with sequence containing at least 4 digits (for example, CVE-2024-12345).', + ), + ); + if (!isValid) { + return; + } + } + setTextFilter?.(key, value || ''); }, 500), - [], + [setTextFilter, t], ); + const urlValueForTextMode = + selectedSearchType !== FilterSearchParams.Label ? textFilters[selectedSearchType] : undefined; + React.useEffect(() => { - if (setNameOrAlias) { - debouncedSetParam(setNameOrAlias, typingText); + if (!setTextFilter || selectedSearchType === FilterSearchParams.Label) { + return undefined; } - }, [typingText, setNameOrAlias, debouncedSetParam]); + debouncedSetTextFilter(selectedSearchType, typingText); + return () => { + debouncedSetTextFilter.cancel(); + }; + }, [typingText, selectedSearchType, setTextFilter, debouncedSetTextFilter]); React.useEffect(() => { - if (!nameOrAlias && typingText) { + if (selectedSearchType === FilterSearchParams.Label) { + return; + } + if (!urlValueForTextMode && typingText) { setTypingText(''); } // When the filter is cleared from the chips, clear the "typingText" too // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nameOrAlias, setTypingText]); + }, [selectedSearchType, urlValueForTextMode, setTypingText]); const onToggle = () => { setIsSearchTypeExpanded(!isSearchTypeExpanded); @@ -348,10 +383,13 @@ const DeviceToolbarFilter = ({ _event: React.MouseEvent | undefined, selection: string | number | undefined, ) => { - setSelectedSearchType(selection as string); + const sel = selection as DeviceFilterTypes; + setSelectedSearchType(sel); setIsSearchTypeExpanded(false); - if (selection === LABEL_SEARCH) { + if (sel === FilterSearchParams.Label) { setTypingText(''); + } else { + setTypingText(textFilters[sel] ?? ''); } }; @@ -377,49 +415,66 @@ const DeviceToolbarFilter = ({ }; return ( - <> - {setNameOrAlias && ( - ) => ( - - {selectedSearchType === NAME_SEARCH ? t('Name and alias') : t('Labels and fleets')} - - )} - onSelect={onSearchTypeSelect} - onOpenChange={(isOpen) => setIsSearchTypeExpanded(isOpen)} - selected={selectedSearchType} - isOpen={isSearchTypeExpanded} - > - - - {t('Name and alias')} - - - - {t('Labels and fleets')} - - - )} - {setNameOrAlias && selectedSearchType === NAME_SEARCH ? ( - - ) : ( - - )} - > + + + + + {setTextFilter && ( + ) => ( + + {getDeviceFilterLabel(t, selectedSearchType)} + + )} + onSelect={onSearchTypeSelect} + onOpenChange={(isOpen) => setIsSearchTypeExpanded(isOpen)} + selected={selectedSearchType} + isOpen={isSearchTypeExpanded} + > + + + {t('Labels and fleets')} + + {DEVICE_TEXT_FILTER_KEYS.map((key) => ( + + {getDeviceFilterLabel(t, key)} + + ))} + + + )} + + + {setTextFilter && selectedSearchType !== FilterSearchParams.Label ? ( + + ) : ( + + )} + + + {freeTextFilterError && ( + + + {freeTextFilterError} + + + )} + + ); }; diff --git a/libs/ui-components/src/components/Device/DevicesPage/DevicesPage.tsx b/libs/ui-components/src/components/Device/DevicesPage/DevicesPage.tsx index 189b108a0..b3a3af267 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/DevicesPage.tsx +++ b/libs/ui-components/src/components/Device/DevicesPage/DevicesPage.tsx @@ -20,12 +20,15 @@ const DevicesPage = ({ canListER }: { canListER: boolean }) => { const { t } = useTranslation(); const { - nameOrAlias, - setNameOrAlias, + textFilters, + clearTextFilters, + setTextFilter, ownerFleets, + onlyFleetless, activeStatuses, hasFiltersEnabled, setOwnerFleets, + setOnlyFleetless, setActiveStatuses, selectedLabels, setSelectedLabels, @@ -41,8 +44,9 @@ const DevicesPage = ({ canListER }: { canListER: boolean }) => { isUpdating: updating, refetch, } = useDevices({ - nameOrAlias, + textFilters, ownerFleets, + onlyFleetless, onlyDecommissioned, activeStatuses, labels: selectedLabels, @@ -70,8 +74,7 @@ const DevicesPage = ({ canListER }: { canListER: boolean }) => { { ) : ( ; ownerFleets: string[]; + onlyFleetless: boolean; activeStatuses: FilterStatusMap; hasFiltersEnabled: boolean; - nameOrAlias: string | undefined; + textFilters: Partial>; + setTextFilter: (key: DeviceTextFilterKey, value: string) => void; + clearTextFilters: VoidFunction; setOnlyDecommissioned: (check: boolean) => void; - setNameOrAlias: (text: string) => void; setOwnerFleets: (ownerFleets: string[]) => void; + setOnlyFleetless: (enabled: boolean) => void; setActiveStatuses: (activeStatuses: FilterStatusMap) => void; selectedLabels: FlightCtlLabel[]; setSelectedLabels: (labels: FlightCtlLabel[]) => void; @@ -48,7 +51,7 @@ interface EnrolledDeviceTableProps { // getSortParams: (columnIndex: number) => ThProps['sort']; } -export const getDeviceTableColumns = (t: TFunction): ApiSortTableColumn[] => [ +export const getDeviceTableColumns = (t: TFunction) => [ { id: 'alias', name: t('Alias'), @@ -86,10 +89,13 @@ const enrolledDevicesPermissions = [ const EnrolledDevicesTable = ({ devices, - nameOrAlias, - setNameOrAlias, + textFilters, + setTextFilter, + clearTextFilters, ownerFleets, setOwnerFleets, + onlyFleetless, + setOnlyFleetless, activeStatuses, setActiveStatuses, setOnlyDecommissioned, @@ -131,7 +137,8 @@ const EnrolledDevicesTable = ({ [FilterSearchParams.UpdatedStatus]: [], }); setOwnerFleets([]); - setNameOrAlias(''); + setOnlyFleetless(false); + clearTextFilters(); setSelectedLabels([]); } }; @@ -141,10 +148,13 @@ const EnrolledDevicesTable = ({ => + DEVICE_TEXT_FILTER_KEYS.reduce( + (acc, key) => { + acc[key] = ['']; + return acc; + }, + {} as Record, + ); + export const useDeviceBackendFilters = () => { const { router: { useSearchParams }, @@ -28,7 +42,19 @@ export const useDeviceBackendFilters = () => { const [searchParams, setSearchParams] = useSearchParams(); const paramsRef = React.useRef(searchParams); const ownerFleets = searchParams.getAll(FilterSearchParams.Fleet) || undefined; - const nameOrAlias = searchParams.get(FilterSearchParams.NameOrAlias) || undefined; + const onlyFleetless = searchParams.get(FilterSearchParams.OnlyFleetless) === 'true'; + + const textFilters = React.useMemo((): Partial> => { + const out: Partial> = {}; + for (const key of DEVICE_TEXT_FILTER_KEYS) { + const v = searchParams.get(key) || ''; + const isValid = key === FilterSearchParams.CveId ? isValidCveIdFilterValue(v) : Boolean(v); + if (isValid) { + out[key] = v; + } + } + return out; + }, [searchParams]); const updateSearchParams = React.useCallback( (params: [string, string][]) => { @@ -89,6 +115,15 @@ export const useDeviceBackendFilters = () => { [updateSearchParams], ); + const setOnlyFleetless = React.useCallback( + (enabled: boolean) => { + updateSearchParams( + getNewParams(paramsRef.current, { [FilterSearchParams.OnlyFleetless]: [enabled ? 'true' : ''] }), + ); + }, + [updateSearchParams], + ); + const setActiveStatuses = React.useCallback( (activeStatuses: FilterStatusMap) => { updateSearchParams(getNewParams(paramsRef.current, activeStatuses)); @@ -103,26 +138,34 @@ export const useDeviceBackendFilters = () => { [updateSearchParams], ); - const setNameOrAlias = React.useCallback( - (nameOrAlias: string) => { - updateSearchParams(getNewParams(paramsRef.current, { [FilterSearchParams.NameOrAlias]: [nameOrAlias] })); + const setTextFilter = React.useCallback( + (key: DeviceTextFilterKey, value: string) => { + updateSearchParams(getNewParams(paramsRef.current, { [key]: [value] })); }, [updateSearchParams], ); + const clearTextFilters = React.useCallback(() => { + updateSearchParams(getNewParams(paramsRef.current, buildTextFilterRemoval())); + }, [updateSearchParams]); + const hasFiltersEnabled = - !!nameOrAlias || !!selectedLabels.length || !!ownerFleets.length || - Object.values(activeStatuses).some((s) => !!s.length); + onlyFleetless || + Object.values(activeStatuses).some((s) => !!s.length) || + DEVICE_TEXT_FILTER_KEYS.some((key) => !!textFilters[key]); return { - nameOrAlias, - setNameOrAlias, + textFilters, + setTextFilter, + clearTextFilters, activeStatuses, setActiveStatuses, ownerFleets, setOwnerFleets, + onlyFleetless, + setOnlyFleetless, selectedLabels, setSelectedLabels, hasFiltersEnabled, diff --git a/libs/ui-components/src/components/Device/DevicesPage/useDevices.ts b/libs/ui-components/src/components/Device/DevicesPage/useDevices.ts index bc22546eb..75f958fdb 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/useDevices.ts +++ b/libs/ui-components/src/components/Device/DevicesPage/useDevices.ts @@ -1,7 +1,7 @@ import { useDebounce } from 'use-debounce'; import { Device, DeviceLifecycleStatusType, DeviceList, DevicesSummary } from '@flightctl/types'; -import { FilterSearchParams } from '../../../utils/status/devices'; +import { DeviceTextFilterKey, FilterSearchParams, isValidCveIdFilterValue } from '../../../utils/status/devices'; import * as queryUtils from '../../../utils/query'; import { useFetchPeriodically } from '../../../hooks/useFetchPeriodically'; import { FlightCtlLabel } from '../../../types/extraTypes'; @@ -10,7 +10,8 @@ import { PAGE_SIZE } from '../../../constants'; import { PaginationDetails, useTablePagination } from '../../../hooks/useTablePagination'; type DevicesEndpointArgs = { - nameOrAlias?: string; + /** Free-text filters synced with URL (name/alias, CVE ID, …). */ + textFilters?: Partial>; ownerFleets?: string[]; onlyFleetless?: boolean; activeStatuses?: FilterStatusMap; @@ -31,15 +32,18 @@ const decommissionedStatuses = [ ]; const getDevicesEndpoint = ({ - nameOrAlias, + textFilters, ownerFleets, activeStatuses, labels, onlyDecommissioned, + onlyFleetless, nextContinue, summaryOnly, - onlyFleetless, }: DevicesEndpointArgs) => { + const nameOrAlias = textFilters?.[FilterSearchParams.NameOrAlias]; + const cveId = textFilters?.[FilterSearchParams.CveId]; + const filterByAppStatus = activeStatuses?.[FilterSearchParams.AppStatus]; const filterByDevStatus = activeStatuses?.[FilterSearchParams.DeviceStatus]; const filterByUpdateStatus = activeStatuses?.[FilterSearchParams.UpdatedStatus]; @@ -52,7 +56,10 @@ const getDevicesEndpoint = ({ if (nameOrAlias) { queryUtils.addTextContainsCondition(fieldSelectors, 'metadata.nameOrAlias', nameOrAlias); } - if (ownerFleets?.length) { + + if (onlyFleetless) { + fieldSelectors.push('!metadata.owner'); + } else if (ownerFleets?.length) { queryUtils.addQueryConditions( fieldSelectors, 'metadata.owner', @@ -60,10 +67,6 @@ const getDevicesEndpoint = ({ ); } - if (onlyFleetless) { - fieldSelectors.push('!metadata.owner'); - } - if (onlyDecommissioned) { queryUtils.addQueryConditions(fieldSelectors, 'status.lifecycle.status', decommissionedStatuses); } else { @@ -74,6 +77,9 @@ const getDevicesEndpoint = ({ if (fieldSelectors.length > 0) { params.set('fieldSelector', fieldSelectors.join(',')); } + if (cveId && isValidCveIdFilterValue(cveId)) { + params.set('cveId', cveId); + } queryUtils.setLabelParams(params, labels); if (summaryOnly) { params.set('summaryOnly', 'true'); @@ -105,7 +111,6 @@ export const useDevicesSummary = ({ const [deviceList, listLoading] = useFetchPeriodically({ endpoint: devicesEndpoint, }); - return [deviceList?.summary, listLoading]; }; @@ -119,7 +124,7 @@ export type DevicesResult = { }; export const useDevices = (args: { - nameOrAlias?: string; + textFilters?: Partial>; ownerFleets?: string[]; activeStatuses?: FilterStatusMap; labels?: FlightCtlLabel[]; @@ -136,7 +141,6 @@ export const useDevices = (args: { }, args.onPageFetched, ); - const hasMore = !!devicesList?.metadata?.continue || (devicesList?.metadata?.remainingItemCount ?? 0) > 0; return { @@ -163,7 +167,7 @@ export type DevicesPaginatedResult = { * Use this for paginated tables/modals. */ export const useDevicesPaginated = (args: { - nameOrAlias?: string; + textFilters?: Partial>; ownerFleets?: string[]; onlyDecommissioned: boolean; onlyFleetless?: boolean; diff --git a/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx b/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx index a8544c96e..3f4d5f74d 100644 --- a/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx +++ b/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx @@ -4,7 +4,7 @@ import { Tbody } from '@patternfly/react-table'; import { SelectList, SelectOption, ToolbarItem } from '@patternfly/react-core'; import { MicrochipIcon } from '@patternfly/react-icons/dist/js/icons'; -import Table, { ApiSortTableColumn } from '../Table/Table'; +import Table from '../Table/Table'; import TableActions from '../Table/TableActions'; import ListPage from '../ListPage/ListPage'; import ListPageBody from '../ListPage/ListPageBody'; @@ -28,7 +28,7 @@ const EnrollmentRequestEmptyState = () => { return ; }; -const getEnrollmentColumns = (t: TFunction): ApiSortTableColumn[] => [ +const getEnrollmentColumns = (t: TFunction) => [ { name: t('Alias'), }, diff --git a/libs/ui-components/src/components/Fleet/FleetDetails/FleetDetailsContent.tsx b/libs/ui-components/src/components/Fleet/FleetDetails/FleetDetailsContent.tsx index 4eec7fa45..adb976b20 100644 --- a/libs/ui-components/src/components/Fleet/FleetDetails/FleetDetailsContent.tsx +++ b/libs/ui-components/src/components/Fleet/FleetDetails/FleetDetailsContent.tsx @@ -17,15 +17,19 @@ import LabelsView from '../../common/LabelsView'; import { getDateDisplay } from '../../../utils/dates'; import { getFleetRolloutStatusWarning } from '../../../utils/status/fleet'; import { useTranslation } from '../../../hooks/useTranslation'; +import { useVulnerabilitiesEnabled } from '../../../hooks/useServicesEnabled'; import RepositorySourceList from '../../Repository/RepositoryDetails/RepositorySourceList'; import FleetOwnerLink from './FleetOwnerLink'; import FleetDevicesCharts from './FleetDevicesCharts'; import FleetStatus from '../FleetStatus'; import FleetDevicesCount from './FleetDevicesCount'; import EventsCard from '../../Events/EventsCard'; +import FleetVulnerabilities from './FleetVulnerabilities'; const FleetDetailsContent = ({ fleet }: { fleet: Fleet }) => { const { t } = useTranslation(); + const [vulnerabilitiesEnabled, canListVulnerabilities] = useVulnerabilitiesEnabled(); + const showVulnerabilities = vulnerabilitiesEnabled && canListVulnerabilities; const fleetId = fleet.metadata.name as string; const devicesSummary = fleet.status?.devicesSummary; const rolloutError = getFleetRolloutStatusWarning(fleet, t); @@ -82,6 +86,16 @@ const FleetDetailsContent = ({ fleet }: { fleet: Fleet }) => { + + {showVulnerabilities && ( + + {t('Security overview')} + + + + + )} + {devicesSummary && ( {t('Fleet devices')} diff --git a/libs/ui-components/src/components/Fleet/FleetDetails/FleetVulnerabilities.tsx b/libs/ui-components/src/components/Fleet/FleetDetails/FleetVulnerabilities.tsx new file mode 100644 index 000000000..95a6198b5 --- /dev/null +++ b/libs/ui-components/src/components/Fleet/FleetDetails/FleetVulnerabilities.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +import { useVulnerabilities } from '../../../hooks/useVulnerabilities'; +import ListPageBody from '../../ListPage/ListPageBody'; +import VulnerabilitiesTable from '../../SecurityOverview/VulnerabilitiesTable'; + +const FleetVulnerabilities = ({ fleetId }: { fleetId: string }) => { + const { + vulnerabilities, + currentPage, + setCurrentPage, + itemCount, + search, + setSearch, + selectedSeverities, + setSelectedSeverities, + sortBy, + sortDirection, + onSort, + isLoading, + isUpdating, + error, + } = useVulnerabilities({ + endpoint: `vulnerabilities/fleets/${fleetId}`, + }); + + return ( + + + + ); +}; + +export default FleetVulnerabilities; diff --git a/libs/ui-components/src/components/Fleet/FleetsPage.tsx b/libs/ui-components/src/components/Fleet/FleetsPage.tsx index 3cbe9d0e3..0adbcad98 100644 --- a/libs/ui-components/src/components/Fleet/FleetsPage.tsx +++ b/libs/ui-components/src/components/Fleet/FleetsPage.tsx @@ -21,7 +21,7 @@ import ListPage from '../ListPage/ListPage'; import ListPageBody from '../ListPage/ListPageBody'; import TablePagination from '../Table/TablePagination'; import TableTextSearch from '../Table/TableTextSearch'; -import Table, { ApiSortTableColumn } from '../Table/Table'; +import Table from '../Table/Table'; import { useTableSelect } from '../../hooks/useTableSelect'; import { getResourceId } from '../../utils/resource'; import MassDeleteFleetModal from '../modals/massModals/MassDeleteFleetModal/MassDeleteFleetModal'; @@ -90,7 +90,7 @@ const FleetEmptyState = () => { ); }; -export const getFleetTableColumns = (t: TFunction): ApiSortTableColumn[] => [ +export const getFleetTableColumns = (t: TFunction) => [ { name: t('Name'), }, diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildAndExportStatus.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildAndExportStatus.tsx index 21ec3c76d..c15ec340f 100644 --- a/libs/ui-components/src/components/ImageBuilds/ImageBuildAndExportStatus.tsx +++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildAndExportStatus.tsx @@ -52,6 +52,8 @@ const getImageBuildStatusInfo = (condition: ImageBuildCondition | undefined, t: return { level: 'warning', label: t('Canceling') }; case ImageBuildConditionReason.ImageBuildConditionReasonCanceled: return { level: 'warning', label: t('Canceled') }; + case ImageBuildConditionReason.ImageBuildConditionReasonGeneratingSBOM: + return { level: 'info', label: t('Scanning for vulnerabilities') }; default: return { level: 'unknown', label: t('Unknown') }; } diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildsPage.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildsPage.tsx index 1f8fac206..7130b3b6c 100644 --- a/libs/ui-components/src/components/ImageBuilds/ImageBuildsPage.tsx +++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildsPage.tsx @@ -24,7 +24,7 @@ import ListPage from '../ListPage/ListPage'; import ListPageBody from '../ListPage/ListPageBody'; import TablePagination from '../Table/TablePagination'; import TableTextSearch from '../Table/TableTextSearch'; -import Table, { ApiSortTableColumn } from '../Table/Table'; +import Table from '../Table/Table'; import MassDeleteImageBuildModal from '../modals/massModals/MassDeleteImageBuildModal/MassDeleteImageBuildModal'; import CancelImageBuildModal from './CancelImageBuildModal/CancelImageBuildModal'; @@ -33,7 +33,7 @@ import { useImageBuilds, useImageBuildsBackendFilters } from './useImageBuilds'; import ImageBuildRow from './ImageBuildRow'; import { OciRegistriesContextProvider } from './OciRegistriesContext'; -const getColumns = (t: TFunction): ApiSortTableColumn[] => [ +const getColumns = (t: TFunction) => [ { name: t('Name'), }, diff --git a/libs/ui-components/src/components/OverviewPage/Cards/SecurityOverview/SecurityOverviewCard.tsx b/libs/ui-components/src/components/OverviewPage/Cards/SecurityOverview/SecurityOverviewCard.tsx new file mode 100644 index 000000000..f0b40c73e --- /dev/null +++ b/libs/ui-components/src/components/OverviewPage/Cards/SecurityOverview/SecurityOverviewCard.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import { + Bullseye, + Button, + Card, + CardBody, + CardHeader, + CardTitle, + Flex, + FlexItem, + Spinner, +} from '@patternfly/react-core'; + +import { useTranslation } from '../../../../hooks/useTranslation'; +import { ROUTE, useNavigate } from '../../../../hooks/useNavigate'; +import { useVulnerabilitySummary } from '../../../../hooks/useVulnerabilitySummary'; +import SecurityOverviewSummary from '../../../SecurityOverview/SecurityOverviewSummary'; +import LabelWithHelperText from '../../../common/WithHelperText'; + +const SecurityOverviewCard = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const { counts, isLoading } = useVulnerabilitySummary(); + const isEmpty = counts.total === 0; + + return ( + + + + + + + + + + {!isLoading && !isEmpty && ( + navigate(ROUTE.SECURITY_OVERVIEW)} + aria-label={t('View all CVEs')} + hasNoPadding + > + {t('View all CVEs')} + + )} + + + + + {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 ( <> - + {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 ( + + + + {vulnerability.cveId} + + + + + + {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 && ( + setIsExpanded((v) => !v)}> + {isExpanded ? t('Show less') : t('Show more')} + + )} + > + ); +}; + +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 ( + + + + ) : undefined + } + > + {item.label} + + ); +}; + +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'); + } +};