- |
+ |
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 (
+ : }>
+ {isImageMode ? t('Image') : t('Package')}
+
+ );
+};
+
+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..2a07153f58
--- /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 (isAttentionHealthLevel(level)) {
+ counts += 1;
+ if (level === 'danger') {
+ hasErrors = true;
+ }
+ }
+ });
+
+ 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 (
- : }>
- {isImageMode ? t('Image') : t('Package')}
-
- );
- }
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..fcd848e319
--- /dev/null
+++ b/libs/ui-components/src/hooks/useRepositoryDetailsMap.ts
@@ -0,0 +1,78 @@
+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([...new Set(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;
+ }
+
+ const abortController = new AbortController();
+ setIsLoading(true);
+
+ const fetchRepositories = async () => {
+ 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];
+ if (isPromiseRejected(result)) {
+ map[repoName] = { errorMsg: getErrorMessage(result.reason) };
+ return;
+ }
+
+ map[repoName] = { url: getRepoUrlOrRegistry(result.value.spec) };
+ });
+
+ setRepoDetailsMap(map);
+ setIsLoading(false);
+ };
+
+ void fetchRepositories();
+
+ return () => {
+ abortController.abort();
+ };
+ }, [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,
+ };
+};
| |