diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index c3371ae2..c7a823d4 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -374,6 +374,24 @@ "Set the maximum memory usage for your container using Podman format. You can specify a number with an optional unit: \"b\" (bytes), \"k\" (kibibytes), \"m\" (mebibytes), \"g\" (gibibytes). Examples: \"512\", \"512m\", \"1g\", \"2048k\". Ensure the limit fits within your device's available memory and accounts for other applications and system processes.": "Set the maximum memory usage for your container using Podman format. You can specify a number with an optional unit: \"b\" (bytes), \"k\" (kibibytes), \"m\" (mebibytes), \"g\" (gibibytes). Examples: \"512\", \"512m\", \"1g\", \"2048k\". Ensure the limit fits within your device's available memory and accounts for other applications and system processes.", "Enter numeric value with optional unit": "Enter numeric value with optional unit", "Provide a valid memory value (e.g., \"512\", \"512m\", \"2g\", \"1024k\").": "Provide a valid memory value (e.g., \"512\", \"512m\", \"2g\", \"1024k\").", + "OCI reference URL": "OCI reference URL", + "Reference to the OCI image or artifact containing the Helm chart.": "Reference to the OCI image or artifact containing the Helm chart.", + "Namespace": "Namespace", + "The namespace to install the Helm chart into.": "The namespace to install the Helm chart into.", + "Type namespace here": "Type namespace here", + "If you do not specify a namespace, the agent uses a namespace based on the application name.": "If you do not specify a namespace, the agent uses a namespace based on the application name.", + "Values file names": "Values file names", + "Reference values files that are defined within the Helm application package. Files are applied in the specified order, before user-provided values.": "Reference values files that are defined within the Helm application package. Files are applied in the specified order, before user-provided values.", + "Values file {{ number }}": "Values file {{ number }}", + "Enter a file path relative to the Helm chart root. For example, values.yaml": "Enter a file path relative to the Helm chart root. For example, values.yaml", + "Type values file name here": "Type values file name here", + "Delete values file": "Delete values file", + "Fill in the existing values files before you can add more values files.": "Fill in the existing values files before you can add more values files.", + "Add values file": "Add values file", + "Order of precedence: Files are applied in the ordered listed. If the same parameter is defined in multiple files, the value in the last file takes precedence and overrides previous values.": "Order of precedence: Files are applied in the ordered listed. If the same parameter is defined in multiple files, the value in the last file takes precedence and overrides previous values.", + "Inline values": "Inline values", + "Provide a valid YAML file": "Provide a valid YAML file", + "Enter configuration values in YAML format to be applied to the Helm chart. These values take precedence over those defined in the files listed above.": "Enter configuration values in YAML format to be applied to the Helm chart. These values take precedence over those defined in the files listed above.", "The application image. Learn how to create one": "The application image. Learn how to create one", "here": "here", "File {{ fileNum }}": "File {{ fileNum }}", @@ -382,9 +400,6 @@ "Content is base64 encoded": "Content is base64 encoded", "Delete file": "Delete file", "Add file": "Add file", - "Single Container application": "Single Container application", - "Quadlet application": "Quadlet application", - "Compose application": "Compose application", "Application {{ appNum }}": "Application {{ appNum }}", "Application type": "Application type", "Select an application type": "Select an application type", @@ -394,7 +409,6 @@ "Pull definitions from container registry (reusable, versioned).": "Pull definitions from container registry (reusable, versioned).", "Inline": "Inline", "Define application files directly in this interface (custom, one-off).": "Define application files directly in this interface (custom, one-off).", - "OCI reference URL": "OCI reference URL", "The unique identifier for this application.": "The unique identifier for this application.", "Variable {{ number }}": "Variable {{ number }}", "Delete variable": "Delete variable", @@ -484,10 +498,6 @@ "The device will download and apply updates as soon as they are available.": "The device will download and apply updates as soon as they are available.", "Device alias": "Device alias", "Device labels": "Device labels", - "Single Container": "Single Container", - "Quadlet": "Quadlet", - "Compose": "Compose", - "Unknown": "Unknown", "Unnamed": "Unnamed", "Device fleet": "Device fleet", "Edge Manager will not manage system image": "Edge Manager will not manage system image", @@ -699,6 +709,7 @@ "Add item": "Add item", "Resolved": "Resolved", "Name must be unique": "Name must be unique", + "Unknown": "Unknown", "Accessible": "Accessible", "Not accessible": "Not accessible", "Missing repository": "Missing repository", @@ -755,6 +766,7 @@ "Container port is required": "Container port is required", "CPU limit is invalid.": "CPU limit is invalid.", "Memory limit is invalid.": "Memory limit is invalid.", + "YAML content is invalid.": "YAML content is invalid.", "Name is required for quadlet applications.": "Name is required for quadlet applications.", "Name is required for compose applications.": "Name is required for compose applications.", "Application name must be unique.": "Application name must be unique.", @@ -1161,6 +1173,14 @@ "Product UUID": "Product UUID", "TPM vendor info": "TPM vendor info", "Websocket error occured": "Websocket error occured", + "Single Container application": "Single Container application", + "Quadlet application": "Quadlet application", + "Helm application": "Helm application", + "Compose application": "Compose application", + "Single Container": "Single Container", + "Quadlet": "Quadlet", + "Compose": "Compose", + "Helm": "Helm", "OpenShift": "OpenShift", "Kubernetes": "Kubernetes", "Ansible Automation Platform": "Ansible Automation Platform", diff --git a/libs/types/models/AppType.ts b/libs/types/models/AppType.ts index 94a10a25..a9b7c20a 100644 --- a/libs/types/models/AppType.ts +++ b/libs/types/models/AppType.ts @@ -9,4 +9,5 @@ export enum AppType { AppTypeCompose = 'compose', AppTypeQuadlet = 'quadlet', AppTypeContainer = 'container', + AppTypeHelm = 'helm', } diff --git a/libs/types/models/ImageApplicationProviderSpec.ts b/libs/types/models/ImageApplicationProviderSpec.ts index f19b4dac..94cfefa6 100644 --- a/libs/types/models/ImageApplicationProviderSpec.ts +++ b/libs/types/models/ImageApplicationProviderSpec.ts @@ -10,6 +10,18 @@ export type ImageApplicationProviderSpec = (ApplicationVolumeProviderSpec & { * Reference to the OCI image or artifact for the application package. */ image: string; + /** + * Kubernetes namespace for helm chart installation. Only applicable when appType is 'helm'. + */ + namespace?: string; + /** + * Helm values to pass during install/upgrade. Supports arbitrarily nested YAML structures. Only applicable when appType is 'helm'. + */ + values?: Record; + /** + * List of values files from within the chart to use during install/upgrade. Files are relative to chart root and are applied in array order before user-provided values. Only applicable when appType is 'helm'. + */ + valuesFiles?: Array; /** * Port mappings. */ diff --git a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx index 1076584e..4325cc4a 100644 --- a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx +++ b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx @@ -5,6 +5,7 @@ import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { DeviceApplicationStatus } from '@flightctl/types'; import { useTranslation } from '../../../hooks/useTranslation'; import ApplicationStatus from '../../Status/ApplicationStatus'; +import { getAppTypeLabel } from '../../../utils/apps'; type ApplicationsTableProps = { appsStatus: DeviceApplicationStatus[]; @@ -39,7 +40,9 @@ const ApplicationsTable = ({ appsStatus }: ApplicationsTableProps) => { {app.ready} {app.restarts} - {app.appType ? : '-'} + + {app.appType ? : '-'} + {app.embedded ? t('Yes') : t('No')} ); diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts index 5774065c..bed3bdec 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts @@ -1,3 +1,4 @@ +import yaml from 'js-yaml'; import { AppType, // eslint-disable-next-line no-restricted-imports @@ -38,6 +39,7 @@ import { isComposeImageAppForm, isGitConfigTemplate, isGitProviderSpec, + isHelmImageAppForm, isHttpConfigTemplate, isHttpProviderSpec, isImageAppProvider, @@ -215,6 +217,32 @@ export const getDeviceSpecConfigPatches = ( }; export const toAPIApplication = (app: AppForm): ApplicationProviderSpec => { + if (isHelmImageAppForm(app)) { + const data: ImageApplicationProviderSpec & ApplicationProviderSpec = { + name: app.name, + image: app.image, + appType: app.appType, + }; + if (app.namespace) { + data.namespace = app.namespace; + } + if (app.valuesYaml) { + try { + const values = yaml.load(app.valuesYaml) as Record; + if (values && Object.keys(values).length > 0) { + data.values = values; + } + } catch (error) { + throw new Error('Values content is not valid YAML.'); + } + } + const fileNames = app.valuesFiles.filter((file) => file && file.trim() !== ''); + if (fileNames.length > 0) { + data.valuesFiles = fileNames; + } + return data; + } + const envVars = app.variables.reduce((acc, variable) => { acc[variable.name] = variable.value; return acc; @@ -265,7 +293,7 @@ export const toAPIApplication = (app: AppForm): ApplicationProviderSpec => { limits: appLimits, }; } - return data as ApplicationProviderSpec; + return data; } if (isQuadletImageAppForm(app) || isComposeImageAppForm(app)) { @@ -433,15 +461,38 @@ const hasApplicationChanged = (currentApp: ApplicationProviderSpec, updatedApp: return true; } - if (!areEnvVariablesEqual(currentApp.envVars, updatedApp.variables)) { - return true; - } - // The app is a single container application if (isSingleContainerAppForm(updatedApp)) { return hasSingleContainerAppChanged(currentApp, updatedApp); } + // The app is a Helm application + if (isHelmImageAppForm(updatedApp)) { + const imageApp = currentApp as ImageApplicationProviderSpec; + if (imageApp.image !== updatedApp.image || imageApp.namespace !== updatedApp.namespace) { + return true; + } + + // Compare valuesFiles arrays + const currentValuesFiles = (imageApp.valuesFiles || []).filter((file) => file !== ''); + const updatedValuesFiles = updatedApp.valuesFiles.filter((file) => file !== ''); + if (currentValuesFiles.length !== updatedValuesFiles.length) { + return true; + } + if (!currentValuesFiles.every((file, index) => file === updatedValuesFiles[index])) { + return true; + } + const updatedValues = yaml.load(updatedApp.valuesYaml || ' ') as Record; + if (JSON.stringify(imageApp.values || {}) !== JSON.stringify(updatedValues)) { + return true; + } + return false; + } + + if (!areEnvVariablesEqual(currentApp.envVars, updatedApp.variables)) { + return true; + } + // The app is an image application (Quadlet/Compose image apps) if (isCurrentImageApp) { const imageApp = currentApp as ImageApplicationProviderSpec; @@ -595,8 +646,8 @@ export const getApplicationValues = (deviceSpec?: DeviceSpec): AppForm[] => { }) || []; return { - specType: AppSpecType.OCI_IMAGE, appType: AppType.AppTypeContainer, + specType: AppSpecType.OCI_IMAGE, name: app.name || '', image: app.image, variables: getAppFormVariables(app), @@ -611,23 +662,36 @@ export const getApplicationValues = (deviceSpec?: DeviceSpec): AppForm[] => { }; } + // Helm application + if (app.appType === AppType.AppTypeHelm && isImageAppProvider(app)) { + return { + appType: AppType.AppTypeHelm, + specType: AppSpecType.OCI_IMAGE, + name: app.name || '', + image: app.image, + namespace: app.namespace, + valuesYaml: app.values && Object.keys(app.values).length > 0 ? yaml.dump(app.values) : undefined, + valuesFiles: app.valuesFiles || [''], + }; + } + // Compose or Quadlet image application if (isImageAppProvider(app)) { return { + appType: app.appType, specType: AppSpecType.OCI_IMAGE, name: app.name || '', image: app.image, - appType: app.appType, variables: getAppFormVariables(app), volumes: convertVolumesToForm(app.volumes), - }; + } as QuadletImageAppForm | ComposeImageAppForm; } // Compose or Quadlet inline application const inlineApp = app as InlineApplicationProviderSpec; return { - specType: AppSpecType.INLINE, appType: app.appType, + specType: AppSpecType.INLINE, name: app.name || '', files: inlineApp.inline, variables: getAppFormVariables(app), diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.css b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.css index b9046ada..8193dbc9 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.css +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.css @@ -3,3 +3,15 @@ border: 1px solid gray !important; margin-top: 1rem; } + +/* +The tooltip text has the same color as the background, making the text unreadable in light and dark modes. + Due to Patternfly targets the tooltip content ([class*='pf-v6-c-content']), just using the CSS variables does not work. + */ +.fctl-application-helm-form__tooltip [class*='pf-v6-c-content'] { + color: var(--pf-t--global--text--color--300, white); +} + +.pf-v6-theme-dark .fctl-application-helm-form__tooltip [class*='pf-v6-c-content'] { + color: var(--pf-t--global--text--color--100, gray); +} diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationHelmForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationHelmForm.tsx new file mode 100644 index 00000000..6c01dfd0 --- /dev/null +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationHelmForm.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import { FieldArray, useField } from 'formik'; +import { Alert, Button, Content, FormGroup, Grid, Radio, Split, SplitItem, Tooltip } from '@patternfly/react-core'; +import { MinusCircleIcon } from '@patternfly/react-icons/dist/js/icons/minus-circle-icon'; +import { PlusCircleIcon } from '@patternfly/react-icons/dist/js/icons/plus-circle-icon'; + +import { FormGroupWithHelperText } from '../../../common/WithHelperText'; +import TextField from '../../../form/TextField'; +import UploadField from '../../../form/UploadField'; +import { useTranslation } from '../../../../hooks/useTranslation'; +import { AppSpecType, HelmImageAppForm } from '../../../../types/deviceSpec'; + +const ApplicationHelmForm = ({ + app, + index, + isReadOnly, +}: { + app: HelmImageAppForm; + index: number; + isReadOnly?: boolean; +}) => { + const { t } = useTranslation(); + const appFieldName = `applications[${index}]`; + const [{ value: valuesFiles }] = useField>(`${appFieldName}.valuesFiles`); + const canAddValuesFile = valuesFiles && valuesFiles.every((file) => file && file.trim() !== ''); + + return ( + + + + + + {/* Field not configurable - just to display helm apps like the other app types that have OCI image references */} + + + + + + + + + + + + + + {({ push, remove }) => ( + <> + {(valuesFiles || []).map((file, fileIndex) => ( + + + + + {!isReadOnly && ( + + + + + )} + + + )} + + + + + + {t( + 'Enter configuration values in YAML format to be applied to the Helm chart. These values take precedence over those defined in the files listed above.', + )} + + + + + ); +}; + +export default ApplicationHelmForm; diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx index a279c7f1..a6a573ee 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx @@ -32,12 +32,15 @@ const InlineApplicationFileForm = ({ file, fileIndex, fileFieldName, isReadOnly - + + + + ({ - [AppType.AppTypeContainer]: t('Single Container application'), - [AppType.AppTypeQuadlet]: t('Quadlet application'), - [AppType.AppTypeCompose]: t('Compose application'), -}); - const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: boolean }) => { const { t } = useTranslation(); const appFieldName = `applications[${index}]`; @@ -54,11 +50,13 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: const { appType, specType, name: appName } = app; const isContainer = isSingleContainerAppForm(app); + const isHelm = isHelmImageAppForm(app); const isImageIncomplete = !isContainer && specType === AppSpecType.OCI_IMAGE && !('image' in app); const isInlineIncomplete = !isContainer && specType === AppSpecType.INLINE && !('files' in app); const isContainerIncomplete = isContainer && (!('ports' in app) || !('volumes' in app)); + const isHelmIncomplete = isHelm && !('valuesFiles' in app); - const shouldResetApp = isInlineIncomplete || isImageIncomplete || isContainerIncomplete; + const shouldResetApp = isInlineIncomplete || isImageIncomplete || isContainerIncomplete || isHelmIncomplete; // @ts-expect-error Formik error object includes "variables" const appVarsError = typeof error?.variables === 'string' ? (error.variables as string) : undefined; // eslint-disable @typescript-eslint/no-unsafe-assignment @@ -70,7 +68,7 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: return; } // When switching appType to Container, initialize Container-specific fields - if (isContainer) { + if (appType === AppType.AppTypeContainer) { setValue( { appType: AppType.AppTypeContainer, @@ -86,6 +84,22 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: return; } + if (appType === AppType.AppTypeHelm) { + setValue( + { + appType: AppType.AppTypeHelm, + specType: AppSpecType.OCI_IMAGE, + name: appName || '', + image: '', + namespace: undefined, + valuesYaml: '', + valuesFiles: [''], // We want to show a "values files" field by default + } as AppForm, + false, + ); + return; + } + // When switching specType, the app becomes "incomplete" and we must add the required fields for the new type if (specType === AppSpecType.INLINE) { // Switching to inline - need files @@ -114,7 +128,7 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: false, ); } - }, [shouldResetApp, isContainer, specType, appType, appName, setValue]); + }, [shouldResetApp, specType, appType, appName, setValue]); return ( + ) : isHelm ? ( + ) : ( <> )} - - - {({ push, remove }) => ( - <> - {app.variables.map((variable, varIndex) => ( - - - - - - - - - - - - - - + {!isHelm && ( + <> + + + {({ push, remove }) => ( + <> + {app.variables?.map((variable, varIndex) => ( + + + + + + + + + + + + + + + {!isReadOnly && ( + + + )} - - ))} - - {!isReadOnly && ( - - - + )} - - )} - + + + )} ); diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ConfigInlineTemplateForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ConfigInlineTemplateForm.tsx index dc6e792b..64f3c344 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ConfigInlineTemplateForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ConfigInlineTemplateForm.tsx @@ -48,12 +48,14 @@ const FileForm = ({ fieldName, index, isReadOnly }: { fieldName: string; index: - + + + diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewApplications.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewApplications.tsx index 552fa37e..cb6afb8c 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewApplications.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewApplications.tsx @@ -2,18 +2,9 @@ import React from 'react'; import { Stack, StackItem } from '@patternfly/react-core'; import { TFunction } from 'react-i18next'; -import { AppType } from '@flightctl/types'; import { useTranslation } from '../../../../hooks/useTranslation'; import { AppForm, getAppIdentifier } from '../../../../types/deviceSpec'; - -const getAppFormatLabel = (appType: AppType, t: TFunction): string => { - const labels: Record = { - [AppType.AppTypeContainer]: t('Single Container'), - [AppType.AppTypeQuadlet]: t('Quadlet'), - [AppType.AppTypeCompose]: t('Compose'), - }; - return labels[appType] || t('Unknown'); -}; +import { getAppTypeLabel } from '../../../../utils/apps'; const getAppName = (app: AppForm, t: TFunction): string => { if (app.name) { @@ -36,10 +27,10 @@ const ReviewApplications = ({ apps }: { apps: AppForm[] }) => { {apps.map((app, index) => { const name = getAppName(app, t); - const formatType = getAppFormatLabel(app.appType, t); + const appType = getAppTypeLabel(app.appType, t); return ( - {name} ({formatType}) + {name} ({appType}) ); })} diff --git a/libs/ui-components/src/components/form/UploadField.tsx b/libs/ui-components/src/components/form/UploadField.tsx index ff159454..4a4adf9c 100644 --- a/libs/ui-components/src/components/form/UploadField.tsx +++ b/libs/ui-components/src/components/form/UploadField.tsx @@ -7,7 +7,7 @@ import ErrorHelperText, { DefaultHelperText } from './FieldHelperText'; type UploadFieldProps = { name: string; isRequired?: boolean; - label: string; + ariaLabel: string; isDisabled?: boolean; getErrorText?: (error: string) => React.ReactNode | undefined; placeholder?: string; @@ -55,7 +55,7 @@ const UploadHelperText = ({ return defaultContent; }; -const UploadField = ({ label, maxFileBytes, isRequired, name }: UploadFieldProps) => { +const UploadField = ({ ariaLabel, maxFileBytes, isRequired, name }: UploadFieldProps) => { const { t } = useTranslation(); const fieldId = `fileuploadfield-${name}`; @@ -89,7 +89,7 @@ const UploadField = ({ label, maxFileBytes, isRequired, name }: UploadFieldProps }; return ( - + { +const UploadFieldWrapper = ({ name, ariaLabel, maxFileBytes, isRequired, ...rest }: UploadFieldProps) => { const [{ value }] = useField(name); if (rest.isDisabled) { - return ( - -