diff --git a/libs/types/assisted-installer-service.d.ts b/libs/types/assisted-installer-service.d.ts index 39e585e2cb..202044ee42 100644 --- a/libs/types/assisted-installer-service.d.ts +++ b/libs/types/assisted-installer-service.d.ts @@ -205,17 +205,17 @@ export interface Cluster { * Status of the OpenShift cluster. */ status: - | 'insufficient' - | 'ready' - | 'error' - | 'preparing-for-installation' - | 'pending-for-input' - | 'installing' - | 'finalizing' - | 'installed' - | 'adding-hosts' - | 'cancelled' - | 'installing-pending-user-action'; + | 'insufficient' + | 'ready' + | 'error' + | 'preparing-for-installation' + | 'pending-for-input' + | 'installing' + | 'finalizing' + | 'installed' + | 'adding-hosts' + | 'cancelled' + | 'installing-pending-user-action'; /** * Additional information pertaining to the status of the OpenShift cluster. */ @@ -342,15 +342,15 @@ export interface Cluster { * Enable/disable hyperthreading on master nodes, arbiter nodes, worker nodes, or a combination of them. */ hyperthreading?: - | 'none' - | 'masters' - | 'arbiters' - | 'workers' - | 'masters,arbiters' - | 'masters,workers' - | 'arbiters,workers' - | 'masters,arbiters,workers' - | 'all'; + | 'none' + | 'masters' + | 'arbiters' + | 'workers' + | 'masters,arbiters' + | 'masters,workers' + | 'arbiters,workers' + | 'masters,arbiters,workers' + | 'all'; /** * JSON-formatted string containing the usage information by feature name */ @@ -494,15 +494,15 @@ export interface ClusterCreateParams { * Enable/disable hyperthreading on master nodes, arbiter nodes, worker nodes, or a combination of them. */ hyperthreading?: - | 'none' - | 'masters' - | 'arbiters' - | 'workers' - | 'masters,arbiters' - | 'masters,workers' - | 'arbiters,workers' - | 'masters,arbiters,workers' - | 'all'; + | 'none' + | 'masters' + | 'arbiters' + | 'workers' + | 'masters,arbiters' + | 'masters,workers' + | 'arbiters,workers' + | 'masters,arbiters,workers' + | 'all'; /** * The desired network type used. */ @@ -688,7 +688,9 @@ export type ClusterValidationId = | 'oadp-requirements-satisfied' | 'metallb-requirements-satisfied' | 'loki-requirements-satisfied' - | 'openshift-logging-requirements-satisfied'; + | 'openshift-logging-requirements-satisfied' + | 'network-observability-requirements-satisfied' + | 'network-observability-host-requirements-satisfied'; export interface CompletionParams { isSuccess: boolean; errorInfo?: string; @@ -880,15 +882,15 @@ export interface DiskEncryption { * Enable/disable disk encryption on master nodes, arbiter nodes, worker nodes, or a combination of them. */ enableOn?: - | 'none' - | 'masters' - | 'arbiters' - | 'workers' - | 'masters,arbiters' - | 'masters,workers' - | 'arbiters,workers' - | 'masters,arbiters,workers' - | 'all'; + | 'none' + | 'masters' + | 'arbiters' + | 'workers' + | 'masters,arbiters' + | 'masters,workers' + | 'arbiters,workers' + | 'masters,arbiters,workers' + | 'all'; /** * The disk encryption mode to use. */ @@ -1103,7 +1105,8 @@ export type FeatureSupportLevelId = | 'OADP' | 'METALLB' | 'LOKI' - | 'OPENSHIFT_LOGGING'; + | 'OPENSHIFT_LOGGING' + | 'NETWORK_OBSERVABILITY'; /** * Cluster finalizing stage managed by controller */ @@ -1169,34 +1172,34 @@ export interface Host { */ infraEnvId?: string; // uuid status: - | 'discovering' - | 'known' - | 'disconnected' - | 'insufficient' - | 'disabled' - | 'preparing-for-installation' - | 'preparing-failed' - | 'preparing-successful' - | 'pending-for-input' - | 'installing' - | 'installing-in-progress' - | 'installing-pending-user-action' - | 'resetting-pending-user-action' - | 'installed' - | 'error' - | 'resetting' - | 'added-to-existing-cluster' - | 'cancelled' - | 'binding' - | 'unbinding' - | 'unbinding-pending-user-action' - | 'known-unbound' - | 'disconnected-unbound' - | 'insufficient-unbound' - | 'disabled-unbound' - | 'discovering-unbound' - | 'reclaiming' - | 'reclaiming-rebooting'; + | 'discovering' + | 'known' + | 'disconnected' + | 'insufficient' + | 'disabled' + | 'preparing-for-installation' + | 'preparing-failed' + | 'preparing-successful' + | 'pending-for-input' + | 'installing' + | 'installing-in-progress' + | 'installing-pending-user-action' + | 'resetting-pending-user-action' + | 'installed' + | 'error' + | 'resetting' + | 'added-to-existing-cluster' + | 'cancelled' + | 'binding' + | 'unbinding' + | 'unbinding-pending-user-action' + | 'known-unbound' + | 'disconnected-unbound' + | 'insufficient-unbound' + | 'disabled-unbound' + | 'discovering-unbound' + | 'reclaiming' + | 'reclaiming-rebooting'; statusInfo: string; /** * JSON-formatted string containing the validation results for each validation id grouped by category (network, hardware, etc.) @@ -1376,34 +1379,34 @@ export interface HostRegistrationResponse { */ infraEnvId?: string; // uuid status: - | 'discovering' - | 'known' - | 'disconnected' - | 'insufficient' - | 'disabled' - | 'preparing-for-installation' - | 'preparing-failed' - | 'preparing-successful' - | 'pending-for-input' - | 'installing' - | 'installing-in-progress' - | 'installing-pending-user-action' - | 'resetting-pending-user-action' - | 'installed' - | 'error' - | 'resetting' - | 'added-to-existing-cluster' - | 'cancelled' - | 'binding' - | 'unbinding' - | 'unbinding-pending-user-action' - | 'known-unbound' - | 'disconnected-unbound' - | 'insufficient-unbound' - | 'disabled-unbound' - | 'discovering-unbound' - | 'reclaiming' - | 'reclaiming-rebooting'; + | 'discovering' + | 'known' + | 'disconnected' + | 'insufficient' + | 'disabled' + | 'preparing-for-installation' + | 'preparing-failed' + | 'preparing-successful' + | 'pending-for-input' + | 'installing' + | 'installing-in-progress' + | 'installing-pending-user-action' + | 'resetting-pending-user-action' + | 'installed' + | 'error' + | 'resetting' + | 'added-to-existing-cluster' + | 'cancelled' + | 'binding' + | 'unbinding' + | 'unbinding-pending-user-action' + | 'known-unbound' + | 'disconnected-unbound' + | 'insufficient-unbound' + | 'disabled-unbound' + | 'discovering-unbound' + | 'reclaiming' + | 'reclaiming-rebooting'; statusInfo: string; /** * JSON-formatted string containing the validation results for each validation id grouped by category (network, hardware, etc.) @@ -1670,7 +1673,9 @@ export type HostValidationId = | 'oadp-requirements-satisfied' | 'metallb-requirements-satisfied' | 'loki-requirements-satisfied' - | 'openshift-logging-requirements-satisfied'; + | 'openshift-logging-requirements-satisfied' + | 'network-observability-requirements-satisfied' + | 'network-observability-host-requirements-satisfied'; /** * Explicit ignition endpoint overrides the default ignition endpoint. */ @@ -2836,15 +2841,15 @@ export interface V2ClusterUpdateParams { * Enable/disable hyperthreading on master nodes, arbiter nodes, worker nodes, or a combination of them. */ hyperthreading?: - | 'none' - | 'masters' - | 'arbiters' - | 'workers' - | 'masters,arbiters' - | 'masters,workers' - | 'arbiters,workers' - | 'masters,arbiters,workers' - | 'all'; + | 'none' + | 'masters' + | 'arbiters' + | 'workers' + | 'masters,arbiters' + | 'masters,workers' + | 'arbiters,workers' + | 'masters,arbiters,workers' + | 'all'; /** * The desired network type used. */ diff --git a/libs/ui-lib/lib/common/api/assisted-service/OperatorsAPI.ts b/libs/ui-lib/lib/common/api/assisted-service/OperatorsAPI.ts index 01f823eab6..5992b43313 100644 --- a/libs/ui-lib/lib/common/api/assisted-service/OperatorsAPI.ts +++ b/libs/ui-lib/lib/common/api/assisted-service/OperatorsAPI.ts @@ -1,4 +1,5 @@ import { client } from '../axiosClient'; +import { OperatorProperties } from '@openshift-assisted/types/assisted-installer-service'; const OperatorsAPI = { makeBaseURI() { @@ -8,6 +9,12 @@ const OperatorsAPI = { list() { return client.get(`${OperatorsAPI.makeBaseURI()}`); }, + + getProperties(operatorName: string) { + return client.get( + `${OperatorsAPI.makeBaseURI()}/${encodeURIComponent(operatorName)}`, + ); + }, }; export default OperatorsAPI; diff --git a/libs/ui-lib/lib/common/components/operators/operatorDescriptions.tsx b/libs/ui-lib/lib/common/components/operators/operatorDescriptions.tsx index 331c5637e2..c79a467452 100644 --- a/libs/ui-lib/lib/common/components/operators/operatorDescriptions.tsx +++ b/libs/ui-lib/lib/common/components/operators/operatorDescriptions.tsx @@ -74,3 +74,6 @@ export const DESCRIPTION_OADP = export const DESCRIPTION_METALLB = 'Provides load balancer services for bare metal OpenShift clusters.'; + +export const DESCRIPTION_NETWORK_OBSERVABILITY = + 'Provides network flow monitoring and observability capabilities using eBPF-based flow collection.'; diff --git a/libs/ui-lib/lib/common/components/operators/operatorSpecs.tsx b/libs/ui-lib/lib/common/components/operators/operatorSpecs.tsx index c3688fabe2..7c001b759a 100644 --- a/libs/ui-lib/lib/common/components/operators/operatorSpecs.tsx +++ b/libs/ui-lib/lib/common/components/operators/operatorSpecs.tsx @@ -29,6 +29,7 @@ import { OPERATOR_NAME_NUMA_RESOURCES, OPERATOR_NAME_OADP, OPERATOR_NAME_METALLB, + OPERATOR_NAME_NETWORK_OBSERVABILITY, } from '../../config/constants'; import { ExternalLink } from '../ui'; import { @@ -49,6 +50,7 @@ import { getNvidiaGpuLink, getOadpLink, getOpenShiftLoggingLink, + getNetworkObservabilityLink, MTV_LINK, NODE_HEALTHCHECK_LINK, NODE_MAINTENANCE_LINK, @@ -92,6 +94,7 @@ import { DESCRIPTION_NUMA_RESOURCES, DESCRIPTION_OADP, DESCRIPTION_METALLB, + DESCRIPTION_NETWORK_OBSERVABILITY, } from './operatorDescriptions'; // TODO check if it's unused and it can be deleted in favor of "isMajorMinorVersionEqualOrGreater" @@ -348,6 +351,19 @@ export const getOperatorSpecs = ( notStandalone: true, supportLevel: getFeatureSupportLevel('METALLB'), }, + { + operatorKey: OPERATOR_NAME_NETWORK_OBSERVABILITY, + title: 'Network Observability', + featureId: 'NETWORK_OBSERVABILITY', + descriptionText: DESCRIPTION_NETWORK_OBSERVABILITY, + Description: ({ openshiftVersion, searchTerm }) => ( + <> + {' '} + Learn more + + ), + supportLevel: getFeatureSupportLevel('NETWORK_OBSERVABILITY'), + }, ], [categories[Category.REMEDIATION]]: [ { diff --git a/libs/ui-lib/lib/common/config/constants.ts b/libs/ui-lib/lib/common/config/constants.ts index a814c71366..1d016ee75b 100644 --- a/libs/ui-lib/lib/common/config/constants.ts +++ b/libs/ui-lib/lib/common/config/constants.ts @@ -150,6 +150,8 @@ export const hostValidationLabels = (t: TFunction): { [key in HostValidationId]: 'numa-resources-requirements-satisfied': t('ai:NUMA Resources requirements'), 'oadp-requirements-satisfied': t('ai:OADP requirements'), 'metallb-requirements-satisfied': t('ai:MetalLB requirements'), + 'network-observability-requirements-satisfied': t('ai:Network Observability requirements'), + 'network-observability-host-requirements-satisfied': t('ai:Network Observability host requirements'), }); export const hostValidationFailureHints = ( @@ -223,6 +225,8 @@ export const hostValidationFailureHints = ( 'numa-resources-requirements-satisfied': '', 'oadp-requirements-satisfied': '', 'metallb-requirements-satisfied': '', + 'network-observability-requirements-satisfied': '', + 'network-observability-host-requirements-satisfied': '', }); export const clusterValidationLabels = ( @@ -259,7 +263,8 @@ export const clusterValidationLabels = ( 'numa-resources-requirements-satisfied': t('ai:NUMA Resources requirements'), 'oadp-requirements-satisfied': t('ai:OADP requirements'), 'metallb-requirements-satisfied': t('ai:MetalLB requirements'), -}); + 'network-observability-requirements-satisfied': t('ai:Network Observability requirements'), +} as { [key in ClusterValidationId]?: string }); export const clusterValidationGroupLabels = ( t: TFunction, @@ -345,6 +350,7 @@ export const OPERATOR_NAME_OPENSHIFT_LOGGING = 'openshift-logging'; export const OPERATOR_NAME_NUMA_RESOURCES = 'numaresources'; export const OPERATOR_NAME_OADP = 'oadp'; export const OPERATOR_NAME_METALLB = 'metallb'; +export const OPERATOR_NAME_NETWORK_OBSERVABILITY = 'network-observability'; export const singleClusterOperators = [ OPERATOR_NAME_CNV, diff --git a/libs/ui-lib/lib/common/config/docs_links.ts b/libs/ui-lib/lib/common/config/docs_links.ts index 39fb8a296f..528089575b 100644 --- a/libs/ui-lib/lib/common/config/docs_links.ts +++ b/libs/ui-lib/lib/common/config/docs_links.ts @@ -237,3 +237,8 @@ export const getNumaResourcesLink = (ocpVersion?: string) => `https://docs.redhat.com/en/documentation/openshift_container_platform/${getDocsOpenshiftVersion( ocpVersion, )}/html/scalability_and_performance/cnf-numa-aware-scheduling`; + +export const getNetworkObservabilityLink = (ocpVersion?: string) => + `https://docs.redhat.com/en/documentation/openshift_container_platform/${getDocsOpenshiftVersion( + ocpVersion, + )}/html/network_observability`; diff --git a/libs/ui-lib/lib/common/types/clusters.ts b/libs/ui-lib/lib/common/types/clusters.ts index 1771ec1235..b6f3511c34 100644 --- a/libs/ui-lib/lib/common/types/clusters.ts +++ b/libs/ui-lib/lib/common/types/clusters.ts @@ -61,6 +61,7 @@ export type StorageValues = V2ClusterUpdateParams & { export type OperatorsValues = { selectedBundles: string[]; selectedOperators: string[]; + operatorProperties: Record; // operator name -> properties JSON string }; export type SupportedPlatformType = Extract; diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/operators/OperatorCheckbox.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/operators/OperatorCheckbox.tsx index ba36c2ded6..ee633616e4 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/operators/OperatorCheckbox.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/operators/OperatorCheckbox.tsx @@ -16,13 +16,14 @@ import { Bundle, Cluster, PreflightHardwareRequirements, + OperatorProperties, } from '@openshift-assisted/types/assisted-installer-service'; import { selectCurrentClusterPermissionsState, selectIsCurrentClusterSNO, } from '../../../store/slices/current-cluster/selectors'; import NewFeatureSupportLevelBadge from '../../../../common/components/newFeatureSupportLevels/NewFeatureSupportLevelBadge'; -import { getFieldId, OperatorsValues, PopoverIcon } from '../../../../common'; +import { getFieldId, OperatorsValues, PopoverIcon, getApiErrorMessage, handleApiError, useAlerts } from '../../../../common'; import { useNewFeatureSupportLevel } from '../../../../common/components/newFeatureSupportLevels'; import { getNewOperators } from './utils'; import { @@ -30,6 +31,8 @@ import { OperatorSpec, useOperatorSpecs, } from '../../../../common/components/operators/operatorSpecs'; +import OperatorsService from '../../../services/OperatorsService'; +import OperatorPropertiesForm from './OperatorPropertiesForm'; const OperatorRequirements = ({ operatorId, @@ -130,12 +133,17 @@ const OperatorCheckbox = ({ } & OperatorSpec) => { const { getFeatureSupportLevel, getFeatureDisabledReason } = useNewFeatureSupportLevel(); const { byKey: opSpecs } = useOperatorSpecs(); + const { addAlert } = useAlerts(); const { isViewerMode } = useSelector(selectCurrentClusterPermissionsState); const { values, setFieldValue } = useFormikContext(); const fieldId = getFieldId(operatorId, 'input'); const supportLevel = getFeatureSupportLevel(featureId); + const [operatorProperties, setOperatorProperties] = React.useState([]); + const [propertiesLoading, setPropertiesLoading] = React.useState(false); + const [propertiesFetched, setPropertiesFetched] = React.useState(false); + const isInBundle = values.selectedBundles.some( (sb) => !!bundles.find((b) => b.id === sb)?.operators?.includes(operatorId), ); @@ -157,10 +165,47 @@ const OperatorCheckbox = ({ const disabledReason = isInBundle ? 'This operator is part of a bundle and cannot be deselected.' : notStandalone - ? 'This operator cannot be installed as a standalone' - : parentOperatorName - ? `This operator is a dependency of ${parentOperatorName}` - : getFeatureDisabledReason(featureId); + ? 'This operator cannot be installed as a standalone' + : parentOperatorName + ? `This operator is a dependency of ${parentOperatorName}` + : getFeatureDisabledReason(featureId); + + // Fetch operator properties when operator is selected + React.useEffect(() => { + if (isChecked && !propertiesFetched && !propertiesLoading) { + setPropertiesLoading(true); + let cancelled = false; + OperatorsService.getOperatorProperties(operatorId) + .then((properties) => { + if (!cancelled) { + setOperatorProperties(properties); + setPropertiesFetched(true); + } + }) + .catch((error) => { + if (!cancelled) { + handleApiError(error, () => + addAlert({ + title: 'Failed to fetch operator properties', + message: getApiErrorMessage(error), + }), + ); + } + }) + .finally(() => { + if (!cancelled) { + setPropertiesLoading(false); + } + }); + return () => { + cancelled = true; + }; + } else if (!isChecked) { + // Clear properties when operator is unchecked to allow refetch on re-check + setOperatorProperties([]); + setPropertiesFetched(false); + } + }, [isChecked, operatorId, propertiesFetched, propertiesLoading, addAlert]); return ( @@ -206,6 +251,13 @@ const OperatorCheckbox = ({ } data-testid={`operator-checkbox-${operatorId}`} /> + {isChecked && operatorProperties.length > 0 && ( + + )} ); }; diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/operators/OperatorPropertiesForm.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/operators/OperatorPropertiesForm.tsx new file mode 100644 index 0000000000..20ed6eced6 --- /dev/null +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/operators/OperatorPropertiesForm.tsx @@ -0,0 +1,391 @@ +import * as React from 'react'; +import { + ExpandableSection, + FormGroup, + HelperText, + HelperTextItem, + Stack, + StackItem, + TextInput, + NumberInput, + Switch, +} from '@patternfly/react-core'; +import { useFormikContext } from 'formik'; +import { OperatorProperty } from '@openshift-assisted/types/assisted-installer-service'; +import { OperatorsValues } from '../../../../common/types/clusters'; +import { getFieldId } from '../../../../common'; + +// API response type: matches the actual OpenAPI spec with snake_case fields +// The API returns data_type and default_value (snake_case) +interface ApiOperatorProperty { + name?: string; + data_type?: string; + default_value?: string; + description?: string; + mandatory?: boolean; + options?: string[]; +} + +// Normalized internal type with camelCase fields +interface NormalizedOperatorProperty { + name?: string; + dataType?: string; + defaultValue?: string; + description?: string; + mandatory?: boolean; + options?: string[]; +} + +interface OperatorPropertiesFormProps { + operatorId: string; + // Accept API response type - may be snake_case (API contract) or camelCase (if axios-case-converter converted it) + // Will be normalized internally via normalizeOperatorProperty + properties: (ApiOperatorProperty | OperatorProperty)[]; + isDisabled?: boolean; +} + +// Helper function to sanitize a value to ensure it's JSON-serializable +// This function ALWAYS returns a primitive (string, number, or boolean) +// It rejects: objects, arrays, functions, symbols, bigint, and any other non-primitive types +// +// Note: The extensive defensive checks here serve multiple purposes: +// 1. Safety against corrupted state from previous versions or edge cases +// 2. Ensuring JSON serialization always succeeds (operatorProperties must be JSON-serializable) +// 3. Future-proofing against unexpected input types from form state or component callbacks +// While callers should pass primitives directly, this layer provides a safety net. +const sanitizeValue = (value: unknown): string | number | boolean => { + // Handle null and undefined - convert to empty string + if (value === null || value === undefined) { + return ''; + } + + // If it's already a primitive, return it directly + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + // Explicitly reject functions (including methods like isDefaultPrevented) + if (typeof value === 'function') { + return ''; + } + + // For ANY object type (including DOM elements, event objects, arrays, plain objects, etc.) + // we return empty string - we only want primitives + // This includes objects with nested properties like nativeEvent, currentTarget, target, etc. + if (typeof value === 'object') { + // Even if it's a plain object that could be stringified, we don't want objects + // We only want primitive values (string, number, boolean) + // This handles event objects with properties like: + // - isDefaultPrevented (function) + // - nativeEvent (object) + // - currentTarget (DOM element) + // - target (DOM element) + // - preventDefault (function) + // etc. + return ''; + } + + // For other types (symbols, bigint, etc.), return empty string + return ''; +}; + +// Shared helper to filter and sanitize a properties object +// This ensures we ONLY have primitive values (string, number, boolean) - NO objects, NO functions +// Used by both cleanProperties and updateProperty to maintain consistency +const filterToSerializableProperties = ( + props: Record, +): Record => { + const filtered: Record = {}; + for (const key in props) { + if (Object.prototype.hasOwnProperty.call(props, key) && typeof key === 'string') { + const value = props[key]; + + // CRITICAL: Skip functions explicitly (including methods like isDefaultPrevented, preventDefault, etc.) + if (typeof value === 'function') { + continue; + } + + // CRITICAL: If the value is an object (including event objects, DOM elements, arrays, etc.), skip it entirely + // We only want primitive values + // This handles event objects with nested properties like: + // - createFlowCollector: { isDefaultPrevented: function, nativeEvent: object, currentTarget: DOM element, etc. } + if (typeof value === 'object' && value !== null) { + // Skip objects entirely - we only want primitives + continue; + } + + // Sanitize the value (should be primitive now: string, number, boolean, null, or undefined) + filtered[key] = sanitizeValue(value); + } + } + return filtered; +}; + +// Helper function to clean properties object, removing any non-serializable values +// This ensures we ONLY have primitive values (string, number, boolean) - NO objects, NO functions +const cleanProperties = (props: Record): Record => { + try { + const cleaned = filterToSerializableProperties(props); + // Verify the cleaned object can be stringified + JSON.stringify(cleaned); + return cleaned; + } catch (error) { + // If cleaning fails, return empty object + console.warn('Failed to clean properties:', error); + return {}; + } +}; + +// Deserialization layer: maps API fields (snake_case) to internal camelCase fields +// The API returns snake_case (data_type, default_value) per the OpenAPI spec. +// Note: axios-case-converter may convert responses to camelCase at runtime, +// but the type reflects the actual API contract (snake_case). +// This function normalizes the API response to camelCase for internal use. +const normalizeOperatorProperty = (prop: ApiOperatorProperty | OperatorProperty): NormalizedOperatorProperty => { + // Handle both snake_case (API contract) and camelCase (if axios-case-converter converted it) + // Prefer snake_case as the source of truth per the OpenAPI spec + const apiProp = prop as ApiOperatorProperty; + const camelProp = prop as OperatorProperty; + + return { + name: prop.name, + // Prefer snake_case from API, fallback to camelCase if converter was applied + dataType: apiProp.data_type ?? camelProp.dataType, + defaultValue: apiProp.default_value ?? camelProp.defaultValue, + description: prop.description, + mandatory: prop.mandatory, + options: prop.options, + }; +}; + +// Helper function to compute NumberInput value from currentValue and defaultValue +const computeNumberInputValue = ( + currentValue: unknown, + defaultValue: unknown, +): number => { + // Ensure we always return a number for NumberInput + if (typeof currentValue === 'number') { + return currentValue; + } + if (typeof currentValue === 'string') { + const parsed = parseInt(currentValue, 10); + if (!isNaN(parsed)) { + return parsed; + } + } + // Fall back to defaultValue + if (typeof defaultValue === 'number') { + return defaultValue; + } + if (typeof defaultValue === 'string') { + const parsed = parseInt(defaultValue, 10); + if (!isNaN(parsed)) { + return parsed; + } + } + return 0; +}; + +const OperatorPropertiesForm: React.FC = ({ + operatorId, + properties, + isDisabled = false, +}) => { + const { values, setFieldValue } = useFormikContext(); + const [isExpanded, setIsExpanded] = React.useState(false); + const initializationRef = React.useRef(false); + + // Parse current properties JSON or use defaults + const currentProperties = React.useMemo(() => { + const propertiesJson = values.operatorProperties?.[operatorId] || '{}'; + try { + // First, verify the JSON string is valid + if (typeof propertiesJson !== 'string') { + return {}; + } + const parsed = JSON.parse(propertiesJson); + // Ensure parsed is an object + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return {}; + } + // Clean the parsed properties to remove any non-serializable values + const cleaned = cleanProperties(parsed); + // Verify the cleaned object can be stringified (sanity check) + try { + JSON.stringify(cleaned); + return cleaned; + } catch { + // If cleaned object still can't be stringified, return empty + return {}; + } + } catch { + return {}; + } + }, [values.operatorProperties, operatorId]); + + // Initialize with default values if not set + // Note: initializationRef prevents re-initialization after the first render, which works for the typical + // use case (operator checked → component mounts → defaults initialized → operator unchecked → component + // unmounts → operator checked again → component remounts with fresh ref). However, there's an edge case: + // if values.operatorProperties[operatorId] is cleared by an external action while the component remains + // mounted, re-initialization won't occur because initializationRef.current stays true. This seems unlikely + // in practice but worth noting for future maintenance. + React.useEffect(() => { + if (!initializationRef.current && (!values.operatorProperties || !values.operatorProperties[operatorId])) { + initializationRef.current = true; + const defaults: Record = {}; + properties.forEach((prop) => { + // Normalize API response to match type definition + const normalized = normalizeOperatorProperty(prop); + const defaultValue = normalized.defaultValue; + const dataType = normalized.dataType; + + if (defaultValue !== undefined && prop.name) { + if (dataType === 'boolean' || dataType === 'Boolean') { + defaults[prop.name] = defaultValue === 'true' || String(defaultValue).toLowerCase() === 'true'; + } else if (dataType === 'integer' || dataType === 'Integer') { + defaults[prop.name] = parseInt(String(defaultValue), 10); + } else { + defaults[prop.name] = defaultValue; + } + } + }); + if (Object.keys(defaults).length > 0) { + const currentProps = values.operatorProperties || {}; + setFieldValue('operatorProperties', { + ...currentProps, + [operatorId]: JSON.stringify(defaults), + }); + } + } + }, [operatorId, properties, setFieldValue, values.operatorProperties]); + + const updateProperty = (propertyName: string, value: unknown) => { + try { + // Sanitize the value to ensure it's JSON-serializable (primitive only) + const sanitizedValue = sanitizeValue(value); + + // Create a completely new object from scratch to avoid any reference issues + // Use shared filtering logic to ensure consistency with cleanProperties + // Note: currentProperties is already cleaned in the memo, but we re-filter here defensively + // to guard against race conditions or unexpected mutations + const newProperties = filterToSerializableProperties(currentProperties); + + // Set the new property value (which is already sanitized to a primitive) + newProperties[propertyName] = sanitizedValue; + + // Verify the new object can be stringified before proceeding + let propertiesJson: string; + try { + propertiesJson = JSON.stringify(newProperties); + } catch (error) { + console.error('Failed to stringify properties:', error, { propertyName, sanitizedValue, newProperties }); + // Fallback: create a minimal safe object with just this property + propertiesJson = JSON.stringify({ [propertyName]: sanitizedValue }); + } + + // Safely update the operatorProperties + const currentProps = values.operatorProperties || {}; + const updatedProps = { ...currentProps }; + updatedProps[operatorId] = propertiesJson; + + setFieldValue('operatorProperties', updatedProps); + } catch (error) { + console.error('Error updating property:', error, { propertyName, value }); + // Don't update if there's an error to prevent corrupting the state + } + }; + + if (!properties || properties.length === 0) { + return null; + } + + return ( + setIsExpanded(expanded)} + isExpanded={isExpanded} + data-testid={`operator-properties-${operatorId}`} + > + + {properties.map((property) => { + if (!property.name) return null; + + const fieldId = getFieldId(`${operatorId}-${property.name}`, 'input'); + const currentValue = currentProperties[property.name]; + const isRequired = property.mandatory || false; + + // Normalize API response to match type definition + const normalized = normalizeOperatorProperty(property); + const dataType = normalized.dataType; + const defaultValue = normalized.defaultValue; + + return ( + + + {property.description && ( + + {property.description} + + )} + {dataType === 'boolean' || dataType === 'Boolean' ? ( + updateProperty(property.name || '', checked)} + isDisabled={isDisabled} + data-testid={`operator-property-${operatorId}-${property.name}`} + /> + ) : dataType === 'integer' || dataType === 'Integer' ? ( + { + const numValue = computeNumberInputValue(currentValue, defaultValue); + // Note: Currently enforces minimum of 0, but no maximum limit. + // If the API adds optional min/max metadata to property definitions, + // this could be enhanced to use property.minValue and property.maxValue. + updateProperty(property.name || '', Math.max(0, numValue - 1)); + }} + onPlus={() => { + const numValue = computeNumberInputValue(currentValue, defaultValue); + // Note: No upper limit currently enforced. If the API adds optional min/max + // metadata to property definitions, this could be enhanced to use property.maxValue. + updateProperty(property.name || '', numValue + 1); + }} + onChange={(event) => { + const value = parseInt((event.target as HTMLInputElement).value, 10); + if (!isNaN(value)) { + // Note: Could validate against property.minValue/property.maxValue if API adds this metadata + updateProperty(property.name || '', value); + } + }} + isDisabled={isDisabled} + data-testid={`operator-property-${operatorId}-${property.name}`} + /> + ) : ( + updateProperty(property.name || '', value)} + isDisabled={isDisabled} + data-testid={`operator-property-${operatorId}-${property.name}`} + /> + )} + + + ); + })} + + + ); +}; + +export default OperatorPropertiesForm; + diff --git a/libs/ui-lib/lib/ocm/components/clusterWizard/Operators.tsx b/libs/ui-lib/lib/ocm/components/clusterWizard/Operators.tsx index 82d0493cef..771fe08fd5 100644 --- a/libs/ui-lib/lib/ocm/components/clusterWizard/Operators.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterWizard/Operators.tsx @@ -29,9 +29,19 @@ const getOperatorsInitialValues = ( uiSettings: UISettingsValues | undefined, cluster: Cluster, ): OperatorsValues => { + const olmOperators = selectOlmOperators(cluster); + const operatorProperties: Record = {}; + + olmOperators.forEach((op) => { + if (op.name && op.properties) { + operatorProperties[op.name] = op.properties; + } + }); + return { selectedBundles: uiSettings?.bundlesSelected || [], - selectedOperators: selectOlmOperators(cluster).map((o) => o.name || ''), + selectedOperators: olmOperators.map((o) => o.name || ''), + operatorProperties: operatorProperties || {}, }; }; @@ -85,9 +95,13 @@ const Operators = ({ cluster }: { cluster: Cluster }) => { const handleSubmit: FormikConfig['onSubmit'] = async (values) => { clearAlerts(); - const enabledOperators = values.selectedOperators.map((so) => ({ - name: so, - })); + const enabledOperators = values.selectedOperators.map((so) => { + const operator: { name: string; properties?: string } = { name: so }; + if (values.operatorProperties[so]) { + operator.properties = values.operatorProperties[so]; + } + return operator; + }); try { const { data: updatedCluster } = await ClustersService.update(cluster.id, cluster.tags, { @@ -107,8 +121,13 @@ const Operators = ({ cluster }: { cluster: Cluster }) => { } }; + const initialValues = React.useMemo( + () => getOperatorsInitialValues(uiSettings, cluster), + [uiSettings, cluster], + ); + return ( - + ); diff --git a/libs/ui-lib/lib/ocm/components/clusterWizard/OperatorsSelect.test.ts b/libs/ui-lib/lib/ocm/components/clusterWizard/OperatorsSelect.test.ts index cb7f935b0f..2b3ffa8af0 100644 --- a/libs/ui-lib/lib/ocm/components/clusterWizard/OperatorsSelect.test.ts +++ b/libs/ui-lib/lib/ocm/components/clusterWizard/OperatorsSelect.test.ts @@ -50,6 +50,7 @@ describe('OperatorsSelect counting logic', () => { const values: OperatorsValues = { selectedOperators: ['lvm', 'odf'], selectedBundles: [], + operatorProperties: {}, }; const result = calculateSelectedOperators(values, mockBundles, mockOpSpecs); @@ -62,6 +63,7 @@ describe('OperatorsSelect counting logic', () => { it('should count bundle operators when a bundle is selected', () => { const values: OperatorsValues = { selectedOperators: ['lvm'], // 1 manually selected + operatorProperties: {}, selectedBundles: ['virtualization'], // bundle with 3 operators: cnv, nmstate, mtv }; @@ -78,6 +80,7 @@ describe('OperatorsSelect counting logic', () => { const values: OperatorsValues = { selectedOperators: ['lvm', 'cnv'], // cnv is also in the bundle selectedBundles: ['virtualization'], // bundle with: cnv, nmstate, mtv + operatorProperties: {}, }; const result = calculateSelectedOperators(values, mockBundles, mockOpSpecs); @@ -95,6 +98,7 @@ describe('OperatorsSelect counting logic', () => { const values: OperatorsValues = { selectedOperators: ['lvm', 'invalid-operator'], // invalid-operator not in opSpecs selectedBundles: [], + operatorProperties: {}, }; const result = calculateSelectedOperators(values, mockBundles, mockOpSpecs); @@ -108,6 +112,7 @@ describe('OperatorsSelect counting logic', () => { const values: OperatorsValues = { selectedOperators: ['lvm'], selectedBundles: ['virtualization', 'openshift-ai'], // Two bundles + operatorProperties: {}, }; const result = calculateSelectedOperators(values, mockBundles, mockOpSpecs); @@ -126,6 +131,7 @@ describe('OperatorsSelect counting logic', () => { const values: OperatorsValues = { selectedOperators: [], selectedBundles: ['virtualization', 'openshift-ai'], // Both bundles have 'odf' (but only virtualization has it in our mock) + operatorProperties: {}, }; // Let's modify the virtualization bundle to include odf to test overlap @@ -154,6 +160,7 @@ describe('OperatorsSelect counting logic', () => { const values: OperatorsValues = { selectedOperators: [], selectedBundles: [], + operatorProperties: {}, }; const result = calculateSelectedOperators(values, mockBundles, mockOpSpecs); diff --git a/libs/ui-lib/lib/ocm/services/OperatorsService.tsx b/libs/ui-lib/lib/ocm/services/OperatorsService.tsx index e3b0c758fb..e1e3b0a445 100644 --- a/libs/ui-lib/lib/ocm/services/OperatorsService.tsx +++ b/libs/ui-lib/lib/ocm/services/OperatorsService.tsx @@ -1,9 +1,15 @@ import OperatorsAPI from '../../common/api/assisted-service/OperatorsAPI'; +import { OperatorProperties } from '@openshift-assisted/types/assisted-installer-service'; const OperatorsService = { getSupportedOperators: async (): Promise => { const { data: operators } = await OperatorsAPI.list(); return operators; }, + + getOperatorProperties: async (operatorName: string): Promise => { + const { data: properties } = await OperatorsAPI.getProperties(operatorName); + return properties; + }, }; export default OperatorsService;