From 6627be1b735d01bf4527367e51dbb91376656a4b Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Thu, 3 Sep 2026 13:22:32 +0200 Subject: [PATCH 1/2] EDM-3863: Device details page redesign Made-with: Cursor --- .../components/Devices/DeviceDetailsPage.tsx | 16 +- libs/i18n/locales/en/translation.json | 51 +++-- .../DetailsPage/DetailsPageCard.tsx | 22 +- .../Device/DeviceDetails/DeviceAliasEdit.tsx | 52 +++-- .../DeviceDetails/DeviceApplications.tsx | 23 +- .../DeviceDetails/DeviceCustomDataCard.tsx | 36 ++++ .../DeviceDetails/DeviceDetailsPage.tsx | 36 ++-- .../DeviceDetailsStatusAccent.css | 11 + .../DeviceDetailsStatusAccent.tsx | 27 +++ .../Device/DeviceDetails/DeviceDetailsTab.css | 23 +- .../Device/DeviceDetails/DeviceDetailsTab.tsx | 135 ++---------- .../ConfigurationsContent.tsx | 148 ++++++------- .../DeviceDetailsTabContent/StatusContent.tsx | 139 ++++++------- .../SystemResourcesContent.tsx | 60 +++--- .../DeviceDetails/DeviceHealthAlert.tsx | 69 ++++++ .../DeviceDetails/DeviceInformationCard.tsx | 64 ++++++ .../DeviceDetails/DeviceLastSeenHeader.tsx | 21 ++ .../DeviceDetails/DeviceOverviewLayout.tsx | 83 ++++++++ .../DeviceSpecificationsCard.tsx | 92 ++++++++ .../Device/DeviceDetails/DeviceStatusCard.tsx | 60 ++++++ .../DeviceDetails/DeviceSystemdUnits.tsx | 11 +- .../DeviceDetails/DeviceVulnerabilities.tsx | 48 ++--- .../DeviceDetails/SidebarDescriptionList.tsx | 50 +++++ .../DevicesPage/EnrolledDeviceTableRow.tsx | 10 +- .../steps/ReviewDeviceStep.tsx | 4 +- .../EnrollmentRequestDetails.tsx | 24 +-- .../Fleet/CreateFleet/steps/ReviewStep.tsx | 4 +- .../FleetDetails/FleetDetailsContent.tsx | 19 +- .../FleetDetails/FleetVulnerabilities.tsx | 41 ++-- .../SecurityOverview/SecurityOverviewCard.tsx | 4 +- .../ConfigSourceSyncDetails.tsx | 44 ++-- .../ConfigurationSourcesHeader.tsx | 30 +++ .../RepositoryDetails/RepositorySource.tsx | 59 ++---- .../RepositorySourceList.tsx | 147 +++++++------ .../EntitySecurityOverviewCard.tsx | 160 ++++++++++++++ .../SecurityOverviewSummary.css | 72 +++++++ .../SecurityOverviewSummary.tsx | 196 +++++++++++++++--- .../VulnerabilitiesTableRow.tsx | 22 +- .../Status/ApplicationSummaryStatus.tsx | 2 +- .../Status/DeviceResourceStatus.tsx | 22 +- .../src/components/Status/DeviceStatus.tsx | 8 +- .../components/Status/SystemUpdateStatus.tsx | 8 +- .../src/components/Table/Table.css | 4 - .../src/components/Table/Table.tsx | 5 +- .../common/EditableLabelControl.tsx | 3 + .../src/components/common/OsModeContent.tsx | 22 +- .../src/components/common/WithHelperText.css | 10 + .../src/components/common/WithHelperText.tsx | 8 +- .../src/components/form/LabelsField.tsx | 21 +- .../modals/EditLabelsModal/EditLabelsForm.tsx | 5 +- .../src/hooks/useDeviceOverallHealth.ts | 95 +++++++++ .../src/hooks/useDeviceSpecSystemInfo.tsx | 100 ++------- .../src/hooks/useRepositoryDetailsMap.ts | 67 ++++++ .../src/hooks/useVulnerabilitySummary.ts | 33 ++- libs/ui-components/src/utils/capabilities.ts | 6 +- .../src/utils/status/applications.ts | 61 ++++-- libs/ui-components/src/utils/status/common.ts | 13 ++ .../ui-components/src/utils/status/devices.ts | 50 +++-- .../src/utils/status/integrity.ts | 20 +- .../src/utils/status/resources.ts | 13 ++ libs/ui-components/src/utils/status/system.ts | 20 +- .../src/utils/vulnerabilities.ts | 55 +++++ 62 files changed, 1925 insertions(+), 839 deletions(-) create mode 100644 libs/ui-components/src/components/Device/DeviceDetails/DeviceCustomDataCard.tsx create mode 100644 libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsStatusAccent.css create mode 100644 libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsStatusAccent.tsx create mode 100644 libs/ui-components/src/components/Device/DeviceDetails/DeviceHealthAlert.tsx create mode 100644 libs/ui-components/src/components/Device/DeviceDetails/DeviceInformationCard.tsx create mode 100644 libs/ui-components/src/components/Device/DeviceDetails/DeviceLastSeenHeader.tsx create mode 100644 libs/ui-components/src/components/Device/DeviceDetails/DeviceOverviewLayout.tsx create mode 100644 libs/ui-components/src/components/Device/DeviceDetails/DeviceSpecificationsCard.tsx create mode 100644 libs/ui-components/src/components/Device/DeviceDetails/DeviceStatusCard.tsx create mode 100644 libs/ui-components/src/components/Device/DeviceDetails/SidebarDescriptionList.tsx create mode 100644 libs/ui-components/src/components/Repository/RepositoryDetails/ConfigurationSourcesHeader.tsx create mode 100644 libs/ui-components/src/components/SecurityOverview/EntitySecurityOverviewCard.tsx delete mode 100644 libs/ui-components/src/components/Table/Table.css create mode 100644 libs/ui-components/src/components/common/WithHelperText.css create mode 100644 libs/ui-components/src/hooks/useDeviceOverallHealth.ts create mode 100644 libs/ui-components/src/hooks/useRepositoryDetailsMap.ts create mode 100644 libs/ui-components/src/utils/status/resources.ts diff --git a/apps/ocp-plugin/src/components/Devices/DeviceDetailsPage.tsx b/apps/ocp-plugin/src/components/Devices/DeviceDetailsPage.tsx index b61ea303d0..cb7afce295 100644 --- a/apps/ocp-plugin/src/components/Devices/DeviceDetailsPage.tsx +++ b/apps/ocp-plugin/src/components/Devices/DeviceDetailsPage.tsx @@ -1,7 +1,13 @@ import * as React from 'react'; import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; import ExclamationCircleIcon from '@patternfly/react-icons/dist/js/icons/exclamation-circle-icon'; -import { Icon, Popover, Stack, StackItem } from '@patternfly/react-core'; +import { + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Icon, + Popover, +} from '@patternfly/react-core'; import { useAppContext } from '@flightctl/ui-components/src/hooks/useAppContext'; import { useTranslation } from '@flightctl/ui-components/src/hooks/useTranslation'; import DeviceDetails from '@flightctl/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage'; @@ -65,10 +71,10 @@ const DeviceDetailsPage = () => { return ( - - {t('MicroShift cluster')} - {mcContent} - + + {t('MicroShift cluster')} + {mcContent} + ); diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index c5ecab72d3..26af41801b 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -569,6 +569,8 @@ "Please try refreshing the page. If the problem persists, contact your administrator.": "Please try refreshing the page. If the problem persists, contact your administrator.", "Error details": "Error details", "Reload organizations": "Reload organizations", + "Image": "Image", + "Package": "Package", "Image mode: OS uses bootable container images": "Image mode: OS uses bootable container images", "Package mode: OS managed outside of Edge Manager": "Package mode: OS managed outside of Edge Manager", "OS mode: Unknown": "OS mode: Unknown", @@ -635,6 +637,9 @@ "Alias": "Alias", "Edit alias": "Edit alias", "Device alias could not be updated": "Device alias could not be updated", + "{{count}} application errors_one": "{{count}} application error", + "{{count}} application errors_other": "{{count}} application errors", + "Custom data": "Custom data", "Failed to fetch owner fleet": "Failed to fetch owner fleet", "Delete forever": "Delete forever", "You are about to resume <1>{deviceNameOrAlias}": "You are about to resume <1>{deviceNameOrAlias}", @@ -643,19 +648,12 @@ "Events": "Events", "Edit device configurations": "Edit device configurations", "Delete device forever": "Delete device forever", - "Fleet name": "Fleet name", - "Labels": "Labels", - "Custom data": "Custom data", "Indicates whether the device is available to be managed and assigned to do work or is moving to an end-of-life state.": "Indicates whether the device is available to be managed and assigned to do work or is moving to an end-of-life state.", + "Configurations": "Configurations", "Application status is not available for decommissioned devices": "Application status is not available for decommissioned devices", - "System image": "System image", "This device uses package-based OS management and cannot satisfy the fleet's OS image requirement. The device will remain out of date and unable to apply any fleet updates. To resolve this, remove the device from the fleet or update it to a bootable container image.": "This device uses package-based OS management and cannot satisfy the fleet's OS image requirement. The device will remain out of date and unable to apply any fleet updates. To resolve this, remove the device from the fleet or update it to a bootable container image.", - "OS image status not fully determined": "OS image status not fully determined", - "The device is bound to a fleet, but its OS image status could not be determined.": "The device is bound to a fleet, but its OS image status could not be determined.", + "The device is bound to a fleet, and its OS image status could not be determined.": "The device is bound to a fleet, and its OS image status could not be determined.", "System image (running)": "System image (running)", - "Configurations": "Configurations", - "Sources ({{size}})": "Sources ({{size}})", - "System status": "System status", "Application status": "Application status", "Indicates the overall status of application workloads on the device.": "Indicates the overall status of application workloads on the device.", "Device status": "Device status", @@ -664,8 +662,6 @@ "Indicates whether a system is running the latest target configuration or is updating towards it.": "Indicates whether a system is running the latest target configuration or is updating towards it.", "Integrity status": "Integrity status", "Indicates whether the device has been verified as secure and authentic.": "Indicates whether the device has been verified as secure and authentic.", - "Last seen": "Last seen", - "Resource status": "Resource status", "CPU pressure": "CPU pressure", "Disk pressure": "Disk pressure", "Memory pressure": "Memory pressure", @@ -673,6 +669,12 @@ "Device labels don't match any fleet's selector labels": "Device labels don't match any fleet's selector labels", "Ownership information": "Ownership information", "Device labels match multiple fleets:": "Device labels match multiple fleets:", + "{{count}} status issues_one": "{{count}} status issue", + "{{count}} status issues_other": "{{count}} status issues", + "Issues detected": "Issues detected", + "Device information": "Device information", + "Labels": "Labels", + "Last seen {{time}}": "Last seen {{time}}", "Log file not found": "Log file not found", "The requested log file could not be found on the device. Verify the file path is correct and the logs exist on the device, then try again.": "The requested log file could not be found on the device. Verify the file path is correct and the logs exist on the device, then try again.", "Path is a directory": "Path is a directory", @@ -736,7 +738,14 @@ "Running system image is unknown": "Running system image is unknown", "System image mismatch": "System image mismatch", "Desired system image": "Desired system image", + "Device specifications": "Device specifications", + "Hide full system info": "Hide full system info", + "Show full system info": "Show full system info", + "Capabilities": "Capabilities", + "System status": "System status", + "Resource status": "Resource status", "System services": "System services", + "OS mode": "OS mode", "You do not have permission to access the device terminal.": "You do not have permission to access the device terminal.", "Console type": "Console type", "Device terminal": "Device terminal", @@ -758,7 +767,6 @@ "Networking details": "Networking details", "Toggle networking details": "Toggle networking details", "VM details for {{name}}": "VM details for {{name}}", - "Image": "Image", "Run as user": "Run as user", "Embedded": "Embedded", "Yes": "Yes", @@ -777,7 +785,6 @@ "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!", "View and manage your enrolled edge devices. Monitor device status, configurations, and fleet assignments.": "View and manage your enrolled edge devices. Monitor device status, configurations, and fleet assignments.", - "OS mode": "OS mode", "Clear all filters": "Clear all filters", "Searching...": "Searching...", "No results": "No results", @@ -963,6 +970,7 @@ "view documentation": "view documentation", "Using template variables": "Using template variables", "Add a variable by using <1>{templateOption1} or <3>{templateOption2} and it will be applied based on each device’s details. For example, you could set the following value to apply different files in a Git configuration:": "Add a variable by using <1>{templateOption1} or <3>{templateOption2} and it will be applied based on each device’s details. For example, you could set the following value to apply different files in a Git configuration:", + "System image": "System image", "The target system image for this fleet's devices.": "The target system image for this fleet's devices.", "The target system image for this device.": "The target system image for this device.", "System image is managed outside of Edge Manager": "System image is managed outside of Edge Manager", @@ -1000,6 +1008,7 @@ "Enter image reference or choose from catalog": "Enter image reference or choose from catalog", "Choose from catalog": "Choose from catalog", "Approve": "Approve", + "Last seen": "Last seen", "Certificate signing request": "Certificate signing request", "A PEM-encoded PKCS#10 certificate signing request.": "A PEM-encoded PKCS#10 certificate signing request.", "Certificate Signing Request": "Certificate Signing Request", @@ -1096,6 +1105,7 @@ "{{ count }} devices matching the labels were selected._one": "{{ count }} device matching the labels was selected.", "{{ count }} devices matching the labels were selected._other": "{{ count }} devices matching the labels were selected.", "Labels used to select devices for your fleet. If not specified, no devices will be added.": "Labels used to select devices for your fleet. If not specified, no devices will be added.", + "Fleet name": "Fleet name", "Fleet labels": "Fleet labels", "Device selector": "Device selector", "Rollout policy": "Rollout policy", @@ -1788,7 +1798,9 @@ "Enter a valid HTTP service URL. Example: https://my-service-url": "Enter a valid HTTP service URL. Example: https://my-service-url", "HTTP service URL is required": "HTTP service URL is required", "Copy fingerprint": "Copy fingerprint", - "Last synced": "Last synced", + "Last synced {{syncTime}}": "Last synced {{syncTime}}", + "Sources": "Sources", + "Number of configurations": "Number of configurations", "Deleting {{count}} resource sync_one": "Deleting {{count}} resource sync", "Deleting {{count}} resource sync_other": "Deleting {{count}} resource syncs", "{{count}} resource sync could not be deleted. Try deleting it manually._one": "{{count}} resource sync could not be deleted. Try deleting it manually.", @@ -1830,11 +1842,13 @@ "Resource sync {{rsId}} could not be found": "Resource sync {{rsId}} could not be found", "Resource sync {{rsId}}": "Resource sync {{rsId}}", "Could not find the details for the resource sync <1>{rsId}": "Could not find the details for the resource sync <1>{rsId}", + "Toggle security details": "Toggle security details", "Monitor security findings across your edge environment. Review vulnerabilities to identify affected devices and images.": "Monitor security findings across your edge environment. Review vulnerabilities to identify affected devices and images.", "Vulnerability reporting is not enabled in this environment.": "Vulnerability reporting is not enabled in this environment.", + "Filter table by {{severity}} severity": "Filter table by {{severity}} severity", + "Vulnerability counts by severity": "Vulnerability counts by severity", "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 vulnerabilities were found affecting the image currently deployed on this device. This reflects the most recent scan data available.": "No vulnerabilities were found affecting the image currently deployed on this device. This reflects the most recent scan data available.", "No vulnerabilities were found affecting images currently deployed across all devices in this fleet. This reflects the most recent scan data available.": "No vulnerabilities were found affecting images currently deployed across all devices in this fleet. This reflects the most recent scan data available.", "No vulnerabilities detected": "No vulnerabilities detected", @@ -1937,10 +1951,11 @@ "End active session": "End active session", "Console session unavailable": "Console session unavailable", "Log retrieval failed with exit code {{code}}": "Log retrieval failed with exit code {{code}}", - "Architecture": "Architecture", "Agent version": "Agent version", - "Distro": "Distro", "Hostname": "Hostname", + "TPM vendor info": "TPM vendor info", + "Architecture": "Architecture", + "Distro": "Distro", "Boot ID": "Boot ID", "Kernel": "Kernel", "Net interface default": "Net interface default", @@ -1949,8 +1964,6 @@ "Product name": "Product name", "Product serial": "Product serial", "Product UUID": "Product UUID", - "TPM vendor info": "TPM vendor info", - "Package": "Package", "Unknown error": "Unknown error", "Failed to connect to device terminal: {{reason}}": "Failed to connect to device terminal: {{reason}}", "Failed to connect to device terminal": "Failed to connect to device terminal", diff --git a/libs/ui-components/src/components/DetailsPage/DetailsPageCard.tsx b/libs/ui-components/src/components/DetailsPage/DetailsPageCard.tsx index 606e3a3726..219ed69abb 100644 --- a/libs/ui-components/src/components/DetailsPage/DetailsPageCard.tsx +++ b/libs/ui-components/src/components/DetailsPage/DetailsPageCard.tsx @@ -1,5 +1,25 @@ import * as React from 'react'; -import { Card, type CardProps } from '@patternfly/react-core'; +import { Card, type CardProps, CardTitle, Flex, FlexItem, Icon } from '@patternfly/react-core'; + +export const DetailsPageCardTitle = ({ + icon, + title, + badge, +}: { + title: string; + icon: React.ReactNode; + badge?: React.ReactNode; +}) => ( + + + + {icon} + + {title} + {badge} + + +); const DetailsPageCard = (props: CardProps) => ; diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceAliasEdit.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceAliasEdit.tsx index 1aa0d6c0e0..ec5211476a 100644 --- a/libs/ui-components/src/components/Device/DeviceDetails/DeviceAliasEdit.tsx +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceAliasEdit.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Alert, Button, Grid, GridItem, Icon } from '@patternfly/react-core'; +import { Alert, Button, Flex, FlexItem, Icon } from '@patternfly/react-core'; import { PencilAltIcon } from '@patternfly/react-icons/dist/js/icons/pencil-alt-icon'; import { Formik, useField } from 'formik'; import * as Yup from 'yup'; @@ -129,7 +129,7 @@ const DeviceAliasEdit = ({ deviceId, hasLabels, alias: originalAlias = '', onAli ); return ( - + initialValues={{ alias: originalAlias, @@ -140,7 +140,7 @@ const DeviceAliasEdit = ({ deviceId, hasLabels, alias: originalAlias = '', onAli validateOnBlur onSubmit={onSubmit} > - + {isEditing ? ( ) : ( - <> - {originalAlias || t('Untitled')} - + ); +}; + +const DeviceHealthAlert = ({ deviceHealth }: { deviceHealth: DeviceOverallHealth }) => { + const { t } = useTranslation(); + const alertRef = React.useRef(null); + + // PatternFly Alert manages expand state internally (defaults collapsed); expand on mount so jump links are visible. + React.useLayoutEffect(() => { + const toggle = alertRef.current?.querySelector('.pf-v6-c-alert__toggle button'); + if (toggle?.getAttribute('aria-expanded') !== 'true') { + toggle?.click(); + } + }, []); + + if (deviceHealth.level === null) { + return null; + } + + const { statusHealth, appsHealth } = deviceHealth; + return ( +
+ + + {statusHealth.itemCount > 0 && ( + + + + )} + {appsHealth.itemCount > 0 && ( + + + + )} + + +
+ ); +}; + +export default DeviceHealthAlert; diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceInformationCard.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceInformationCard.tsx new file mode 100644 index 0000000000..845f55ef87 --- /dev/null +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceInformationCard.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { + CardBody, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, +} from '@patternfly/react-core'; +import IdBadgeIcon from '@patternfly/react-icons/dist/js/icons/id-badge-icon'; + +import { type Device } from '@flightctl/types'; +import { useTranslation } from '../../../hooks/useTranslation'; +import EditLabelsForm, { ViewLabels } from '../../modals/EditLabelsModal/EditLabelsForm'; +import ResourceLink from '../../common/ResourceLink'; +import DetailsPageCard, { DetailsPageCardTitle } from '../../DetailsPage/DetailsPageCard'; +import DeviceFleet from './DeviceFleet'; +import SidebarDescriptionList from './SidebarDescriptionList'; + +import './DeviceDetailsTab.css'; + +type DeviceInformationCardProps = { + device: Required; + refetch: VoidFunction; + canEdit: boolean; +}; + +const DeviceInformationCard = ({ + device, + refetch, + canEdit, + children, +}: React.PropsWithChildren) => { + const { t } = useTranslation(); + + return ( + + } /> + + + + {t('Name')} + + + + + + {t('Fleet')} + + + + + + {t('Labels')} + + {canEdit ? : } + + + {children} + + + + ); +}; + +export default DeviceInformationCard; diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceLastSeenHeader.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceLastSeenHeader.tsx new file mode 100644 index 0000000000..8914784a17 --- /dev/null +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceLastSeenHeader.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { Content } from '@patternfly/react-core'; + +import { type Device, type DeviceLastSeen } from '@flightctl/types'; +import { useTranslation } from '../../../hooks/useTranslation'; +import { useFetchPeriodically } from '../../../hooks/useFetchPeriodically'; +import { timeSinceText } from '../../../utils/dates'; + +const LAST_SEEN_REFRESH_INTERVAL = 60 * 1000; + +const DeviceLastSeenHeader = ({ device }: { device: Required }) => { + const { t } = useTranslation(); + const [lastSeenResponse] = useFetchPeriodically({ + endpoint: `devices/${device.metadata.name}/lastseen`, + timeout: LAST_SEEN_REFRESH_INTERVAL, + }); + + return {t('Last seen {{time}}', { time: timeSinceText(t, lastSeenResponse?.lastSeen) })}; +}; + +export default DeviceLastSeenHeader; diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceOverviewLayout.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceOverviewLayout.tsx new file mode 100644 index 0000000000..b46a74f797 --- /dev/null +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceOverviewLayout.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { Grid, GridItem, Stack, StackItem } from '@patternfly/react-core'; + +import { type Device } from '@flightctl/types'; +import { useDeviceOverallHealth } from '../../../hooks/useDeviceOverallHealth'; +import { useVulnerabilitiesEnabled } from '../../../hooks/useServicesEnabled'; +import DeviceInformationCard from './DeviceInformationCard'; +import DeviceSpecificationsCard from './DeviceSpecificationsCard'; +import DeviceCustomDataCard from './DeviceCustomDataCard'; +import DeviceStatusCard from './DeviceStatusCard'; +import DeviceApplications from './DeviceApplications'; +import DeviceVulnerabilities from './DeviceVulnerabilities'; +import DeviceSystemdUnits from './DeviceSystemdUnits'; +import DeviceHealthAlert from './DeviceHealthAlert'; + +import './DeviceDetailsTab.css'; + +type DeviceOverviewLayoutProps = { + device: Required; + refetch: VoidFunction; + canEdit: boolean; +}; + +const DeviceOverviewLayout = ({ + device, + refetch, + canEdit, + children, +}: React.PropsWithChildren) => { + const deviceHealth = useDeviceOverallHealth(device); + const [vulnerabilitiesEnabled, canListVulnerabilities] = useVulnerabilitiesEnabled(); + const showVulnerabilities = vulnerabilitiesEnabled && canListVulnerabilities; + + const customInfo = Object.entries(device.status?.systemInfo?.customInfo || {}); + + return ( + + + + + + + + + + + + {showVulnerabilities && ( + + + + )} + + + + + + + + + + {children} + + + + + + {customInfo.length > 0 && ( + + + + )} + + + + + + + + ); +}; + +export default DeviceOverviewLayout; diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceSpecificationsCard.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceSpecificationsCard.tsx new file mode 100644 index 0000000000..2bb41ff699 --- /dev/null +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceSpecificationsCard.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import { CardBody, Divider, ExpandableSection, Stack, StackItem, Title } from '@patternfly/react-core'; +import { AddressCardIcon } from '@patternfly/react-icons/dist/js/icons/address-card-icon'; + +import { type Device } from '@flightctl/types'; +import { useTranslation } from '../../../hooks/useTranslation'; +import { useDeviceSpecSystemInfo } from '../../../hooks/useDeviceSpecSystemInfo'; +import DetailsPageCard, { DetailsPageCardTitle } from '../../DetailsPage/DetailsPageCard'; +import ConfigurationsContent from './DeviceDetailsTabContent/ConfigurationsContent'; +import { CapabilitiesFieldsList, SystemInfoFieldsList } from './SidebarDescriptionList'; + +import './DeviceDetailsTab.css'; + +// By default only show the first 4 fields, with the rest shown in an expandable section +// However, if there are less than 8 fields, show all of them without needing to expand +const EXPAND_SYSTEM_INFO_COUNT = 4; +const MIN_SYSTEM_INFO_FIELDS_FOR_EXPAND = 8; + +const DeviceSpecificationsCard = ({ device }: { device: Required }) => { + const { t } = useTranslation(); + const systemInfoFields = useDeviceSpecSystemInfo(device.status?.systemInfo, t); + + const [showMoreSystemInfo, setShowMoreSystemInfo] = React.useState(false); + + const { visibleSystemInfoFields, expandableSystemInfoFields } = React.useMemo(() => { + if (systemInfoFields.length < MIN_SYSTEM_INFO_FIELDS_FOR_EXPAND) { + return { + visibleSystemInfoFields: systemInfoFields, + expandableSystemInfoFields: [], + }; + } + + return { + visibleSystemInfoFields: systemInfoFields.slice(0, EXPAND_SYSTEM_INFO_COUNT), + expandableSystemInfoFields: systemInfoFields.slice(EXPAND_SYSTEM_INFO_COUNT), + }; + }, [systemInfoFields]); + + return ( + + } /> + + + {visibleSystemInfoFields.length > 0 && ( + + + + )} + {expandableSystemInfoFields.length > 0 && ( + + setShowMoreSystemInfo(expanded)} + isExpanded={showMoreSystemInfo} + > + + + + )} + + + + + + {t('Capabilities')} + + + + + + + + + + + + + {t('Configurations')} + + + + + + + + + + + ); +}; + +export default DeviceSpecificationsCard; diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceStatusCard.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceStatusCard.tsx new file mode 100644 index 0000000000..a4e238821b --- /dev/null +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceStatusCard.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { CardBody, Label, Stack, StackItem, Title } from '@patternfly/react-core'; +import TachometerAltIcon from '@patternfly/react-icons/dist/js/icons/tachometer-alt-icon'; + +import type { Device } from '@flightctl/types'; +import type { DeviceHealthItem } from '../../../hooks/useDeviceOverallHealth'; +import { useTranslation } from '../../../hooks/useTranslation'; +import DetailsPageCard, { DetailsPageCardTitle } from '../../DetailsPage/DetailsPageCard'; +import StatusContent from './DeviceDetailsTabContent/StatusContent'; +import SystemResourcesContent from './DeviceDetailsTabContent/SystemResourcesContent'; + +const DEVICE_STATUS_CARD_ID = 'device-status-card'; + +const DeviceStatusCard = ({ device, health }: { device: Required; health: DeviceHealthItem }) => { + const { t } = useTranslation(); + + return ( + + } + badge={ + health.level !== null && ( + + ) + } + /> + + + + + + + {t('System status')} + + + + + + + + + + + + {t('Resource status')} + + + + + + + + + + + ); +}; + +export default DeviceStatusCard; diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceSystemdUnits.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceSystemdUnits.tsx index 58f5354ac7..87941e3fe8 100644 --- a/libs/ui-components/src/components/Device/DeviceDetails/DeviceSystemdUnits.tsx +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceSystemdUnits.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; -import { CardBody, CardTitle, Flex, FlexItem } from '@patternfly/react-core'; +import { CardBody } from '@patternfly/react-core'; +import CogIcon from '@patternfly/react-icons/dist/js/icons/cog-icon'; import { type Device } from '@flightctl/types'; import { useTranslation } from '../../../hooks/useTranslation'; import SystemdUnitsTable from '../../DetailsPage/Tables/SystemdUnitsTable'; -import DetailsPageCard from '../../DetailsPage/DetailsPageCard'; +import DetailsPageCard, { DetailsPageCardTitle } from '../../DetailsPage/DetailsPageCard'; type DeviceSystemdUnitsProps = { device: Required; @@ -15,11 +16,7 @@ const DeviceSystemdUnits = ({ device }: DeviceSystemdUnitsProps) => { return ( - - - {t('System services')} - - + } /> diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceVulnerabilities.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceVulnerabilities.tsx index 1670148696..3361a4a293 100644 --- a/libs/ui-components/src/components/Device/DeviceDetails/DeviceVulnerabilities.tsx +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceVulnerabilities.tsx @@ -1,16 +1,13 @@ import * as React from 'react'; -import { CardBody, CardTitle } from '@patternfly/react-core'; import { type VulnerabilityList } from '@flightctl/types/alpha'; -import { useTranslation } from '../../../hooks/useTranslation'; +import { useDeviceVulnerabilitySummary } from '../../../hooks/useVulnerabilitySummary'; import { useVulnerabilities } from '../../../hooks/useVulnerabilities'; -import ListPageBody from '../../ListPage/ListPageBody'; -import DetailsPageCard from '../../DetailsPage/DetailsPageCard'; -import VulnerabilitiesTable from '../../SecurityOverview/VulnerabilitiesTable'; +import EntitySecurityOverviewCard from '../../SecurityOverview/EntitySecurityOverviewCard'; const DeviceVulnerabilities = ({ deviceId }: { deviceId: string }) => { - const { t } = useTranslation(); + const { counts, isLoading: isSummaryLoading } = useDeviceVulnerabilitySummary(deviceId); const { vulnerabilities, currentPage, @@ -31,26 +28,25 @@ const DeviceVulnerabilities = ({ deviceId }: { deviceId: string }) => { }); return ( - - {t('Security overview')} - - - - - - + ); }; diff --git a/libs/ui-components/src/components/Device/DeviceDetails/SidebarDescriptionList.tsx b/libs/ui-components/src/components/Device/DeviceDetails/SidebarDescriptionList.tsx new file mode 100644 index 0000000000..5e3f971fe8 --- /dev/null +++ b/libs/ui-components/src/components/Device/DeviceDetails/SidebarDescriptionList.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, +} from '@patternfly/react-core'; +import { type DeviceCapabilities } from '@flightctl/types'; + +import type { SystemInfoEntry } from '../../../hooks/useDeviceSpecSystemInfo'; +import { useTranslation } from '../../../hooks/useTranslation'; +import { getDeviceCapability } from '../../../utils/capabilities'; +import { OsModeLabel } from '../../common/OsModeContent'; + +const SidebarDescriptionList = ({ children }: React.PropsWithChildren) => ( + + {children} + +); + +export const SystemInfoFieldsList = ({ fields }: { fields: SystemInfoEntry[] }) => { + return ( + + {fields.map((field, index) => ( + + {field.title} + {field.value} + + ))} + + ); +}; + +export const CapabilitiesFieldsList = ({ capabilities }: { capabilities: DeviceCapabilities | undefined }) => { + const { t } = useTranslation(); + + const osModeCapability = getDeviceCapability(capabilities, 'osMode'); + return ( + + + {t('OS mode')} + + + + + + ); +}; + +export default SidebarDescriptionList; diff --git a/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx b/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx index 2f28676736..365f49d989 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx +++ b/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx @@ -14,7 +14,7 @@ import SystemUpdateStatus from '../../Status/SystemUpdateStatus'; import { useTranslation } from '../../../hooks/useTranslation'; import { ROUTE, useNavigate } from '../../../hooks/useNavigate'; import ResourceLink from '../../common/ResourceLink'; -import OsModeContent from '../../common/OsModeContent'; +import OsModeIcon from '../../common/OsModeContent'; import { buildAllDropdownActions } from '../../common/ActionsDropdownList'; import { type ApiTableColumn } from '../../Table/Table'; @@ -51,7 +51,7 @@ const EnrolledDeviceTableRow = ({ const navigate = useNavigate(); const deviceName = device.metadata.name as string; const deviceAlias = device.metadata.labels?.alias; - const osMode = getDeviceCapability(device, 'osMode'); + const osMode = getDeviceCapability(device.status?.capabilities, 'osMode'); const editActionProps = getDisabledTooltipProps(getEditDisabledReason(device, t)); const decommissionDisabledReason = getDecommissionDisabledReason(device, t); const resumeDisabledReason = getResumeDisabledReason(device, t); @@ -114,7 +114,7 @@ const EnrolledDeviceTableRow = ({ flexWrap={{ default: 'nowrap' }} > - + - + )} {columnIds.includes('updateStatus') && ( - + )} {!hideActions && ( diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewDeviceStep.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewDeviceStep.tsx index fb9de24f79..4775024fa4 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewDeviceStep.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewDeviceStep.tsx @@ -15,7 +15,7 @@ import { useTranslation } from '../../../../hooks/useTranslation'; import LabelsView from '../../../common/LabelsView'; import { toAPILabel } from '../../../../utils/labels'; import { getErrorMessage } from '../../../../utils/error'; -import RepositorySourceList from '../../../Repository/RepositoryDetails/RepositorySourceList'; +import { RepositorySourcePlainList } from '../../../Repository/RepositoryDetails/RepositorySourceList'; import { getApiConfig } from '../deviceSpecUtils'; import ReviewApplications from './ReviewApplications'; import SystemImage from '../SystemImageDescriptionGroup'; @@ -59,7 +59,7 @@ const ReviewStep = ({ error }: { error?: string }) => { {t('Configurations')} - + )} diff --git a/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestDetails/EnrollmentRequestDetails.tsx b/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestDetails/EnrollmentRequestDetails.tsx index e0e35a72cf..8c121e59f0 100644 --- a/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestDetails/EnrollmentRequestDetails.tsx +++ b/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestDetails/EnrollmentRequestDetails.tsx @@ -26,6 +26,7 @@ import { import { useFetch } from '../../../hooks/useFetch'; import ApproveDeviceModal from '../../modals/ApproveDeviceModal/ApproveDeviceModal'; import DetailsPageCard from '../../DetailsPage/DetailsPageCard'; +import DeviceCustomDataCard from '../../Device/DeviceDetails/DeviceCustomDataCard'; import DetailsPageActions, { useDeleteAction } from '../../DetailsPage/DetailsPageActions'; import EnrollmentRequestStatus from '../../Status/EnrollmentRequestStatus'; import LabelWithHelperText from '../../common/WithHelperText'; @@ -60,7 +61,10 @@ const EnrollmentRequestDetails = () => { const [canApprove, canDelete] = checkPermissions(enrollmentRequestDetailsPermissions); const [isApprovalModalOpen, setIsApprovalModalOpen] = React.useState(false); - const erSystemInfo = useDeviceSpecSystemInfo(er?.spec.deviceStatus, t); + const systemInfo = er?.spec.deviceStatus?.systemInfo; + const erSystemInfo = useDeviceSpecSystemInfo(systemInfo, t); + const customInfo = Object.entries(systemInfo?.customInfo || {}); + const hasDefaultLabels = Object.keys(er?.spec.labels || {}).length > 0; const deviceId = er?.metadata.name as string; @@ -130,7 +134,7 @@ const EnrollmentRequestDetails = () => { - {erSystemInfo.baseInfo.map((systemInfo) => ( + {erSystemInfo.map((systemInfo) => ( {systemInfo.title} {systemInfo.value} @@ -140,21 +144,9 @@ const EnrollmentRequestDetails = () => {
- {erSystemInfo.customInfo.length > 0 && ( + {customInfo.length > 0 && ( - - {t('Custom data')} - - - {erSystemInfo.customInfo.map((systemInfo) => ( - - {systemInfo.title} - {systemInfo.value} - - ))} - - - + )} diff --git a/libs/ui-components/src/components/Fleet/CreateFleet/steps/ReviewStep.tsx b/libs/ui-components/src/components/Fleet/CreateFleet/steps/ReviewStep.tsx index e0ea2ef40d..129477b839 100644 --- a/libs/ui-components/src/components/Fleet/CreateFleet/steps/ReviewStep.tsx +++ b/libs/ui-components/src/components/Fleet/CreateFleet/steps/ReviewStep.tsx @@ -14,7 +14,7 @@ import { useTranslation } from '../../../../hooks/useTranslation'; import { type FleetFormValues, UpdateMode } from '../../../../types/deviceSpec'; import LabelsView from '../../../common/LabelsView'; import { toAPILabel } from '../../../../utils/labels'; -import RepositorySourceList from '../../../Repository/RepositoryDetails/RepositorySourceList'; +import { RepositorySourcePlainList } from '../../../Repository/RepositoryDetails/RepositorySourceList'; import { getErrorMessage } from '../../../../utils/error'; import { getApiConfig } from '../../../Device/EditDeviceWizard/deviceSpecUtils'; import SystemImage from '../../../Device/EditDeviceWizard/SystemImageDescriptionGroup'; @@ -67,7 +67,7 @@ const ReviewStep = ({ error }: { error?: unknown }) => { {t('Configurations')} - + )} diff --git a/libs/ui-components/src/components/Fleet/FleetDetails/FleetDetailsContent.tsx b/libs/ui-components/src/components/Fleet/FleetDetails/FleetDetailsContent.tsx index 384da01422..9ae0c15710 100644 --- a/libs/ui-components/src/components/Fleet/FleetDetails/FleetDetailsContent.tsx +++ b/libs/ui-components/src/components/Fleet/FleetDetails/FleetDetailsContent.tsx @@ -18,7 +18,8 @@ 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 { RepositorySourcePlainList } from '../../Repository/RepositoryDetails/RepositorySourceList'; +import ConfigurationSourcesHeader from '../../Repository/RepositoryDetails/ConfigurationSourcesHeader'; import FleetOwnerLink from './FleetOwnerLink'; import FleetDevicesCharts from './FleetDevicesCharts'; import FleetStatus from '../FleetStatus'; @@ -38,6 +39,9 @@ const FleetDetailsContent = ({ fleet }: { fleet: Fleet }) => { const devicesSummary = fleet.status?.devicesSummary; const osModeCounts = devicesSummary?.capabilities?.osMode; + const fleetConfig = fleet.spec.template.spec.config || []; + const fleetConfigCount = fleetConfig.length; + return ( @@ -91,10 +95,10 @@ const FleetDetailsContent = ({ fleet }: { fleet: Fleet }) => { - {t('Sources ({{size}})', { size: fleet.spec.template.spec.config?.length || 0 })} + - + @@ -102,12 +106,9 @@ const FleetDetailsContent = ({ fleet }: { fleet: Fleet }) => {
{showVulnerabilities && ( - - {t('Security overview')} - - - - +
+ +
)} {devicesSummary && ( diff --git a/libs/ui-components/src/components/Fleet/FleetDetails/FleetVulnerabilities.tsx b/libs/ui-components/src/components/Fleet/FleetDetails/FleetVulnerabilities.tsx index 95a6198b5a..54b3609690 100644 --- a/libs/ui-components/src/components/Fleet/FleetDetails/FleetVulnerabilities.tsx +++ b/libs/ui-components/src/components/Fleet/FleetDetails/FleetVulnerabilities.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; +import { useFleetVulnerabilitySummary } from '../../../hooks/useVulnerabilitySummary'; import { useVulnerabilities } from '../../../hooks/useVulnerabilities'; -import ListPageBody from '../../ListPage/ListPageBody'; -import VulnerabilitiesTable from '../../SecurityOverview/VulnerabilitiesTable'; +import EntitySecurityOverviewCard from '../../SecurityOverview/EntitySecurityOverviewCard'; const FleetVulnerabilities = ({ fleetId }: { fleetId: string }) => { + const { counts, isLoading: isSummaryLoading } = useFleetVulnerabilitySummary(fleetId); const { vulnerabilities, currentPage, @@ -25,22 +26,26 @@ const FleetVulnerabilities = ({ fleetId }: { fleetId: string }) => { }); return ( - - - + ); }; diff --git a/libs/ui-components/src/components/OverviewPage/Cards/SecurityOverview/SecurityOverviewCard.tsx b/libs/ui-components/src/components/OverviewPage/Cards/SecurityOverview/SecurityOverviewCard.tsx index 7de8d02d53..6c97fbc11c 100644 --- a/libs/ui-components/src/components/OverviewPage/Cards/SecurityOverview/SecurityOverviewCard.tsx +++ b/libs/ui-components/src/components/OverviewPage/Cards/SecurityOverview/SecurityOverviewCard.tsx @@ -14,7 +14,7 @@ import { import { useTranslation } from '../../../../hooks/useTranslation'; import { ROUTE, useNavigate } from '../../../../hooks/useNavigate'; import { useVulnerabilitySummary } from '../../../../hooks/useVulnerabilitySummary'; -import SecurityOverviewSummary from '../../../SecurityOverview/SecurityOverviewSummary'; +import { SecurityOverviewSummaryOverview } from '../../../SecurityOverview/SecurityOverviewSummary'; import LabelWithHelperText from '../../../common/WithHelperText'; const SecurityOverviewCard = () => { @@ -63,7 +63,7 @@ const SecurityOverviewCard = () => { ) : ( - + )} diff --git a/libs/ui-components/src/components/Repository/RepositoryDetails/ConfigSourceSyncDetails.tsx b/libs/ui-components/src/components/Repository/RepositoryDetails/ConfigSourceSyncDetails.tsx index 339d7ef63e..4cf6af3426 100644 --- a/libs/ui-components/src/components/Repository/RepositoryDetails/ConfigSourceSyncDetails.tsx +++ b/libs/ui-components/src/components/Repository/RepositoryDetails/ConfigSourceSyncDetails.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ClipboardCopy, Content, Flex, FlexItem, Tooltip, type TruncateProps } from '@patternfly/react-core'; +import { ClipboardCopy, Content, Tooltip, type TruncateProps } from '@patternfly/react-core'; import { type DependencySyncConfigRefStatus } from '@flightctl/types'; import { useTranslation } from '../../../hooks/useTranslation'; @@ -28,34 +28,30 @@ const ConfigSourceSyncDetails = ({ syncRef }: ConfigSourceSyncDetailsProps) => { const { t } = useTranslation(); const fingerprint = syncRef.fingerprint; + return ( - + <> {fingerprint && ( - - - {fingerprint} - - + + {fingerprint} + )} - {syncRef.lastUpdatedAt && ( - - - - {t('Last synced')} {timeSinceText(t, syncRef.lastUpdatedAt)} - - - + + + {t('Last synced {{syncTime}}', { syncTime: timeSinceText(t, syncRef.lastUpdatedAt) })} + + )} - + ); }; diff --git a/libs/ui-components/src/components/Repository/RepositoryDetails/ConfigurationSourcesHeader.tsx b/libs/ui-components/src/components/Repository/RepositoryDetails/ConfigurationSourcesHeader.tsx new file mode 100644 index 0000000000..e7618c0a2c --- /dev/null +++ b/libs/ui-components/src/components/Repository/RepositoryDetails/ConfigurationSourcesHeader.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { Badge, Flex, FlexItem, Title } from '@patternfly/react-core'; + +import { useTranslation } from '../../../hooks/useTranslation'; + +type ConfigurationSourcesHeaderProps = { + count: number; + className?: string; +}; + +const ConfigurationSourcesHeader = ({ count, className }: ConfigurationSourcesHeaderProps) => { + const { t } = useTranslation(); + + return ( + + + + {t('Sources')} + + + + + {count} + + + + ); +}; + +export default ConfigurationSourcesHeader; diff --git a/libs/ui-components/src/components/Repository/RepositoryDetails/RepositorySource.tsx b/libs/ui-components/src/components/Repository/RepositoryDetails/RepositorySource.tsx index d132425d64..e367d4a0ff 100644 --- a/libs/ui-components/src/components/Repository/RepositoryDetails/RepositorySource.tsx +++ b/libs/ui-components/src/components/Repository/RepositoryDetails/RepositorySource.tsx @@ -3,33 +3,16 @@ import { Button, Icon, Tooltip } from '@patternfly/react-core'; import { ExternalLinkAltIcon } from '@patternfly/react-icons/dist/js/icons/external-link-alt-icon'; import ExclamationCircleIcon from '@patternfly/react-icons/dist/js/icons/exclamation-circle-icon'; -import { - type GitConfigProviderSpec, - type HttpConfigProviderSpec, - type InlineConfigProviderSpec, - type KubernetesSecretProviderSpec, -} from '@flightctl/types'; import { type ConfigSourceProvider, - ConfigType, - type RepoConfig, getConfigFullRepoUrl, getRepoName, + isGitProviderSpec, isHttpProviderSpec, } from '../../../types/deviceSpec'; -import CopyButton from '../../common/CopyButton'; import { useTranslation } from '../../../hooks/useTranslation'; -import { getConfigType } from '../../Device/EditDeviceWizard/deviceSpecUtils'; - -type ExtraArgs = Record; - -export const DefaultConfigDetails = ({ - config, -}: { - config: InlineConfigProviderSpec | KubernetesSecretProviderSpec; -}) => { - return <>{config.name}; -}; +import type { RepositoryDetails } from '../../../hooks/useRepositoryDetailsMap'; +import CopyButton from '../../common/CopyButton'; export const HttpRepositoryUrl = ({ name, url }: { name?: string; url: string }) => { const { t } = useTranslation(); @@ -55,12 +38,25 @@ export const GitRepositoryLink = ({ name, url }: { name?: string; url: string }) ); -export const RepositoryConfigDetails = ({ config, extraArgs }: { config: RepoConfig; extraArgs: ExtraArgs }) => { +const RepositorySource = ({ + config, + repoDetails, +}: { + config: ConfigSourceProvider; + repoDetails?: RepositoryDetails; +}) => { const { t } = useTranslation(); - if (extraArgs.errorMsg) { + + const isGitConfig = isGitProviderSpec(config); + const isHttpConfig = isHttpProviderSpec(config); + if (!repoDetails || !(isGitConfig || isHttpConfig)) { + return <>{config.name}; + } + + if (repoDetails.errorMsg) { const fullError = `${t('The repository "{{name}}" defined for this source failed to load.', { name: getRepoName(config), - })} ${extraArgs.errorMsg}`; + })} ${repoDetails.errorMsg}`; return ( <> {config.name}{' '} @@ -73,24 +69,11 @@ export const RepositoryConfigDetails = ({ config, extraArgs }: { config: RepoCon ); } - const url = getConfigFullRepoUrl(config, extraArgs.url); + const url = getConfigFullRepoUrl(config, repoDetails.url || ''); if (isHttpProviderSpec(config)) { return ; } return ; }; -export const getConfigDetails = (config: ConfigSourceProvider, extraArgs: ExtraArgs) => { - switch (getConfigType(config)) { - case ConfigType.GIT: - case ConfigType.HTTP: - return ( - - ); - default: - return ; - } -}; +export default RepositorySource; diff --git a/libs/ui-components/src/components/Repository/RepositoryDetails/RepositorySourceList.tsx b/libs/ui-components/src/components/Repository/RepositoryDetails/RepositorySourceList.tsx index abea03d4ad..a56c0ef232 100644 --- a/libs/ui-components/src/components/Repository/RepositoryDetails/RepositorySourceList.tsx +++ b/libs/ui-components/src/components/Repository/RepositoryDetails/RepositorySourceList.tsx @@ -1,24 +1,20 @@ import React from 'react'; -import { Divider, List, ListItem, Spinner, Stack, StackItem } from '@patternfly/react-core'; - -import { type DependencySyncConfigRefStatus, type DependencySyncStatus, type Repository } from '@flightctl/types'; -import { useFetch } from '../../../hooks/useFetch'; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Divider, + Spinner, + Stack, + StackItem, +} from '@patternfly/react-core'; + +import { type DependencySyncConfigRefStatus, type DependencySyncStatus } from '@flightctl/types'; import { type ConfigSourceProvider, getRepoName, isRepoConfig } from '../../../types/deviceSpec'; -import { isPromiseRejected } from '../../../types/typeUtils'; -import { getErrorMessage } from '../../../utils/error'; +import { type RepositoryDetails, useRepositoryDetailsMap } from '../../../hooks/useRepositoryDetailsMap'; +import RepositorySource from './RepositorySource'; import ConfigSourceSyncDetails from './ConfigSourceSyncDetails'; -import { getConfigDetails } from './RepositorySource'; -import { getRepoUrlOrRegistry } from '../CreateRepository/utils'; - -const useArrayEq = (array: string[]) => { - const prevArrayRef = React.useRef(array); - - if (prevArrayRef.current.length !== array.length || prevArrayRef.current.some((item) => !array.includes(item))) { - prevArrayRef.current = array; - } - - return prevArrayRef.current; -}; const getSyncRef = ( configProviderName: string, @@ -31,48 +27,46 @@ const getSyncRef = ( return null; }; -type RepoLoadDetails = { url?: string; errorMsg?: string }; - type RepositorySourceListProps = { configs: Array; dependencyStatus?: DependencySyncStatus; + showNames?: boolean; }; -const RepositorySourceList = ({ configs, dependencyStatus }: RepositorySourceListProps) => { - const { get } = useFetch(); - const repoConfigs = configs.filter(isRepoConfig); - - // Map indexed by repository name, with the result of fetching the repository details - const [repoDetailsMap, setRepoDetailsMap] = React.useState>>({}); +export const RepositorySourcePlainList = ({ configs }: { configs: ConfigSourceProvider[] }) => { + const { repoDetailsMap, isLoading } = useRepositoryDetailsMap(configs); - const repoConfigNames = useArrayEq(repoConfigs.map((config) => config.name)); - const repositoryNames = useArrayEq(repoConfigs.map(getRepoName)); - const [isLoading, setIsLoading] = React.useState(true); + if (configs.length === 0) { + return '-'; + } - React.useEffect(() => { - const fetch = async () => { - const promises = repositoryNames.map((repoName) => get(`repositories/${repoName}`)); - const results = await Promise.allSettled(promises); + if (isLoading) { + return ; + } - const map = {}; - results.forEach((result, index) => { - const isRepoMissing = isPromiseRejected(result); - const repoName = repositoryNames[index]; + return ( + + {configs.map((config) => { + const repoName = isRepoConfig(config) ? getRepoName(config) : undefined; + const repoDetails = repoName ? repoDetailsMap[repoName] : undefined; - const repoInfo: { url?: string; errorMsg?: string } = {}; - if (isRepoMissing) { - repoInfo.errorMsg = getErrorMessage(result.reason); - } else { - repoInfo.url = getRepoUrlOrRegistry(result.value.spec); - } - map[repoName] = repoInfo; - }); - setRepoDetailsMap(map); - setIsLoading(false); - }; + return ( + + + + ); + })} + + ); +}; - void fetch(); - }, [get, repoConfigNames, repositoryNames]); +const RepositorySourceDescriptionList = ({ + configs, + dependencyStatus, + showNames = true, +}: RepositorySourceListProps) => { + const repoConfigs = configs.filter(isRepoConfig); + const { repoDetailsMap, isLoading } = useRepositoryDetailsMap(repoConfigs); if (isLoading) { return ; @@ -86,43 +80,48 @@ const RepositorySourceList = ({ configs, dependencyStatus }: RepositorySourceLis const hasSyncDetails = configSyncs.some((sync) => sync !== null); return ( - + {configs.map((config, index) => { const addDivider = hasSyncDetails && index !== configs.length - 1; - let extraArgs = {}; + let repoDetails: RepositoryDetails | undefined = undefined; if (isRepoConfig(config)) { const repoName = getRepoName(config); - extraArgs = repoDetailsMap[repoName] || {}; + repoDetails = repoDetailsMap[repoName] || undefined; } const syncRef = configSyncs[index]; return ( - - - {getConfigDetails(config, extraArgs)} - {syncRef && ( + + {showNames && {config.name}} + + - - - )} - {addDivider && ( - - + - )} - - + {syncRef && ( + + + + )} + {addDivider && ( + + + + )} + + + ); })} - + ); }; -export default RepositorySourceList; +export default RepositorySourceDescriptionList; diff --git a/libs/ui-components/src/components/SecurityOverview/EntitySecurityOverviewCard.tsx b/libs/ui-components/src/components/SecurityOverview/EntitySecurityOverviewCard.tsx new file mode 100644 index 0000000000..db0e534d90 --- /dev/null +++ b/libs/ui-components/src/components/SecurityOverview/EntitySecurityOverviewCard.tsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import { CardBody, CardExpandableContent, CardHeader } from '@patternfly/react-core'; +import { type OnSort } from '@patternfly/react-table'; +import ShieldAltIcon from '@patternfly/react-icons/dist/js/icons/shield-alt-icon'; +import { + type CveCountsBySeverity, + type Vulnerability, + type VulnerabilityGroup, + type VulnerabilityGroupList, + type VulnerabilityList, +} from '@flightctl/types/alpha'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { type VulnerabilitySortDirection, type VulnerabilitySortField } from '../../hooks/useVulnerabilities'; +import { type PaginationDetails } from '../../hooks/useTablePagination'; +import { getSeverityToggleResult } from '../../utils/vulnerabilities'; +import ListPageBody from '../ListPage/ListPageBody'; +import DetailsPageCard, { DetailsPageCardTitle } from '../DetailsPage/DetailsPageCard'; +import SecurityOverviewSummary from './SecurityOverviewSummary'; +import VulnerabilitiesTable from './VulnerabilitiesTable'; + +type Severity = Vulnerability.severity; + +type EntitySecurityOverviewCardCommonProps = { + counts: CveCountsBySeverity; + isSummaryLoading: boolean; + vulnerabilities: Vulnerability[] | VulnerabilityGroup[]; + currentPage: number; + setCurrentPage: (page: number) => void; + itemCount: number; + search: string; + setSearch: React.Dispatch>; + selectedSeverities: Severity[]; + setSelectedSeverities: React.Dispatch>; + sortBy: VulnerabilitySortField; + sortDirection: VulnerabilitySortDirection; + onSort: OnSort; + isLoading: boolean; + isUpdating: boolean; + error: unknown; +}; + +type EntitySecurityOverviewCardDeviceProps = EntitySecurityOverviewCardCommonProps & { + isSingleDevice: true; +}; + +type EntitySecurityOverviewCardFleetProps = EntitySecurityOverviewCardCommonProps & { + isSingleDevice: false; + fleetName: string; +}; + +type EntitySecurityOverviewCardProps = EntitySecurityOverviewCardDeviceProps | EntitySecurityOverviewCardFleetProps; + +const EntitySecurityOverviewCard = ({ + counts, + isSummaryLoading, + vulnerabilities, + currentPage, + setCurrentPage, + itemCount, + search, + setSearch, + selectedSeverities, + setSelectedSeverities, + sortBy, + sortDirection, + onSort, + isLoading, + isUpdating, + error, + ...scopeProps +}: EntitySecurityOverviewCardProps) => { + const { t } = useTranslation(); + const [isTableExpanded, setIsTableExpanded] = React.useState(false); + + const handleSeverityToggle = React.useCallback( + (severity: Severity) => { + setSelectedSeverities((currentSeverities) => { + const { selectedSeverities: nextSeverities, expandTable } = getSeverityToggleResult( + severity, + currentSeverities, + ); + if (expandTable) { + setIsTableExpanded(true); + } + return nextSeverities; + }); + }, + [setSelectedSeverities], + ); + + const pagination: Pick< + PaginationDetails, + 'currentPage' | 'setCurrentPage' | 'itemCount' + > = { + currentPage, + setCurrentPage, + itemCount, + }; + + return ( + + setIsTableExpanded((prev) => !prev)} + toggleButtonProps={{ + 'aria-label': t('Toggle security details'), + 'aria-expanded': isTableExpanded, + }} + > + } /> + + + + + + + + {scopeProps.isSingleDevice ? ( + + ) : ( + + )} + + + + + ); +}; + +export default EntitySecurityOverviewCard; diff --git a/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.css b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.css index 632c62e5e3..48637617b6 100644 --- a/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.css +++ b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.css @@ -1,7 +1,79 @@ +.fctl-security-overview-summary-tile-wrapper { + display: block; + height: 100%; +} + .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); + border: 2px solid transparent; + box-sizing: border-box; +} + +.fctl-security-overview-summary-box--interactive { + cursor: pointer; +} + +.fctl-security-overview-summary-box--interactive.critical:hover, +.fctl-security-overview-summary-box--interactive.critical.fctl-security-overview-summary-box--selected { + border-color: var(--pf-t--global--icon--color--severity--critical--default); +} + +.fctl-security-overview-summary-box--interactive.high:hover, +.fctl-security-overview-summary-box--interactive.high.fctl-security-overview-summary-box--selected { + border-color: var(--pf-t--global--icon--color--severity--important--default); +} + +.fctl-security-overview-summary-box--interactive.medium:hover, +.fctl-security-overview-summary-box--interactive.medium.fctl-security-overview-summary-box--selected { + border-color: var(--pf-t--global--icon--color--severity--moderate--default); +} + +.fctl-security-overview-summary-box--interactive.low:hover, +.fctl-security-overview-summary-box--interactive.low.fctl-security-overview-summary-box--selected, +.fctl-security-overview-summary-box--interactive.unknown:hover, +.fctl-security-overview-summary-box--interactive.unknown.fctl-security-overview-summary-box--selected { + border-color: var(--pf-t--global--icon--color--severity--minor--default); +} + +.fctl-security-overview-summary-box--interactive.none:hover, +.fctl-security-overview-summary-box--interactive.none.fctl-security-overview-summary-box--selected { + border-color: var(--pf-t--global--icon--color--severity--none--default); +} + +/* Dark theme - interactive border colors (hover/selected only) */ +.pf-v6-theme-dark .fctl-security-overview-summary-box--interactive.critical:hover, +.pf-v6-theme-dark + .fctl-security-overview-summary-box--interactive.critical.fctl-security-overview-summary-box--selected { + border-color: var(--pf-t--global--color--nonstatus--orangered--default); +} + +.pf-v6-theme-dark .fctl-security-overview-summary-box--interactive.high:hover, +.pf-v6-theme-dark .fctl-security-overview-summary-box--interactive.high.fctl-security-overview-summary-box--selected { + border-color: var(--pf-t--global--color--nonstatus--orange--default); +} + +.pf-v6-theme-dark .fctl-security-overview-summary-box--interactive.medium:hover, +.pf-v6-theme-dark .fctl-security-overview-summary-box--interactive.medium.fctl-security-overview-summary-box--selected { + /* Patternfly does not have a semantic token for moderate severity border in dark mode. */ + border-color: #fff4cc; +} + +.pf-v6-theme-dark .fctl-security-overview-summary-box--interactive.unknown:hover, +.pf-v6-theme-dark .fctl-security-overview-summary-box--interactive.unknown.fctl-security-overview-summary-box--selected, +.pf-v6-theme-dark .fctl-security-overview-summary-box--interactive.none:hover, +.pf-v6-theme-dark .fctl-security-overview-summary-box--interactive.none.fctl-security-overview-summary-box--selected { + border-color: var(--pf-t--color--white); +} + +.pf-v6-theme-dark .fctl-security-overview-summary-box--interactive.low:hover, +.pf-v6-theme-dark .fctl-security-overview-summary-box--interactive.low.fctl-security-overview-summary-box--selected { + border-color: var(--pf-t--global--color--nonstatus--blue--default); +} + +.fctl-security-overview-summary-box--interactive.fctl-security-overview-summary-box--selected { + border-width: 3px; } /* diff --git a/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.tsx b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.tsx index 8be708d22f..c97924a1e6 100644 --- a/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.tsx +++ b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.tsx @@ -1,12 +1,28 @@ import * as React from 'react'; -import { Divider, Flex, FlexItem, Grid, GridItem, Icon, Stack, StackItem } from '@patternfly/react-core'; -import { Vulnerability } from '@flightctl/types/alpha'; +import { + Divider, + Flex, + FlexItem, + Grid, + GridItem, + Icon, + Skeleton, + Stack, + StackItem, + Tooltip, +} from '@patternfly/react-core'; +import { type CveCountsBySeverity, type 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 { type StatusItem } from '../../utils/status/common'; -import { VULNERABILITY_SEVERITY_ORDER, getSeverityCountValue, getSeverityLabel } from '../../utils/vulnerabilities'; +import { + type SeverityTilesLayout, + getSeverityCountValue, + getSeverityLabel, + getVisibleSeverityTilesConfig, +} from '../../utils/vulnerabilities'; import { useDevicesSummary } from '../Device/DevicesPage/useDevices'; import { defaultVulnerabilitySeverityStatusItem, @@ -16,28 +32,73 @@ import { VulnerabilitiesOverviewEmptyState } from './VulnerabilitiesEmptyState'; import './SecurityOverviewSummary.css'; +type Severity = Vulnerability.severity; + type SeverityStatProps = { - severity: Vulnerability.severity; - item: StatusItem; + severity: Severity; + item: StatusItem; count: number; + interactive?: boolean; + isSelected?: boolean; + onToggle?: (severity: Severity) => void; }; -const SeverityStat = ({ count, severity, item }: SeverityStatProps) => { +const SeverityStat = ({ + count, + severity, + item, + interactive = false, + isSelected = false, + onToggle, +}: SeverityStatProps) => { const { t } = useTranslation(); const SeverityIcon = item.customIcon || SeverityUndefinedIcon; + const isSelectable = interactive && count > 0; + const severityLabel = getSeverityLabel(severity, t); - return ( + const handleClick = () => { + if (isSelectable) { + onToggle?.(severity); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!isSelectable) { + return; + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onToggle?.(severity); + } + }; + + const tile = ( 0 ? 'fctl-security-overview-summary-box__filled' : ''} ${severity.toLowerCase()} `} + className={[ + 'fctl-security-overview-summary-box', + count > 0 ? 'fctl-security-overview-summary-box__filled' : '', + severity.toLowerCase(), + isSelectable ? 'fctl-security-overview-summary-box--interactive' : '', + isSelected ? 'fctl-security-overview-summary-box--selected' : '', + ] + .filter(Boolean) + .join(' ')} direction={{ default: 'column' }} justifyContent={{ default: 'justifyContentSpaceBetween' }} alignItems={{ default: 'alignItemsCenter' }} + role={isSelectable ? 'button' : undefined} + tabIndex={isSelectable ? 0 : undefined} + onClick={isSelectable ? handleClick : undefined} + onKeyDown={isSelectable ? handleKeyDown : undefined} + aria-pressed={isSelectable ? isSelected : undefined} + aria-label={ + isSelectable ? t('Filter table by {{severity}} severity', { severity: severityLabel }) : severityLabel + } > - {/* Icon only provides the class to target the element from the CSS file*/} @@ -47,24 +108,92 @@ const SeverityStat = ({ count, severity, item }: SeverityStatProps) => { - {getSeverityLabel(severity, t)} + {severityLabel} ); + + if (isSelectable) { + return ( + + {tile} + + ); + } + + return tile; +}; + +type SeverityTilesGridProps = { + counts: CveCountsBySeverity; + layout?: SeverityTilesLayout; + interactive?: boolean; + selectedSeverities?: Severity[]; + onSeverityToggle?: (severity: Severity) => void; + isLoading?: boolean; +}; + +const SeverityTilesGrid = ({ + counts, + layout = 'overview', + interactive = false, + selectedSeverities = [], + onSeverityToggle, + isLoading = false, +}: SeverityTilesGridProps) => { + const { t } = useTranslation(); + const statusItems = getVulnerabilitySeverityStatusItems(t); + const { severities, columnSpan } = getVisibleSeverityTilesConfig(counts, layout); + + if (isLoading) { + return ( + + {severities.map((severity) => ( + + + + ))} + + ); + } + + return ( + + {severities.map((severity) => { + const item = + statusItems.find((statusItem) => statusItem.id === severity) || defaultVulnerabilitySeverityStatusItem(t); + const count = getSeverityCountValue(severity, counts); + return ( + + + + ); + })} + + ); +}; + +type SecurityOverviewDetailProps = { + counts: CveCountsBySeverity; + selectedSeverities?: Severity[]; + onSeverityToggle?: (severity: Severity) => void; + isLoading?: boolean; }; -const SecurityOverviewSummary = () => { +export const SecurityOverviewSummaryOverview = () => { 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); - const severityColumnSpan = hasAllSeverities ? 4 : 3; - return ( @@ -82,25 +211,28 @@ const SecurityOverviewSummary = () => { - - {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 ( - - - - ); - })} - + ); }; +const SecurityOverviewSummary = ({ + counts, + selectedSeverities = [], + onSeverityToggle, + isLoading = false, +}: SecurityOverviewDetailProps) => { + return ( + + ); +}; + export default SecurityOverviewSummary; diff --git a/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesTableRow.tsx b/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesTableRow.tsx index b8cf4c9972..3023e61f2c 100644 --- a/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesTableRow.tsx +++ b/libs/ui-components/src/components/SecurityOverview/VulnerabilitiesTableRow.tsx @@ -1,14 +1,6 @@ import * as React from 'react'; import { Button } from '@patternfly/react-core'; -import { - ActionsColumn, - ExpandableRowContent, - Tbody, - Td, - type TdProps, - Tr, - type TrProps, -} from '@patternfly/react-table'; +import { ExpandableRowContent, Tbody, Td, type TdProps, Tr, type TrProps } from '@patternfly/react-table'; import { type Vulnerability, type VulnerabilityGroup } from '@flightctl/types/alpha'; import { useTranslation } from '../../hooks/useTranslation'; @@ -54,16 +46,6 @@ const VulnerabilitiesBaseTr = ({ {getDateNoTimeDisplay(isGroupItem ? vulnerability.maxPublishedAt : vulnerability.publishedAt)} - - - ); }; @@ -128,7 +110,7 @@ export const VulnerabilitiesTableFullRow = ({ compoundExpand={compoundExpand} /> - + diff --git a/libs/ui-components/src/components/Status/ApplicationSummaryStatus.tsx b/libs/ui-components/src/components/Status/ApplicationSummaryStatus.tsx index 4a5cd3bc63..238f911f73 100644 --- a/libs/ui-components/src/components/Status/ApplicationSummaryStatus.tsx +++ b/libs/ui-components/src/components/Status/ApplicationSummaryStatus.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { type DeviceApplicationsSummaryStatus } from '@flightctl/types'; +import type { DeviceApplicationsSummaryStatus } from '@flightctl/types'; import { useTranslation } from '../../hooks/useTranslation'; import { getApplicationSummaryStatusItems } from '../../utils/status/applications'; import StatusDisplay from './StatusDisplay'; diff --git a/libs/ui-components/src/components/Status/DeviceResourceStatus.tsx b/libs/ui-components/src/components/Status/DeviceResourceStatus.tsx index 8a6a2dbef4..e9e6df2419 100644 --- a/libs/ui-components/src/components/Status/DeviceResourceStatus.tsx +++ b/libs/ui-components/src/components/Status/DeviceResourceStatus.tsx @@ -10,7 +10,7 @@ import { } from '@flightctl/types'; import { useTranslation } from '../../hooks/useTranslation'; -import { type StatusLevel } from '../../utils/status/common'; +import { getDeviceResourceStatusLevel } from '../../utils/status/resources'; import { StatusDisplayContent } from './StatusDisplay'; export enum MonitorType { @@ -70,7 +70,6 @@ const DeviceResourceStatus = ({ device, monitorType }: { device: Device | undefi return ; } - let level: StatusLevel; let label: string; let messageTitle: string = ''; const status = device.status?.resources[monitorType]; @@ -88,26 +87,9 @@ const DeviceResourceStatus = ({ device, monitorType }: { device: Device | undefi label = status || t('Unknown'); } - switch (status) { - case DeviceResourceStatusType.DeviceResourceStatusHealthy: - level = 'success'; - break; - case DeviceResourceStatusType.DeviceResourceStatusWarning: - level = 'warning'; - break; - case DeviceResourceStatusType.DeviceResourceStatusCritical: - case DeviceResourceStatusType.DeviceResourceStatusError: - level = 'danger'; - break; - case DeviceResourceStatusType.DeviceResourceStatusUnknown: - case undefined: - level = 'unknown'; - break; - } - return ( { +const DeviceStatus = ({ summaryStatus }: { summaryStatus?: DeviceSummaryStatus }) => { const { t } = useTranslation(); - const status = getDeviceSummaryStatus(deviceStatus?.summary); + const status = getDeviceSummaryStatus(summaryStatus); const statusItems = getDeviceStatusItems(t); const item = statusItems.find((statusItem) => { return statusItem.id === status; }); - return ; + return ; }; export default DeviceStatus; diff --git a/libs/ui-components/src/components/Status/SystemUpdateStatus.tsx b/libs/ui-components/src/components/Status/SystemUpdateStatus.tsx index f01ea0d5b7..9a4a09bed5 100644 --- a/libs/ui-components/src/components/Status/SystemUpdateStatus.tsx +++ b/libs/ui-components/src/components/Status/SystemUpdateStatus.tsx @@ -1,18 +1,18 @@ import * as React from 'react'; -import { type DeviceStatus } from '@flightctl/types'; +import { type DeviceUpdatedStatus } from '@flightctl/types'; import { useTranslation } from '../../hooks/useTranslation'; import { getSystemUpdateStatusItems } from '../../utils/status/system'; import StatusDisplay from './StatusDisplay'; -const SystemUpdateStatus = ({ deviceStatus }: { deviceStatus?: DeviceStatus }) => { +const SystemUpdateStatus = ({ updateStatus }: { updateStatus?: DeviceUpdatedStatus }) => { const { t } = useTranslation(); const statusItems = getSystemUpdateStatusItems(t); const item = statusItems.find((statusItem) => { - return statusItem.id === deviceStatus?.updated.status; + return statusItem.id === updateStatus?.status; }); - return ; + return ; }; export default SystemUpdateStatus; diff --git a/libs/ui-components/src/components/Table/Table.css b/libs/ui-components/src/components/Table/Table.css deleted file mode 100644 index 639f8d3c41..0000000000 --- a/libs/ui-components/src/components/Table/Table.css +++ /dev/null @@ -1,4 +0,0 @@ -.fctl-tableth__helper-text .pf-v6-c-button { - --pf-v6-c-button--PaddingBlockEnd: 0; - --pf-v6-c-button--PaddingBlockStart: 0; -} diff --git a/libs/ui-components/src/components/Table/Table.tsx b/libs/ui-components/src/components/Table/Table.tsx index 10e1a3afa2..662193a059 100644 --- a/libs/ui-components/src/components/Table/Table.tsx +++ b/libs/ui-components/src/components/Table/Table.tsx @@ -13,7 +13,6 @@ import { SearchIcon } from '@patternfly/react-icons/dist/js/icons'; import { useTranslation } from '../../hooks/useTranslation'; import LabelWithHelperText from '../common/WithHelperText'; -import './Table.css'; export type ApiTableColumn = { id?: string; @@ -87,9 +86,9 @@ export const Table = ({ {!emptyData && singleSelect && } {isExpandable && !emptyData && } {columns.map((c) => ( - + {c.helperText ? ( - + ) : ( c.name )} diff --git a/libs/ui-components/src/components/common/EditableLabelControl.tsx b/libs/ui-components/src/components/common/EditableLabelControl.tsx index 44fea21f6e..863ddf993b 100644 --- a/libs/ui-components/src/components/common/EditableLabelControl.tsx +++ b/libs/ui-components/src/components/common/EditableLabelControl.tsx @@ -8,6 +8,7 @@ type EditableLabelControlProps = { addButtonText?: string; defaultLabel: string; onAddLabel: (text: string) => void; + className?: string; }; const EditableLabelControl = ({ @@ -15,6 +16,7 @@ const EditableLabelControl = ({ defaultLabel, onAddLabel, isEditable = true, + className, }: EditableLabelControlProps) => { const [isEditing, setIsEditing] = React.useState(false); const [label, setLabel] = React.useState(''); @@ -56,6 +58,7 @@ const EditableLabelControl = ({ variant="link" isInline isDisabled={!isEditable} + className={className} onClick={() => { setIsEditing(true); setLabel(defaultLabel); diff --git a/libs/ui-components/src/components/common/OsModeContent.tsx b/libs/ui-components/src/components/common/OsModeContent.tsx index 3c571fb504..8f641e2894 100644 --- a/libs/ui-components/src/components/common/OsModeContent.tsx +++ b/libs/ui-components/src/components/common/OsModeContent.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Icon, Tooltip } from '@patternfly/react-core'; +import { Icon, Label, Tooltip } from '@patternfly/react-core'; import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons/dist/js/icons/outlined-question-circle-icon'; import { OsImageIcon } from '@patternfly/react-icons/dist/js/icons/os-image-icon'; import { ArchiveIcon } from '@patternfly/react-icons/dist/js/icons/archive-icon'; @@ -7,7 +7,23 @@ import { ArchiveIcon } from '@patternfly/react-icons/dist/js/icons/archive-icon' import { OsModeType } from '@flightctl/types'; import { useTranslation } from '../../hooks/useTranslation'; -const OsModeContent = ({ osMode }: { osMode: OsModeType | undefined }) => { +export const OsModeLabel = ({ osMode }: { osMode: OsModeType | undefined }) => { + const { t } = useTranslation(); + + if (!osMode) { + return '-'; + } + + const isImageMode = osMode === OsModeType.OsModeImage; + + return ( + + ); +}; + +const OsModeIcon = ({ osMode }: { osMode: OsModeType | undefined }) => { const { t } = useTranslation(); let content: string; @@ -32,4 +48,4 @@ const OsModeContent = ({ osMode }: { osMode: OsModeType | undefined }) => { ); }; -export default OsModeContent; +export default OsModeIcon; diff --git a/libs/ui-components/src/components/common/WithHelperText.css b/libs/ui-components/src/components/common/WithHelperText.css new file mode 100644 index 0000000000..a420f6ae71 --- /dev/null +++ b/libs/ui-components/src/components/common/WithHelperText.css @@ -0,0 +1,10 @@ +.fctl-label-withhelpertext { + align-items: center; + display: inline-flex; + gap: var(--pf-t--global--spacer--xs); +} + +.fctl-label-withhelpertext.fctl-label-withhelpertext--inline .pf-v6-c-button { + --pf-v6-c-button--PaddingBlockEnd: 0; + --pf-v6-c-button--PaddingBlockStart: 0; +} diff --git a/libs/ui-components/src/components/common/WithHelperText.tsx b/libs/ui-components/src/components/common/WithHelperText.tsx index dc087a3a8c..f221a0977d 100644 --- a/libs/ui-components/src/components/common/WithHelperText.tsx +++ b/libs/ui-components/src/components/common/WithHelperText.tsx @@ -2,8 +2,11 @@ import * as React from 'react'; import { Button, FormGroup, Popover } from '@patternfly/react-core'; import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons/dist/js/icons/outlined-question-circle-icon'; +import './WithHelperText.css'; + type HelperTextPopoverProps = { label: string; + isInline?: boolean; triggerAction?: 'click' | 'hover'; content: React.ReactNode; }; @@ -35,13 +38,14 @@ const HelperTextPopover = ({ const LabelWithHelperText = ({ label, hideLabel, + isInline, content, triggerAction, }: HelperTextPopoverProps & { hideLabel?: boolean }) => ( - <> + {!hideLabel && label} - + ); export const FormGroupWithHelperText = ({ diff --git a/libs/ui-components/src/components/form/LabelsField.tsx b/libs/ui-components/src/components/form/LabelsField.tsx index 2d690e6230..c0b89a4082 100644 --- a/libs/ui-components/src/components/form/LabelsField.tsx +++ b/libs/ui-components/src/components/form/LabelsField.tsx @@ -72,19 +72,7 @@ const LabelsField = ({ return ( <> - - } - > + {labels .map((label, originalIndex) => ({ ...label, originalIndex })) .map(({ key, value, originalIndex }) => { @@ -110,6 +98,13 @@ const LabelsField = ({ ); })} + diff --git a/libs/ui-components/src/components/modals/EditLabelsModal/EditLabelsForm.tsx b/libs/ui-components/src/components/modals/EditLabelsModal/EditLabelsForm.tsx index bbfb83376b..2e523fb56c 100644 --- a/libs/ui-components/src/components/modals/EditLabelsModal/EditLabelsForm.tsx +++ b/libs/ui-components/src/components/modals/EditLabelsModal/EditLabelsForm.tsx @@ -6,7 +6,6 @@ import * as Yup from 'yup'; import { type Device } from '@flightctl/types'; import LabelsField from '../../form/LabelsField'; -import FlightCtlForm from '../../form/FlightCtlForm'; import { type FlightCtlLabel } from '../../../types/extraTypes'; import { useFetch } from '../../../hooks/useFetch'; import { useTranslation } from '../../../hooks/useTranslation'; @@ -45,10 +44,10 @@ const EditLabelsFormContent = ({ isSubmitting, submitForm }: EditLabelsFormConte }; return ( - + <> {submitError && } - + ); }; diff --git a/libs/ui-components/src/hooks/useDeviceOverallHealth.ts b/libs/ui-components/src/hooks/useDeviceOverallHealth.ts new file mode 100644 index 0000000000..ee1e560053 --- /dev/null +++ b/libs/ui-components/src/hooks/useDeviceOverallHealth.ts @@ -0,0 +1,95 @@ +import * as React from 'react'; + +import type { Device, DeviceStatus } from '@flightctl/types'; +import { getAppStatusLevel, getAppSummaryStatusLevel } from '../utils/status/applications'; +import { getDeviceSummaryStatusLevel } from '../utils/status/devices'; +import { getIntegrityStatusLevel } from '../utils/status/integrity'; +import { getDeviceResourceStatusLevel } from '../utils/status/resources'; +import { getSystemUpdateStatusLevel } from '../utils/status/system'; +import { type HealthLevel } from '../utils/status/common'; + +export type DeviceHealthItem = { + type: 'status' | 'apps'; + level: HealthLevel; + itemCount: number; +}; + +export type DeviceOverallHealth = { + level: HealthLevel; + statusHealth: DeviceHealthItem; + appsHealth: DeviceHealthItem; +}; + +type AttentionHealthLevel = 'danger' | 'warning'; +const isAttentionHealthLevel = (level: string): level is AttentionHealthLevel => ['danger', 'warning'].includes(level); + +const getWorstLevel = (healthLevels: HealthLevel[]): HealthLevel => + healthLevels.reduce((accum, current) => { + if (accum === 'danger' || current === 'danger') { + return 'danger'; + } + if (accum === 'warning' || current === 'warning') { + return 'warning'; + } + return null; + }, null); + +const getStatusHealth = (deviceStatus: DeviceStatus): DeviceHealthItem => { + const resources = deviceStatus.resources; + const attentionLevels: AttentionHealthLevel[] = [ + getDeviceSummaryStatusLevel(deviceStatus.summary?.status), + getAppSummaryStatusLevel(deviceStatus.applicationsSummary?.status), + getSystemUpdateStatusLevel(deviceStatus.updated?.status), + getIntegrityStatusLevel(deviceStatus.integrity?.status), + getDeviceResourceStatusLevel(resources?.cpu), + getDeviceResourceStatusLevel(resources?.disk), + getDeviceResourceStatusLevel(resources?.memory), + ] + .map((level) => (isAttentionHealthLevel(level) ? level : null)) + .filter((level) => level !== null); + + if (attentionLevels.length === 0) { + return { type: 'status', itemCount: 0, level: null }; + } + + return { + type: 'status', + itemCount: attentionLevels.length, + level: getWorstLevel(attentionLevels), + }; +}; + +const getApplicationHealth = (deviceStatus: DeviceStatus): DeviceHealthItem => { + // The individual applications must be aligned to the overall application summary. + const appSummaryLevel = getAppSummaryStatusLevel(deviceStatus.applicationsSummary?.status); + if (!isAttentionHealthLevel(appSummaryLevel)) { + return { type: 'apps', itemCount: 0, level: null }; + } + + let hasErrors = false; + let counts = 0; + + deviceStatus.applications.forEach((app) => { + const level = getAppStatusLevel(app.status); + if (level === 'danger') { + hasErrors = true; + } + if (level !== null) { + counts += 1; + } + }); + + return { type: 'apps', itemCount: counts, level: counts > 0 ? (hasErrors ? 'danger' : 'warning') : null }; +}; + +export const useDeviceOverallHealth = (device: Required): DeviceOverallHealth => + React.useMemo(() => { + const appsHealth = getApplicationHealth(device.status); + const statusHealth = getStatusHealth(device.status); + + return { + statusHealth, + appsHealth, + level: getWorstLevel([statusHealth.level, appsHealth.level]), + }; + }, [device]); diff --git a/libs/ui-components/src/hooks/useDeviceSpecSystemInfo.tsx b/libs/ui-components/src/hooks/useDeviceSpecSystemInfo.tsx index efebdeb47f..94a8e74d86 100644 --- a/libs/ui-components/src/hooks/useDeviceSpecSystemInfo.tsx +++ b/libs/ui-components/src/hooks/useDeviceSpecSystemInfo.tsx @@ -1,21 +1,9 @@ import React from 'react'; -import { Label } from '@patternfly/react-core'; -import OsImageIcon from '@patternfly/react-icons/dist/js/icons/os-image-icon'; -import ArchiveIcon from '@patternfly/react-icons/dist/js/icons/archive-icon'; import type { TFunction } from 'react-i18next'; -import { type CustomDeviceInfo, type DeviceStatus, type DeviceSystemInfo, OsModeType } from '@flightctl/types'; +import type { DeviceSystemInfo } from '@flightctl/types'; -// Used to mark system info properties that we want to always display, regardless of whether they are set -const UnsetValue = 'unset'; - -// The API definition doesn't handle "customInfo" correctly -export type FixedDeviceSystemInfo = DeviceSystemInfo & { - customInfo?: CustomDeviceInfo; - osMode: OsModeType | typeof UnsetValue; -}; - -type SystemInfoEntry = { +export type SystemInfoEntry = { title: string; value: React.ReactNode; }; @@ -34,12 +22,12 @@ const excludedKnownProps = [ ]; const getInfoDataKnownKeys = (t: TFunction) => ({ - osMode: t('OS mode'), - architecture: t('Architecture'), - operatingSystem: t('Operating system'), agentVersion: t('Agent version'), - distroName: t('Distro'), + operatingSystem: t('Operating system'), hostname: t('Hostname'), + tpmVendorInfo: t('TPM vendor info'), + architecture: t('Architecture'), + distroName: t('Distro'), bootID: t('Boot ID'), kernel: t('Kernel'), netInterfaceDefault: t('Net interface default'), @@ -48,17 +36,9 @@ const getInfoDataKnownKeys = (t: TFunction) => ({ productName: t('Product name'), productSerial: t('Product serial'), productUuid: t('Product UUID'), - tpmVendorInfo: t('TPM vendor info'), }); -const buildSystemInfoAndCapabilities = (deviceStatus?: DeviceStatus): FixedDeviceSystemInfo => { - return { - ...(deviceStatus?.systemInfo ?? {}), - osMode: deviceStatus?.capabilities?.osMode ?? UnsetValue, - } as FixedDeviceSystemInfo; -}; - -const getSystemInfoValue = (systemInfo: FixedDeviceSystemInfo, infoKey: string, t: TFunction) => { +const getSystemInfoValue = (systemInfo: DeviceSystemInfo, infoKey: string) => { switch (infoKey) { case 'distroName': { if (systemInfo.distroVersion) { @@ -66,80 +46,40 @@ const getSystemInfoValue = (systemInfo: FixedDeviceSystemInfo, infoKey: string, } return systemInfo.distroName; } - case 'osMode': { - if (systemInfo.osMode === UnsetValue) { - return '-'; - } - - const isImageMode = systemInfo.osMode === OsModeType.OsModeImage; - return ( - - ); - } default: return systemInfo[infoKey]; } }; -export const useDeviceSpecSystemInfo = ( - deviceStatus: DeviceStatus | undefined, - t: TFunction, -): { - baseInfo: SystemInfoEntry[]; - customInfo: SystemInfoEntry[]; -} => { +export const useDeviceSpecSystemInfo = (systemInfo: DeviceSystemInfo | undefined, t: TFunction): SystemInfoEntry[] => { const infoDataKnownKeys = React.useMemo(() => getInfoDataKnownKeys(t), [t]); - const systemInfo = buildSystemInfoAndCapabilities(deviceStatus); + if (!systemInfo) { + return []; + } - // First show the properties known to us, and only those that are set - const baseInfoList = Object.entries(infoDataKnownKeys) + // Add the known fields first, in their desired order of appearance + const systemInfoItems = Object.entries(infoDataKnownKeys) .filter(([infoKey]) => { return !excludedKnownProps.includes(infoKey) && systemInfo[infoKey]; }) - .map(([infoKey, infoTitle]) => { - const infoDataValue = getSystemInfoValue(systemInfo, infoKey, t); - - return { - title: infoTitle, - value: infoDataValue, - }; - }); + .map(([infoKey, infoTitle]) => ({ + title: infoTitle, + value: getSystemInfoValue(systemInfo, infoKey), + })); - // Skip the known properties added in the previous phase. And only add any additional property which has a value + // Add any other fields that weren't included yet, in arbitrary order Object.keys(systemInfo).forEach((infoKey) => { if (infoDataKnownKeys[infoKey] || excludedKnownProps.includes(infoKey)) { return; } const value = systemInfo[infoKey]; if (value) { - baseInfoList.push({ + systemInfoItems.push({ title: propNameToTitle(infoKey), value, }); } }); - const customInfoList: SystemInfoEntry[] = []; - try { - const customInfo = systemInfo.customInfo || {}; - Object.keys(customInfo).forEach((customInfoKey) => { - const value = customInfo[customInfoKey]; - if (value) { - customInfoList.push({ - title: propNameToTitle(customInfoKey), - value, - }); - } - }); - } catch { - // eslint-disable-next-line no-console - console.warn('customInfo is not an object:', systemInfo.customInfo); - } - - return { - baseInfo: baseInfoList, - customInfo: customInfoList, - }; + return systemInfoItems; }; diff --git a/libs/ui-components/src/hooks/useRepositoryDetailsMap.ts b/libs/ui-components/src/hooks/useRepositoryDetailsMap.ts new file mode 100644 index 0000000000..bafe9f7e5d --- /dev/null +++ b/libs/ui-components/src/hooks/useRepositoryDetailsMap.ts @@ -0,0 +1,67 @@ +import * as React from 'react'; + +import { type Repository } from '@flightctl/types'; +import { type ConfigSourceProvider, getRepoName, isRepoConfig } from '../types/deviceSpec'; +import { isPromiseRejected } from '../types/typeUtils'; +import { getErrorMessage } from '../utils/error'; +import { getRepoUrlOrRegistry } from '../components/Repository/CreateRepository/utils'; +import { useFetch } from './useFetch'; + +export type RepositoryDetails = { + url?: string; + errorMsg?: string; +}; + +const useStableStringArray = (array: string[]) => { + const prevArrayRef = React.useRef(array); + + if (prevArrayRef.current.length !== array.length || prevArrayRef.current.some((item) => !array.includes(item))) { + prevArrayRef.current = array; + } + + return prevArrayRef.current; +}; + +export const useRepositoryDetailsMap = (configs: ConfigSourceProvider[]) => { + const { get } = useFetch(); + const repoConfigs = configs.filter(isRepoConfig); + const repositoryNames = useStableStringArray(repoConfigs.map(getRepoName)); + const [repoDetailsMap, setRepoDetailsMap] = React.useState>({}); + const [isLoading, setIsLoading] = React.useState(repositoryNames.length > 0); + + React.useEffect(() => { + if (repositoryNames.length === 0) { + setRepoDetailsMap({}); + setIsLoading(false); + return; + } + + setIsLoading(true); + + const fetchRepositories = async () => { + const promises = repositoryNames.map((repoName) => get(`repositories/${repoName}`)); + const results = await Promise.allSettled(promises); + + const map: Record = {}; + results.forEach((result, index) => { + const repoName = repositoryNames[index]; + if (isPromiseRejected(result)) { + map[repoName] = { errorMsg: getErrorMessage(result.reason) }; + return; + } + + map[repoName] = { url: getRepoUrlOrRegistry(result.value.spec) }; + }); + + setRepoDetailsMap(map); + setIsLoading(false); + }; + + void fetchRepositories(); + }, [get, repositoryNames]); + + return { + repoDetailsMap, + isLoading, + }; +}; diff --git a/libs/ui-components/src/hooks/useVulnerabilitySummary.ts b/libs/ui-components/src/hooks/useVulnerabilitySummary.ts index 8de69dd985..8ab357bea9 100644 --- a/libs/ui-components/src/hooks/useVulnerabilitySummary.ts +++ b/libs/ui-components/src/hooks/useVulnerabilitySummary.ts @@ -1,4 +1,9 @@ -import type { CveCountsBySeverity, VulnerabilitySummaryResponse } from '@flightctl/types/alpha'; +import type { + CveCountsBySeverity, + DeviceVulnerabilitySummaryResponse, + FleetVulnerabilitySummaryResponse, + VulnerabilitySummaryResponse, +} from '@flightctl/types/alpha'; import { useFetchPeriodically } from './useFetchPeriodically'; @@ -7,7 +12,7 @@ export type UseVulnerabilitySummaryResult = { isLoading: boolean; }; -const emptySeverityCounts = { +export const emptySeverityCounts: CveCountsBySeverity = { total: 0, critical: 0, high: 0, @@ -23,7 +28,29 @@ export const useVulnerabilitySummary = (): UseVulnerabilitySummaryResult => { }); return { - counts: summaryRes?.cvesBySeverity ?? emptySeverityCounts, + counts: summaryRes?.cvesBySeverity || emptySeverityCounts, + isLoading, + }; +}; + +export const useFleetVulnerabilitySummary = (fleetId: string): UseVulnerabilitySummaryResult => { + const [summaryRes, isLoading] = useFetchPeriodically({ + endpoint: `vulnerabilities/fleets/${fleetId}/summary`, + }); + + return { + counts: summaryRes?.summary || emptySeverityCounts, + isLoading, + }; +}; + +export const useDeviceVulnerabilitySummary = (deviceId: string): UseVulnerabilitySummaryResult => { + const [summaryRes, isLoading] = useFetchPeriodically({ + endpoint: `vulnerabilities/devices/${deviceId}/summary`, + }); + + return { + counts: summaryRes?.summary || emptySeverityCounts, isLoading, }; }; diff --git a/libs/ui-components/src/utils/capabilities.ts b/libs/ui-components/src/utils/capabilities.ts index d4735098b0..4754afb5a1 100644 --- a/libs/ui-components/src/utils/capabilities.ts +++ b/libs/ui-components/src/utils/capabilities.ts @@ -1,9 +1,9 @@ import { type Device, type DeviceCapabilities, OsModeType } from '@flightctl/types'; export const getDeviceCapability = ( - device: Device, + capabilities: DeviceCapabilities | undefined, capability: keyof DeviceCapabilities, -): DeviceCapabilities[keyof DeviceCapabilities] | undefined => device.status?.capabilities?.[capability]; +): DeviceCapabilities[keyof DeviceCapabilities] | undefined => capabilities?.[capability]; export const hasPackageModeCapability = (device: Device): boolean => - getDeviceCapability(device, 'osMode') === OsModeType.OsModePackage; + getDeviceCapability(device?.status?.capabilities, 'osMode') === OsModeType.OsModePackage; diff --git a/libs/ui-components/src/utils/status/applications.ts b/libs/ui-components/src/utils/status/applications.ts index ac93d85f6e..2dd3897443 100644 --- a/libs/ui-components/src/utils/status/applications.ts +++ b/libs/ui-components/src/utils/status/applications.ts @@ -4,73 +4,106 @@ import { ApplicationStatusType as AppStatus, ApplicationsSummaryStatusType as AppSummaryStatus, } from '@flightctl/types'; -import { type StatusItem } from './common'; import { ResourcesEmptyIcon } from '@patternfly/react-icons/dist/js/icons/resources-empty-icon'; import { PausedIcon } from '@patternfly/react-icons/dist/js/icons/paused-icon'; +import { type StatusItem, type StatusLevel, getStatusLevelFromMap } from './common'; + +const APP_SUMMARY_STATUS_LEVELS: Record = { + [AppSummaryStatus.ApplicationsSummaryStatusError]: 'danger', + [AppSummaryStatus.ApplicationsSummaryStatusDegraded]: 'warning', + [AppSummaryStatus.ApplicationsSummaryStatusNoApplications]: 'info', + [AppSummaryStatus.ApplicationsSummaryStatusHealthy]: 'success', + [AppSummaryStatus.ApplicationsSummaryStatusUnknown]: 'unknown', +}; + +const APP_STATUS_LEVELS: Record = { + [AppStatus.ApplicationStatusError]: 'danger', + [AppStatus.ApplicationStatusPreparing]: 'info', + [AppStatus.ApplicationStatusStarting]: 'info', + [AppStatus.ApplicationStatusStopping]: 'info', + [AppStatus.ApplicationStatusStopped]: 'custom', + [AppStatus.ApplicationStatusRunning]: 'success', + [AppStatus.ApplicationStatusCompleted]: 'success', + [AppStatus.ApplicationStatusUnknown]: 'unknown', +}; + +export const getAppSummaryStatusLevel = (status?: AppSummaryStatus) => + getStatusLevelFromMap(status, APP_SUMMARY_STATUS_LEVELS); + +export const getAppStatusLevel = (status?: AppStatus) => getStatusLevelFromMap(status, APP_STATUS_LEVELS); + export const getApplicationSummaryStatusItems = (t: TFunction): StatusItem[] => [ { id: AppSummaryStatus.ApplicationsSummaryStatusError, label: t('Error'), - level: 'danger', + level: APP_SUMMARY_STATUS_LEVELS[AppSummaryStatus.ApplicationsSummaryStatusError], }, { id: AppSummaryStatus.ApplicationsSummaryStatusDegraded, label: t('Degraded'), - level: 'warning', + level: APP_SUMMARY_STATUS_LEVELS[AppSummaryStatus.ApplicationsSummaryStatusDegraded], }, { id: AppSummaryStatus.ApplicationsSummaryStatusUnknown, label: t('Unknown'), - level: 'unknown', + level: APP_SUMMARY_STATUS_LEVELS[AppSummaryStatus.ApplicationsSummaryStatusUnknown], }, { id: AppSummaryStatus.ApplicationsSummaryStatusNoApplications, label: t('No applications'), - level: 'info', + level: APP_SUMMARY_STATUS_LEVELS[AppSummaryStatus.ApplicationsSummaryStatusNoApplications], customIcon: ResourcesEmptyIcon, }, { id: AppSummaryStatus.ApplicationsSummaryStatusHealthy, label: t('Healthy'), - level: 'success', + level: APP_SUMMARY_STATUS_LEVELS[AppSummaryStatus.ApplicationsSummaryStatusHealthy], }, ]; export const getApplicationStatusItems = (t: TFunction): StatusItem[] => [ - { id: AppStatus.ApplicationStatusError, label: t('Error'), level: 'danger' }, + { + id: AppStatus.ApplicationStatusError, + label: t('Error'), + level: APP_STATUS_LEVELS[AppStatus.ApplicationStatusError], + }, { id: AppStatus.ApplicationStatusPreparing, label: t('Preparing'), - level: 'info', + level: APP_STATUS_LEVELS[AppStatus.ApplicationStatusPreparing], + }, + { + id: AppStatus.ApplicationStatusStarting, + label: t('Starting'), + level: APP_STATUS_LEVELS[AppStatus.ApplicationStatusStarting], }, - { id: AppStatus.ApplicationStatusStarting, label: t('Starting'), level: 'info' }, { id: AppStatus.ApplicationStatusUnknown, label: t('Unknown'), - level: 'unknown', + level: APP_STATUS_LEVELS[AppStatus.ApplicationStatusUnknown], }, { id: AppStatus.ApplicationStatusRunning, label: t('Running'), - level: 'success', + level: APP_STATUS_LEVELS[AppStatus.ApplicationStatusRunning], }, { id: AppStatus.ApplicationStatusCompleted, label: t('Completed'), - level: 'success', + level: APP_STATUS_LEVELS[AppStatus.ApplicationStatusCompleted], }, { id: AppStatus.ApplicationStatusStopped, label: t('Stopped'), - level: 'custom', + level: APP_STATUS_LEVELS[AppStatus.ApplicationStatusStopped], customIcon: PausedIcon, }, { id: AppStatus.ApplicationStatusStopping, label: t('Stopping'), - level: 'info', + level: APP_STATUS_LEVELS[AppStatus.ApplicationStatusStopping], }, ]; diff --git a/libs/ui-components/src/utils/status/common.ts b/libs/ui-components/src/utils/status/common.ts index 24a4eecaf4..d9833d9cdd 100644 --- a/libs/ui-components/src/utils/status/common.ts +++ b/libs/ui-components/src/utils/status/common.ts @@ -15,6 +15,19 @@ import defaultColor from '@patternfly/react-tokens/dist/js/t_chart_color_black_2 export type StatusLevel = 'custom' | 'info' | 'success' | 'warning' | 'danger' | 'unknown'; +export type HealthLevel = 'danger' | 'warning' | null; + +export const getStatusLevelFromMap = ( + status: T | undefined, + levels: Record, +): StatusLevel => { + if (!status || !Object.prototype.hasOwnProperty.call(levels, status)) { + // Unrecognized status value (or a prototype key such as "toString") + return 'unknown'; + } + return levels[status]; +}; + export interface StatusItem { id: T; label: string; diff --git a/libs/ui-components/src/utils/status/devices.ts b/libs/ui-components/src/utils/status/devices.ts index f2980a9ffd..0ae2c6d526 100644 --- a/libs/ui-components/src/utils/status/devices.ts +++ b/libs/ui-components/src/utils/status/devices.ts @@ -16,7 +16,7 @@ import { type DeviceUpdatedStatusType, OsModeType, } from '@flightctl/types'; -import { type StatusItem } from './common'; +import { type StatusItem, type StatusLevel, getStatusLevelFromMap } from './common'; export enum FilterSearchParams { Fleet = 'fleetId', @@ -96,49 +96,73 @@ export const getDeviceLifecycleStatus = (device: Device): DeviceLifecycleStatusT return lifecycleStatus; }; +const DEVICE_SUMMARY_STATUS_LEVELS: Record = { + [DeviceSummaryStatusType.DeviceSummaryStatusError]: 'danger', + [DeviceSummaryStatusType.DeviceSummaryStatusDegraded]: 'warning', + [DeviceSummaryStatusType.DeviceSummaryStatusConflictPaused]: 'custom', + [DeviceSummaryStatusType.DeviceSummaryStatusPoweredOff]: 'custom', + [DeviceSummaryStatusType.DeviceSummaryStatusAwaitingReconnect]: 'info', + [DeviceSummaryStatusType.DeviceSummaryStatusRebooting]: 'info', + [DeviceSummaryStatusType.DeviceSummaryStatusOnline]: 'success', + [DeviceSummaryStatusType.DeviceSummaryStatusUnknown]: 'unknown', +}; + +export const getDeviceSummaryStatusLevel = (status?: DeviceSummaryStatusType) => + getStatusLevelFromMap(status, DEVICE_SUMMARY_STATUS_LEVELS); + +const DEVICE_LIFECYCLE_STATUS_LEVELS: Record = { + [DeviceLifecycleStatusType.DeviceLifecycleStatusDecommissioning]: 'warning', + [DeviceLifecycleStatusType.DeviceLifecycleStatusDecommissioned]: 'unknown', + [DeviceLifecycleStatusType.DeviceLifecycleStatusEnrolled]: 'success', + [DeviceLifecycleStatusType.DeviceLifecycleStatusUnknown]: 'unknown', +}; + +export const getDeviceLifecycleStatusLevel = (status?: DeviceLifecycleStatusType) => + getStatusLevelFromMap(status, DEVICE_LIFECYCLE_STATUS_LEVELS); + export const getDeviceStatusItems = (t: TFunction): StatusItem[] => [ { id: DeviceSummaryStatusType.DeviceSummaryStatusError, label: t('Error'), - level: 'danger', + level: DEVICE_SUMMARY_STATUS_LEVELS[DeviceSummaryStatusType.DeviceSummaryStatusError], }, { id: DeviceSummaryStatusType.DeviceSummaryStatusDegraded, label: t('Degraded'), - level: 'warning', + level: DEVICE_SUMMARY_STATUS_LEVELS[DeviceSummaryStatusType.DeviceSummaryStatusDegraded], }, { id: DeviceSummaryStatusType.DeviceSummaryStatusUnknown, label: t('Unknown'), - level: 'unknown', + level: DEVICE_SUMMARY_STATUS_LEVELS[DeviceSummaryStatusType.DeviceSummaryStatusUnknown], }, { id: DeviceSummaryStatusType.DeviceSummaryStatusRebooting, label: t('Rebooting'), - level: 'info', + level: DEVICE_SUMMARY_STATUS_LEVELS[DeviceSummaryStatusType.DeviceSummaryStatusRebooting], }, { id: DeviceSummaryStatusType.DeviceSummaryStatusPoweredOff, label: t('Powered Off'), - level: 'custom', + level: DEVICE_SUMMARY_STATUS_LEVELS[DeviceSummaryStatusType.DeviceSummaryStatusPoweredOff], customIcon: PowerOffIcon, }, { id: DeviceSummaryStatusType.DeviceSummaryStatusOnline, label: t('Online'), - level: 'success', + level: DEVICE_SUMMARY_STATUS_LEVELS[DeviceSummaryStatusType.DeviceSummaryStatusOnline], }, { id: DeviceSummaryStatusType.DeviceSummaryStatusAwaitingReconnect, label: t('Pending sync'), - level: 'info', + level: DEVICE_SUMMARY_STATUS_LEVELS[DeviceSummaryStatusType.DeviceSummaryStatusAwaitingReconnect], customIcon: PendingIcon, customColor: pendingSyncColor.value, }, { id: DeviceSummaryStatusType.DeviceSummaryStatusConflictPaused, label: t('Suspended'), - level: 'custom', + level: DEVICE_SUMMARY_STATUS_LEVELS[DeviceSummaryStatusType.DeviceSummaryStatusConflictPaused], customIcon: PauseCircleIcon, customColor: suspendedColor.value, }, @@ -190,23 +214,23 @@ export const getDeviceLifecycleStatusItems = (t: TFunction): StatusItem = { + [DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusFailed]: 'warning', + [DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusVerified]: 'success', + [DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusUnsupported]: 'unknown', + [DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusUnknown]: 'unknown', +}; + +export const getIntegrityStatusLevel = (status?: DeviceIntegrityStatusSummaryType) => + getStatusLevelFromMap(status, INTEGRITY_STATUS_LEVELS); export const getIntegrityStatusItems = (t: TFunction): StatusItem[] => [ { id: DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusFailed, label: t('Failed'), - level: 'warning', + level: INTEGRITY_STATUS_LEVELS[DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusFailed], }, { id: DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusUnsupported, label: t('Unsupported'), - level: 'unknown', + level: INTEGRITY_STATUS_LEVELS[DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusUnsupported], }, { id: DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusUnknown, label: t('Unknown'), - level: 'unknown', + level: INTEGRITY_STATUS_LEVELS[DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusUnknown], }, { id: DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusVerified, label: t('Verified'), - level: 'success', + level: INTEGRITY_STATUS_LEVELS[DeviceIntegrityStatusSummaryType.DeviceIntegrityStatusVerified], }, ]; diff --git a/libs/ui-components/src/utils/status/resources.ts b/libs/ui-components/src/utils/status/resources.ts new file mode 100644 index 0000000000..d6a6373faa --- /dev/null +++ b/libs/ui-components/src/utils/status/resources.ts @@ -0,0 +1,13 @@ +import { DeviceResourceStatusType } from '@flightctl/types'; +import { type StatusLevel, getStatusLevelFromMap } from './common'; + +const DEVICE_RESOURCE_STATUS_LEVELS: Record = { + [DeviceResourceStatusType.DeviceResourceStatusCritical]: 'danger', + [DeviceResourceStatusType.DeviceResourceStatusError]: 'danger', + [DeviceResourceStatusType.DeviceResourceStatusWarning]: 'warning', + [DeviceResourceStatusType.DeviceResourceStatusHealthy]: 'success', + [DeviceResourceStatusType.DeviceResourceStatusUnknown]: 'unknown', +}; + +export const getDeviceResourceStatusLevel = (status?: DeviceResourceStatusType) => + getStatusLevelFromMap(status, DEVICE_RESOURCE_STATUS_LEVELS); diff --git a/libs/ui-components/src/utils/status/system.ts b/libs/ui-components/src/utils/status/system.ts index 3fddcabd3b..165b71d585 100644 --- a/libs/ui-components/src/utils/status/system.ts +++ b/libs/ui-components/src/utils/status/system.ts @@ -1,28 +1,38 @@ import { type TFunction } from 'react-i18next'; import { DeviceUpdatedStatusType as UpdatedStatus } from '@flightctl/types'; -import { type StatusItem } from './common'; +import { type StatusItem, type StatusLevel, getStatusLevelFromMap } from './common'; + +const SYSTEM_UPDATE_STATUS_LEVELS: Record = { + [UpdatedStatus.DeviceUpdatedStatusOutOfDate]: 'warning', + [UpdatedStatus.DeviceUpdatedStatusUpdating]: 'info', + [UpdatedStatus.DeviceUpdatedStatusUpToDate]: 'success', + [UpdatedStatus.DeviceUpdatedStatusUnknown]: 'unknown', +}; + +export const getSystemUpdateStatusLevel = (status?: UpdatedStatus) => + getStatusLevelFromMap(status, SYSTEM_UPDATE_STATUS_LEVELS); export const getSystemUpdateStatusItems = (t: TFunction): StatusItem[] => [ { id: UpdatedStatus.DeviceUpdatedStatusOutOfDate, label: t('Out-of-date'), - level: 'warning', + level: SYSTEM_UPDATE_STATUS_LEVELS[UpdatedStatus.DeviceUpdatedStatusOutOfDate], }, { id: UpdatedStatus.DeviceUpdatedStatusUpdating, label: t('Updating'), - level: 'info', + level: SYSTEM_UPDATE_STATUS_LEVELS[UpdatedStatus.DeviceUpdatedStatusUpdating], }, { id: UpdatedStatus.DeviceUpdatedStatusUnknown, label: t('Unknown'), - level: 'unknown', + level: SYSTEM_UPDATE_STATUS_LEVELS[UpdatedStatus.DeviceUpdatedStatusUnknown], }, { id: UpdatedStatus.DeviceUpdatedStatusUpToDate, label: t('Up-to-date'), - level: 'success', + level: SYSTEM_UPDATE_STATUS_LEVELS[UpdatedStatus.DeviceUpdatedStatusUpToDate], }, ]; export const systemUpdateStatusOrder = getSystemUpdateStatusItems((s: string) => s).map((item) => item.id); diff --git a/libs/ui-components/src/utils/vulnerabilities.ts b/libs/ui-components/src/utils/vulnerabilities.ts index 6b527273a8..bb3a09db3a 100644 --- a/libs/ui-components/src/utils/vulnerabilities.ts +++ b/libs/ui-components/src/utils/vulnerabilities.ts @@ -68,6 +68,42 @@ export const getSeverityCountValue = (severity: Severity, counts: CveCountsBySev } }; +export type SeverityTileColumnSpan = 2 | 3 | 4; + +export type SeverityTilesLayout = 'overview' | 'entityDetail'; + +export type VisibleSeverityTilesConfig = { + severities: Severity[]; + columnSpan: SeverityTileColumnSpan; +}; + +export const getVisibleSeverityTilesConfig = ( + counts: Pick, + layout: SeverityTilesLayout = 'overview', +): VisibleSeverityTilesConfig => { + const hasAllSeverities = counts.none > 0 || counts.unknown > 0; + const severityThresholdIndex = VULNERABILITY_SEVERITY_ORDER.indexOf(Vulnerability.severity.LOW); + + const severities = VULNERABILITY_SEVERITY_ORDER.filter((severity, index) => { + if (!hasAllSeverities && index > severityThresholdIndex) { + return false; + } + return true; + }); + + let columnSpan: SeverityTileColumnSpan; + if (hasAllSeverities) { + columnSpan = layout === 'entityDetail' ? 2 : 4; + } else { + columnSpan = 3; + } + + return { + severities, + columnSpan, + }; +}; + export const getSeverityLabel = (severity: Severity, t: TFunction): string => { switch (severity) { case Vulnerability.severity.CRITICAL: @@ -85,3 +121,22 @@ export const getSeverityLabel = (severity: Severity, t: TFunction): string => { return t('Undefined'); } }; + +export const getSeverityToggleResult = ( + severity: Severity, + selectedSeverities: Severity[], +): { selectedSeverities: Severity[]; expandTable: boolean } => { + const isSelected = selectedSeverities.includes(severity); + + if (isSelected) { + return { + selectedSeverities: selectedSeverities.filter((selectedSeverity) => selectedSeverity !== severity), + expandTable: false, + }; + } + + return { + selectedSeverities: selectedSeverities.concat(severity), + expandTable: true, + }; +}; From d55f233c6ebfb061b88a4aaa01c6a79cb5adf2b4 Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Tue, 8 Sep 2026 14:43:13 +0200 Subject: [PATCH 2/2] Address review comments Made-with: Cursor --- .../EntitySecurityOverviewCard.tsx | 4 ++-- .../SecurityOverview/SecurityOverviewSummary.tsx | 8 ++++---- .../src/hooks/useDeviceOverallHealth.ts | 8 ++++---- .../src/hooks/useRepositoryDetailsMap.ts | 15 +++++++++++++-- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/libs/ui-components/src/components/SecurityOverview/EntitySecurityOverviewCard.tsx b/libs/ui-components/src/components/SecurityOverview/EntitySecurityOverviewCard.tsx index db0e534d90..29ce43b690 100644 --- a/libs/ui-components/src/components/SecurityOverview/EntitySecurityOverviewCard.tsx +++ b/libs/ui-components/src/components/SecurityOverview/EntitySecurityOverviewCard.tsx @@ -73,7 +73,7 @@ const EntitySecurityOverviewCard = ({ const { t } = useTranslation(); const [isTableExpanded, setIsTableExpanded] = React.useState(false); - const handleSeverityToggle = React.useCallback( + const onSeverityToggle = React.useCallback( (severity: Severity) => { setSelectedSeverities((currentSeverities) => { const { selectedSeverities: nextSeverities, expandTable } = getSeverityToggleResult( @@ -113,7 +113,7 @@ const EntitySecurityOverviewCard = ({ diff --git a/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.tsx b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.tsx index c97924a1e6..37abaee576 100644 --- a/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.tsx +++ b/libs/ui-components/src/components/SecurityOverview/SecurityOverviewSummary.tsx @@ -57,13 +57,13 @@ const SeverityStat = ({ const isSelectable = interactive && count > 0; const severityLabel = getSeverityLabel(severity, t); - const handleClick = () => { + const onClick = () => { if (isSelectable) { onToggle?.(severity); } }; - const handleKeyDown = (event: React.KeyboardEvent) => { + const onKeyDown = (event: React.KeyboardEvent) => { if (!isSelectable) { return; } @@ -89,8 +89,8 @@ const SeverityStat = ({ alignItems={{ default: 'alignItemsCenter' }} role={isSelectable ? 'button' : undefined} tabIndex={isSelectable ? 0 : undefined} - onClick={isSelectable ? handleClick : undefined} - onKeyDown={isSelectable ? handleKeyDown : undefined} + onClick={isSelectable ? onClick : undefined} + onKeyDown={isSelectable ? onKeyDown : undefined} aria-pressed={isSelectable ? isSelected : undefined} aria-label={ isSelectable ? t('Filter table by {{severity}} severity', { severity: severityLabel }) : severityLabel diff --git a/libs/ui-components/src/hooks/useDeviceOverallHealth.ts b/libs/ui-components/src/hooks/useDeviceOverallHealth.ts index ee1e560053..2a07153f58 100644 --- a/libs/ui-components/src/hooks/useDeviceOverallHealth.ts +++ b/libs/ui-components/src/hooks/useDeviceOverallHealth.ts @@ -71,11 +71,11 @@ const getApplicationHealth = (deviceStatus: DeviceStatus): DeviceHealthItem => { deviceStatus.applications.forEach((app) => { const level = getAppStatusLevel(app.status); - if (level === 'danger') { - hasErrors = true; - } - if (level !== null) { + if (isAttentionHealthLevel(level)) { counts += 1; + if (level === 'danger') { + hasErrors = true; + } } }); diff --git a/libs/ui-components/src/hooks/useRepositoryDetailsMap.ts b/libs/ui-components/src/hooks/useRepositoryDetailsMap.ts index bafe9f7e5d..fcd848e319 100644 --- a/libs/ui-components/src/hooks/useRepositoryDetailsMap.ts +++ b/libs/ui-components/src/hooks/useRepositoryDetailsMap.ts @@ -25,7 +25,7 @@ const useStableStringArray = (array: string[]) => { export const useRepositoryDetailsMap = (configs: ConfigSourceProvider[]) => { const { get } = useFetch(); const repoConfigs = configs.filter(isRepoConfig); - const repositoryNames = useStableStringArray(repoConfigs.map(getRepoName)); + const repositoryNames = useStableStringArray([...new Set(repoConfigs.map(getRepoName))]); const [repoDetailsMap, setRepoDetailsMap] = React.useState>({}); const [isLoading, setIsLoading] = React.useState(repositoryNames.length > 0); @@ -36,12 +36,19 @@ export const useRepositoryDetailsMap = (configs: ConfigSourceProvider[]) => { return; } + const abortController = new AbortController(); setIsLoading(true); const fetchRepositories = async () => { - const promises = repositoryNames.map((repoName) => get(`repositories/${repoName}`)); + const promises = repositoryNames.map((repoName) => + get(`repositories/${repoName}`, abortController.signal), + ); const results = await Promise.allSettled(promises); + if (abortController.signal.aborted) { + return; + } + const map: Record = {}; results.forEach((result, index) => { const repoName = repositoryNames[index]; @@ -58,6 +65,10 @@ export const useRepositoryDetailsMap = (configs: ConfigSourceProvider[]) => { }; void fetchRepositories(); + + return () => { + abortController.abort(); + }; }, [get, repositoryNames]); return {