From 7698e1d44ff4958631dc854bac42a18176a016b6 Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Tue, 4 Aug 2026 13:42:54 +0200 Subject: [PATCH 1/5] EDM-4809: Catalog now has improved Backend support Made-with: Cursor --- libs/i18n/locales/en/translation.json | 90 ++-- libs/types/alpha/index.ts | 2 + .../alpha/models/CatalogItemDeployment.ts | 49 ++ .../alpha/models/CatalogItemDeploymentList.ts | 23 + libs/types/index.ts | 4 + .../CatalogItemRefApplicationProviderSpec.ts | 9 + libs/types/models/CatalogItemRefSpec.ts | 26 ++ libs/types/models/ComposeApplication.ts | 3 +- libs/types/models/ContainerApplication.ts | 8 +- libs/types/models/DeviceOsSpec.ts | 12 +- libs/types/models/Event.ts | 2 + libs/types/models/HelmApplication.ts | 8 +- .../models/ImageApplicationProviderSpec.ts | 9 +- .../types/models/ImageOrCatalogItemRefSpec.ts | 17 + libs/types/models/ImageSpec.ts | 14 + libs/types/models/ImageVolumeSource.ts | 6 +- libs/types/models/QuadletApplication.ts | 3 +- .../AddCatalogItemWizard/steps/ReviewStep.tsx | 13 +- .../steps/TypeConfigStep.tsx | 16 +- .../steps/VersionStep.tsx | 11 +- .../Catalog/AddCatalogItemWizard/utils.ts | 2 +- .../components/Catalog/CatalogItemCard.tsx | 2 +- .../components/Catalog/CatalogItemDetails.tsx | 418 ++++++++++-------- .../components/Catalog/CatalogItemTitle.tsx | 127 ++++++ .../Catalog/CatalogItemsContext.tsx | 28 ++ .../src/components/Catalog/CatalogPage.tsx | 3 +- .../components/Catalog/DeleteCatalogModal.tsx | 176 ++++++-- .../Catalog/EditWizard/EditAppWizard.tsx | 20 +- .../Catalog/EditWizard/EditOsWizard.tsx | 11 +- .../Catalog/EditWizard/EditWizard.tsx | 108 +++-- .../Catalog/EditWizard/steps/UpdateStep.tsx | 15 +- .../InstallWizard/InstallAppWizard.tsx | 8 +- .../Catalog/InstallWizard/InstallOsWizard.tsx | 82 ++-- .../Catalog/InstallWizard/InstallWizard.tsx | 12 +- .../InstallWizard/UpdateSuccessPage.tsx | 19 +- .../InstallWizard/steps/AppConfigStep.tsx | 80 ++-- .../InstallWizard/steps/ReviewStep.tsx | 141 +++--- .../InstallWizard/steps/SelectTargetStep.tsx | 71 ++- .../steps/SpecificationsStep.tsx | 9 +- .../components/Catalog/InstallWizard/types.ts | 11 +- .../components/Catalog/InstallWizard/utils.ts | 58 +-- .../components/Catalog/InstalledSoftware.tsx | 326 ++------------ .../Catalog/InstalledSoftwareItem.tsx | 147 ++++++ .../ResourceCatalog/ResourceCatalogPage.tsx | 14 +- .../src/components/Catalog/const.ts | 16 - .../{useCatalogs.ts => useCatalogItems.ts} | 41 +- .../Catalog/useCatalogItemsLookup.ts | 148 +++++++ .../Catalog/useResolvedCatalogRef.ts | 43 ++ .../components/Catalog/useSpecCatalogItems.ts | 97 ++++ .../src/components/Catalog/utils.ts | 397 ----------------- .../DeviceDetails/DeviceDetailsCatalog.tsx | 2 - .../DeviceDetails/DeviceDetailsPage.tsx | 62 +-- .../ConfigurationsContent.tsx | 15 +- .../Device/DeviceDetails/DeviceOs.tsx | 51 ++- .../EditDeviceWizard/EditDeviceWizard.tsx | 156 +++---- .../SystemImageDescriptionGroup.tsx | 81 ++++ .../EditDeviceWizard/deviceSpecUtils.ts | 283 ++++++++---- .../steps/ApplicationContainerForm.css | 11 - .../steps/ApplicationContainerForm.tsx | 13 +- .../steps/ApplicationHelmForm.tsx | 22 +- .../steps/ApplicationImageForm.tsx | 54 ++- .../steps/ApplicationTemplates.tsx | 44 +- .../steps/ApplicationVariablesForm.tsx | 3 +- .../steps/ApplicationVolumeForm.tsx | 16 +- .../steps/DeviceTemplateStep.tsx | 22 +- .../steps/ReviewApplications.tsx | 4 +- .../steps/ReviewDeviceStep.tsx | 8 +- .../Device/EditDeviceWizard/useSystemImage.ts | 24 + .../Device/EditDeviceWizard/utils.ts | 36 +- .../components/DynamicForm/DynamicForm.tsx | 21 +- .../components/DynamicForm/FieldTemplate.tsx | 20 +- .../DynamicForm/VolumeImageField.tsx | 107 ++--- .../src/components/Events/useEvents.ts | 3 + .../Fleet/CreateFleet/CreateFleetWizard.tsx | 148 ++++--- .../Fleet/CreateFleet/steps/ReviewStep.tsx | 8 +- .../src/components/Fleet/CreateFleet/utils.ts | 40 +- .../FleetDetails/FleetDetailsCatalog.tsx | 6 +- .../FleetDetails/FleetDetailsContent.tsx | 14 +- .../Fleet/FleetDetails/FleetDetailsPage.tsx | 16 +- .../src/components/Fleet/FleetRow.tsx | 11 +- .../NewVersionImageBuildWizard.tsx | 24 +- .../NewVersionImageBuildWizard/utils.ts | 31 +- .../ImagePromotion/ImagePromotionForm.tsx | 16 +- .../ImagePromotion/ImagePromotionModal.tsx | 8 +- .../src/components/common/LabelsView.tsx | 3 +- .../form/ApplicationPortMappingField.tsx | 244 +++++----- .../form/ImageOrCatalogRefField.tsx | 63 +++ .../src/components/form/LabelsField.tsx | 2 - .../src/components/form/validations.ts | 47 +- libs/ui-components/src/hooks/useAppLinks.ts | 18 +- libs/ui-components/src/types/deviceSpec.ts | 39 +- libs/ui-components/src/utils/catalog.ts | 413 +++++++++++++++++ libs/ui-components/src/utils/volumes.ts | 64 +++ 93 files changed, 3165 insertions(+), 2063 deletions(-) create mode 100644 libs/types/alpha/models/CatalogItemDeployment.ts create mode 100644 libs/types/alpha/models/CatalogItemDeploymentList.ts create mode 100644 libs/types/models/CatalogItemRefApplicationProviderSpec.ts create mode 100644 libs/types/models/CatalogItemRefSpec.ts create mode 100644 libs/types/models/ImageOrCatalogItemRefSpec.ts create mode 100644 libs/types/models/ImageSpec.ts create mode 100644 libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx create mode 100644 libs/ui-components/src/components/Catalog/CatalogItemsContext.tsx create mode 100644 libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx delete mode 100644 libs/ui-components/src/components/Catalog/const.ts rename libs/ui-components/src/components/Catalog/{useCatalogs.ts => useCatalogItems.ts} (75%) create mode 100644 libs/ui-components/src/components/Catalog/useCatalogItemsLookup.ts create mode 100644 libs/ui-components/src/components/Catalog/useResolvedCatalogRef.ts create mode 100644 libs/ui-components/src/components/Catalog/useSpecCatalogItems.ts delete mode 100644 libs/ui-components/src/components/Catalog/utils.ts create mode 100644 libs/ui-components/src/components/Device/EditDeviceWizard/SystemImageDescriptionGroup.tsx delete mode 100644 libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.css create mode 100644 libs/ui-components/src/components/Device/EditDeviceWizard/useSystemImage.ts create mode 100644 libs/ui-components/src/components/form/ImageOrCatalogRefField.tsx create mode 100644 libs/ui-components/src/utils/catalog.ts create mode 100644 libs/ui-components/src/utils/volumes.ts diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 453d9d7d54..4ddfdbef60 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -305,20 +305,25 @@ "At least one artifact is required": "At least one artifact is required", "Select {{ name }}": "Select {{ name }}", "Provided by {{provider}}": "Provided by {{provider}}", + "catalog item": "catalog item", + "N/A": "N/A", "This resource is managed by an owner and cannot be modified directly": "This resource is managed by an owner and cannot be modified directly", "You do not have permission to deploy": "You do not have permission to deploy", "A channel must be selected": "A channel must be selected", "A version must be selected": "A version must be selected", + "This catalog item does not have a deployable artifact": "This catalog item does not have a deployable artifact", + "Deploy": "Deploy", + "This catalog item is managed by a resource sync and cannot be modified directly. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.": "This catalog item is managed by a resource sync and cannot be modified directly. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.", "Restore": "Restore", - "This catalog item is managed by a resource sync and cannot be directly restored. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.": "This catalog item is managed by a resource sync and cannot be directly restored. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.", "Deprecate": "Deprecate", - "This catalog item is managed by a resource sync and cannot be directly deprecated. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.": "This catalog item is managed by a resource sync and cannot be directly deprecated. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.", - "This catalog item is managed by a resource sync and cannot be directly deleted. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.": "This catalog item is managed by a resource sync and cannot be directly deleted. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.", + "This catalog item is being used in at least one fleet or device.": "This catalog item is being used in at least one fleet or device.", "Data catalog item can be deployed as part of an application.": "Data catalog item can be deployed as part of an application.", - "Deploy": "Deploy", - "catalog item": "catalog item", - "N/A": "N/A", "Description": "Description", + "Version: {{ version }}, Channel: {{ channel }}": "Version: {{ version }}, Channel: {{ channel }}", + "Version: {{ version }}": "Version: {{ version }}", + "Invalid reference to a catalog item": "Invalid reference to a catalog item", + "The catalog item referenced by this element could not be found. Review that the details are correct and the catalog item has not been deleted.": "The catalog item referenced by this element could not be found. Review that the details are correct and the catalog item has not been deleted.", + "Catalog item not found": "Catalog item not found", "The Software Catalog is currently empty": "The Software Catalog is currently empty", "There are no items available to view. You have <1>view-only permissions and cannot add or manage items. Please contact your internal administrator for access or catalog details.": "There are no items available to view. You have <1>view-only permissions and cannot add or manage items. Please contact your internal administrator for access or catalog details.", "Learn more about the Software Catalog in our documentation": "Learn more about the Software Catalog in our documentation", @@ -369,20 +374,23 @@ "Help cards": "Help cards", "Browse and manage catalog items available for deployment to your fleets and devices.": "Browse and manage catalog items available for deployment to your fleets and devices.", "Search by name": "Search by name", - "Deleting {{count}} catalog item_one": "Deleting {{count}} catalog item", - "Deleting {{count}} catalog item_other": "Deleting {{count}} catalog items", - "{{count}} catalog item could not be deleted. Try deleting it manually._one": "{{count}} catalog item could not be deleted. Try deleting it manually.", - "{{count}} catalog item could not be deleted. Try deleting it manually._other": "{{count}} catalog items could not be deleted. Try deleting them manually.", - "Delete catalog ?": "Delete catalog ?", - "This catalog contains catalog items. By deleting the catalog, its catalog items will also be deleted.": "This catalog contains catalog items. By deleting the catalog, its catalog items will also be deleted.", + "Reason": "Reason", + "Catalog item is used in at least one fleet or device.": "Catalog item is used in at least one fleet or device.", + "Reason is unknown. Retrying deletion may succeed.": "Reason is unknown. Retrying deletion may succeed.", + "Deleting {{count}} catalog items_one": "Deleting {{count}} catalog items", + "Deleting {{count}} catalog items_other": "Deleting {{count}} catalog items", "Cannot delete catalog": "Cannot delete catalog", "The catalog cannot be safely deleted at this moment, as we couldn't determine if the catalog contains items. Detail: {{detail}}": "The catalog cannot be safely deleted at this moment, as we couldn't determine if the catalog contains items. Detail: {{detail}}", "Are you sure you want to delete the catalog <1>{catalogDisplayName}?": "Are you sure you want to delete the catalog <1>{catalogDisplayName}?", + "Catalog deletion failure": "Catalog deletion failure", + "Delete catalog ?": "Delete catalog ?", + "This catalog contains catalog items. By deleting the catalog, its catalog items will also be deleted.": "This catalog contains catalog items. By deleting the catalog, its catalog items will also be deleted.", "Checking if the catalog has items": "Checking if the catalog has items", + "The catalog could not be deleted because it contains catalog items that failed to delete. Review the details below and retry the deletion if necessary.": "The catalog could not be deleted because it contains catalog items that failed to delete. Review the details below and retry the deletion if necessary.", "Reload catalog items": "Reload catalog items", + "Retry delete": "Retry delete", "Deprecate catalog item": "Deprecate catalog item", "Are you sure you want to deprecate <1>{itemName}?": "Are you sure you want to deprecate <1>{itemName}?", - "Reason": "Reason", "Explain why this item is being deprecated": "Explain why this item is being deprecated", "Restore catalog item": "Restore catalog item", "Are you sure you want to restore <1>{itemName} from its deprecated state?": "Are you sure you want to restore <1>{itemName} from its deprecated state?", @@ -397,7 +405,7 @@ "Failed to load fleet": "Failed to load fleet", "Loading": "Loading", "Device is in package mode. The catalog item cannot be deployed to this device.": "Device is in package mode. The catalog item cannot be deployed to this device.", - "Failed to load operating system for this catalog item": "Failed to load operating system for this catalog item", + "Failed to find operating system": "Failed to find operating system", "Failed to find application": "Failed to find application", "Software catalog": "Software catalog", "Deploy {{ name }}": "Deploy {{ name }}", @@ -428,15 +436,16 @@ "No version update will be performed": "No version update will be performed", "This version is deprecated": "This version is deprecated", "A resource sync is an automated Gitops method that helps manage your imported catalog by monitoring source repository changes and updating your catalog configuration accordingly.": "A resource sync is an automated Gitops method that helps manage your imported catalog by monitoring source repository changes and updating your catalog configuration accordingly.", - "Update available": "Update available", - "Version: {{version}}, Channel: {{channel}}": "Version: {{version}}, Channel: {{channel}}", "Loading installed software": "Loading installed software", + "Failed to load installed software": "Failed to load installed software", "Deployed Software": "Deployed Software", "No software deployed": "No software deployed", "Select an application from the catalog below.": "Select an application from the catalog below.", "Select an operating system or application from the catalog below.": "Select an operating system or application from the catalog below.", "operating system": "operating system", "application": "application", + "Update available": "Update available", + "The referenced catalog item is invalid. This catalog item cannot be edited.": "The referenced catalog item is invalid. This catalog item cannot be edited.", "Specifications": "Specifications", "Select target": "Select target", "Target must be selected": "Target must be selected", @@ -444,6 +453,8 @@ "Device must be selected": "Device must be selected", "Fleet must be selected": "Fleet must be selected", "Failed to find requested version {{version}}": "Failed to find requested version {{version}}", + "Deployment target not found for {{ target }}": "Deployment target not found for {{ target }}", + "Failed to find requested version {{ version }}": "Failed to find requested version {{ version }}", "Loading catalog item": "Loading catalog item", "Deploy {{name}}": "Deploy {{name}}", "Application name": "Application name", @@ -455,11 +466,14 @@ "Note: Some fields may not be represented in this form view. Please select \"YAML view\" for full control.": "Note: Some fields may not be represented in this form view. Please select \"YAML view\" for full control.", "Fleet update": "Fleet update", "This will deploy the OS <1>{osImageName} for all <3>({numOfDevices}) devices in the <5>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.": "This will deploy the OS <1>{osImageName} for all <3>({numOfDevices}) devices in the <5>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.", + "No action required": "No action required", + "The fleet already defines the selected OS <1>{osImageName}. No update will be performed.": "The fleet already defines the selected OS <1>{osImageName}. No update will be performed.", "You are about to update OS <1>{osImageName}. This will update the OS image for all <4>({numOfDevices}) devices in the <6>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.": "You are about to update OS <1>{osImageName}. This will update the OS image for all <4>({numOfDevices}) devices in the <6>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.", "Existing OS image detected": "Existing OS image detected", "You are about to replace OS with <1>{osImageName}. This will update the OS image for all <4>({numOfDevices}) devices in the <6>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.": "You are about to replace OS with <1>{osImageName}. This will update the OS image for all <4>({numOfDevices}) devices in the <6>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.", "Device update": "Device update", "This will deploy the OS <1>{osImageName}. Device will download and apply the update according to the configured update policies.": "This will deploy the OS <1>{osImageName}. Device will download and apply the update according to the configured update policies.", + "The device already defines the selected OS <1>{osImageName}. No update will be performed.": "The device already defines the selected OS <1>{osImageName}. No update will be performed.", "You are about to update OS with <1>{osImageName}. Device will download and apply the update according to the configured update policies.": "You are about to update OS with <1>{osImageName}. Device will download and apply the update according to the configured update policies.", "You are about to replace OS with <1>{osImageName}. Device will download and apply the update according to the configured update policies.": "You are about to replace OS with <1>{osImageName}. Device will download and apply the update according to the configured update policies.", "Target": "Target", @@ -489,6 +503,8 @@ "Deploy to a single fleetless device.": "Deploy to a single fleetless device.", "New Device": "New Device", "Provision a brand new, unenrolled device": "Provision a brand new, unenrolled device", + "No action performed": "No action performed", + "The selection from the catalog matches the current target spec, so no update was performed.": "The selection from the catalog matches the current target spec, so no update was performed.", "Update configuration successful": "Update configuration successful", "Device will download and apply the update according to the configured update policies.": "Device will download and apply the update according to the configured update policies.", "Devices will download and apply the update according to the configured update policies.": "Devices will download and apply the update according to the configured update policies.", @@ -496,18 +512,6 @@ "View device": "View device", "View fleet": "View fleet", "Not a valid configuration": "Not a valid configuration", - "OS image": "OS image", - "Unknown": "Unknown", - "QCOW2": "QCOW2", - "Bare Metal": "Bare Metal", - "Amazon Web Services": "Amazon Web Services", - "Anaconda Installer": "Anaconda Installer", - "Google Cloud": "Google Cloud", - "KVM/custom cloud import": "KVM/custom cloud import", - "Microsoft Hyper-V": "Microsoft Hyper-V", - "VMware vSphere": "VMware vSphere", - "Cloud native": "Cloud native", - "OpenShift Virtualization": "OpenShift Virtualization", "No devices": "No devices", "Restricted Access": "Restricted Access", "You don't have access to this section.": "You don't have access to this section.", @@ -724,7 +728,7 @@ "Custom": "Custom", "Running system image is unknown": "Running system image is unknown", "System image mismatch": "System image mismatch", - "Desired system image: {{ desiredOsImage }}": "Desired system image: {{ desiredOsImage }}", + "Desired system image": "Desired system image", "System services": "System services", "You do not have permission to access the device terminal.": "You do not have permission to access the device terminal.", "Console type": "Console type", @@ -781,7 +785,6 @@ "The device will be bound to a fleet. As a result, its configurations cannot be edited directly.": "The device will be bound to a fleet. As a result, its configurations cannot be edited directly.", "Why this step is disabled": "Why this step is disabled", "If not specified, the image name will be used. Application name must be unique.": "If not specified, the image name will be used. Application name must be unique.", - "Provide a valid image reference": "Provide a valid image reference", "Provide a list of ports to map to the container": "Provide a list of ports to map to the container", "Enter container port": "Enter container port", "Resources": "Resources", @@ -813,6 +816,7 @@ "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", + "Provide a valid image reference": "Provide a valid image reference", "File {{ fileNum }}": "File {{ fileNum }}", "File path on the device (relative)": "File path on the device (relative)", "Content": "Content", @@ -846,6 +850,7 @@ "Variable value": "Variable value", "Delete variable": "Delete variable", "Add variable": "Add variable", + "None defined": "None defined", "The unique identifier for this application. Must be lowercase alphanumeric with hyphens.": "The unique identifier for this application. Must be lowercase alphanumeric with hyphens.", "VM name": "VM name", "e.g. edge-analytics": "e.g. edge-analytics", @@ -965,7 +970,6 @@ "Device labels": "Device labels", "Unnamed": "Unnamed", "Device fleet": "Device fleet", - "Edge Manager will not manage system image": "Edge Manager will not manage system image", "{{ count }} batches have been defined_one": "{{ count }} batch has been defined", "{{ count }} batches have been defined_other": "{{ count }} batches have been defined", "Applies to all the fleet devices": "Applies to all the fleet devices", @@ -973,6 +977,8 @@ "Maximum unavailable devices: {{ maxUnavailable }}": "Maximum unavailable devices: {{ maxUnavailable }}", "Add service": "Add service", "Tracked systemd services": "Tracked systemd services", + "The fleet will not manage system image": "The fleet will not manage system image", + "The device will not manage system image": "The device will not manage system image", "Items": "Items", "Delete item": "Delete item", "Add item": "Add item", @@ -981,6 +987,7 @@ "No assets available in catalog": "No assets available in catalog", "There are no asset catalog items to choose from. Add assets to your catalogs to select them here.": "There are no asset catalog items to choose from. Add assets to your catalogs to select them here.", "Select": "Select", + "Catalog item {{ catalogItemRef }}": "Catalog item {{ catalogItemRef }}", "Enter image reference or choose from catalog": "Enter image reference or choose from catalog", "Choose from catalog": "Choose from catalog", "Approve": "Approve", @@ -1068,6 +1075,8 @@ "Resourcesync is available": "Resourcesync is available", "Resourcesync is not available": "Resourcesync is not available", "Resourcesync new commit detected": "Resourcesync new commit detected", + "Encryption migration started": "Encryption migration started", + "Encryption migration completed": "Encryption migration completed", "Dependency sync probe failed": "Dependency sync probe failed", "Dependency change detected": "Dependency change detected", "Application lifecycle changed": "Application lifecycle changed", @@ -1080,7 +1089,6 @@ "Labels used to select devices for your fleet. If not specified, no devices will be added.": "Labels used to select devices for your fleet. If not specified, no devices will be added.", "Fleet labels": "Fleet labels", "Device selector": "Device selector", - "The fleet will not manage system image": "The fleet will not manage system image", "Rollout policy": "Rollout policy", "Disruption budget": "Disruption budget", "Set rollout policies": "Set rollout policies", @@ -1176,19 +1184,21 @@ "Port mapping must be in format \"hostPort:targetPort[/protocol]\"": "Port mapping must be in format \"hostPort:targetPort[/protocol]\"", "Port mapping must be in format \"hostPort:targetPort\"": "Port mapping must be in format \"hostPort:targetPort\"", "Invalid port values": "Invalid port values", + "Added ports": "Added ports", "Enter host port": "Enter host port", "Enter target port": "Enter target port", "Add port mapping": "Add port mapping", "Add": "Add", - "Added ports": "Added ports", "Ports": "Ports", "Invalid {{ itemType }}": "Invalid {{ itemType }}", "Drag an image or browse to upload": "Drag an image or browse to upload", "Browse...": "Browse...", "File must be an image under 256 KB.": "File must be an image under 256 KB.", + "This image is defined by the Software Catalog {{ catalogItemRef }} and cannot be edited directly. To change the image, update the catalog item or channel assignment.": "This image is defined by the Software Catalog {{ catalogItemRef }} and cannot be edited directly. To change the image, update the catalog item or channel assignment.", "Resolved": "Resolved", "Name must be unique": "Name must be unique", "A unique system identifier. Cannot be changed after creation.": "A unique system identifier. Cannot be changed after creation.", + "Unknown": "Unknown", "Available": "Available", "Missing repository": "Missing repository", "Create repository": "Create repository", @@ -1244,7 +1254,6 @@ "Invalid SSH public key data": "Invalid SSH public key data", "Image includes invalid characters.": "Image includes invalid characters.", "Image must not start with a dot (.), slash (/), or underscore (_).": "Image must not start with a dot (.), slash (/), or underscore (_).", - "Image is required.": "Image is required.", "Volume name is required": "Volume name is required", "Pull policy must be one of: Always, IfNotPresent, or Never": "Pull policy must be one of: Always, IfNotPresent, or Never", "Pull policy is required": "Pull policy is required", @@ -1257,6 +1266,7 @@ "Protocol is required": "Protocol is required", "Definition source must be image for this type of applications": "Definition source must be image for this type of applications", "Application type is required": "Application type is required", + "Image is required.": "Image is required.", "CPU limit is invalid. Use a positive number of cores (e.g. 0.5 or 2).": "CPU limit is invalid. Use a positive number of cores (e.g. 0.5 or 2).", "Memory limit is invalid. Use a number with optional unit b, k, m, or g (e.g. 512m or 2g).": "Memory limit is invalid. Use a number with optional unit b, k, m, or g (e.g. 512m or 2g).", "YAML content is invalid.": "YAML content is invalid.", @@ -1904,6 +1914,18 @@ "OpenShift": "OpenShift", "Kubernetes": "Kubernetes", "Ansible Automation Platform": "Ansible Automation Platform", + "OS image": "OS image", + "QCOW2 (qcow2)": "QCOW2 (qcow2)", + "Bare Metal (iso)": "Bare Metal (iso)", + "Amazon Web Services (ami)": "Amazon Web Services (ami)", + "Anaconda Installer (anaconda-iso)": "Anaconda Installer (anaconda-iso)", + "Google Cloud (gce)": "Google Cloud (gce)", + "KVM/custom cloud import (raw)": "KVM/custom cloud import (raw)", + "Microsoft Hyper-V (vhd)": "Microsoft Hyper-V (vhd)", + "VMware vSphere (vmdk)": "VMware vSphere (vmdk)", + "Cloud native (container)": "Cloud native (container)", + "OpenShift Virtualization (qcow2-disk-container)": "OpenShift Virtualization (qcow2-disk-container)", + "Unknown ({{ type }})": "Unknown ({{ type }})", "Download {{ tool }} for Mac ({{ arch }})": "Download {{ tool }} for Mac ({{ arch }})", "Download {{ tool }} for Linux ({{ arch }})": "Download {{ tool }} for Linux ({{ arch }})", "Download {{ tool }} for Windows ({{ arch }})": "Download {{ tool }} for Windows ({{ arch }})", diff --git a/libs/types/alpha/index.ts b/libs/types/alpha/index.ts index 4c8480df93..b4a1b2b751 100644 --- a/libs/types/alpha/index.ts +++ b/libs/types/alpha/index.ts @@ -11,6 +11,8 @@ export type { CatalogItemArtifact } from './models/CatalogItemArtifact'; export { CatalogItemArtifactType } from './models/CatalogItemArtifactType'; export { CatalogItemCategory } from './models/CatalogItemCategory'; export type { CatalogItemConfigurable } from './models/CatalogItemConfigurable'; +export type { CatalogItemDeployment } from './models/CatalogItemDeployment'; +export type { CatalogItemDeploymentList } from './models/CatalogItemDeploymentList'; export type { CatalogItemDeprecation } from './models/CatalogItemDeprecation'; export type { CatalogItemList } from './models/CatalogItemList'; export type { CatalogItemMeta } from './models/CatalogItemMeta'; diff --git a/libs/types/alpha/models/CatalogItemDeployment.ts b/libs/types/alpha/models/CatalogItemDeployment.ts new file mode 100644 index 0000000000..b8d35bc18a --- /dev/null +++ b/libs/types/alpha/models/CatalogItemDeployment.ts @@ -0,0 +1,49 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +/** + * CatalogItemDeployment represents a specific deployment of a catalog item to a fleet or device. A catalog item associated to a fleet that has no devices is still considered a deployment. A deployment is only associated with the fleet and not the individual devices in cases where a device is using a catalog item deployment that came from a device spec template on a fleet. + */ +export type CatalogItemDeployment = { + apiVersion: ApiVersion; + /** + * Kind is a string value representing the REST resource this object represents. + */ + kind: string; + /** + * The device or fleet that this deployment pertains to. + */ + deployedTo?: { + /** + * Either a Device or Fleet. + */ + resourceKind?: string; + /** + * The name of the device or fleet. + */ + resourceName?: string; + }; + /** + * The catalog of the catalogItem. + */ + catalog: string; + /** + * The catalogItem that this deployment corresponds to. + */ + catalogItem: string; + /** + * The version of the catalog item that is deployed. + */ + version: string; + /** + * The channel, if any, that is intended to be tracked for the catalog item. + */ + channel?: string; + /** + * For catalog items in the 'application' category, the name of the application on the device that the deployment is associated with. Required for application catalog items. + */ + applicationName?: string; +}; + diff --git a/libs/types/alpha/models/CatalogItemDeploymentList.ts b/libs/types/alpha/models/CatalogItemDeploymentList.ts new file mode 100644 index 0000000000..214fdfd696 --- /dev/null +++ b/libs/types/alpha/models/CatalogItemDeploymentList.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +import type { CatalogItemDeployment } from './CatalogItemDeployment'; +import type { ListMeta } from '../../models/ListMeta'; +/** + * CatalogItemDeploymentList is a list of CatalogItemDeployments. + */ +export type CatalogItemDeploymentList = { + apiVersion: ApiVersion; + /** + * Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds. + */ + kind: string; + metadata: ListMeta; + /** + * List of CatalogItemDeployments. + */ + items: Array; +}; + diff --git a/libs/types/index.ts b/libs/types/index.ts index 8a10d2b533..37264a0c9f 100644 --- a/libs/types/index.ts +++ b/libs/types/index.ts @@ -37,6 +37,8 @@ export type { AuthStaticRoleAssignment } from './models/AuthStaticRoleAssignment export type { BaseImageEntry } from './models/BaseImageEntry'; export type { Batch } from './models/Batch'; export type { BatchSequence } from './models/BatchSequence'; +export type { CatalogItemRefApplicationProviderSpec } from './models/CatalogItemRefApplicationProviderSpec'; +export type { CatalogItemRefSpec } from './models/CatalogItemRefSpec'; export type { CertificateSigningRequest } from './models/CertificateSigningRequest'; export type { CertificateSigningRequestList } from './models/CertificateSigningRequestList'; export type { CertificateSigningRequestSpec } from './models/CertificateSigningRequestSpec'; @@ -144,7 +146,9 @@ export type { HttpConfigProviderSpec } from './models/HttpConfigProviderSpec'; export type { HttpRepoSpec } from './models/HttpRepoSpec'; export type { ImageApplicationProviderSpec } from './models/ImageApplicationProviderSpec'; export type { ImageMountVolumeProviderSpec } from './models/ImageMountVolumeProviderSpec'; +export type { ImageOrCatalogItemRefSpec } from './models/ImageOrCatalogItemRefSpec'; export { ImagePullPolicy } from './models/ImagePullPolicy'; +export type { ImageSpec } from './models/ImageSpec'; export type { ImageVolumeProviderSpec } from './models/ImageVolumeProviderSpec'; export type { ImageVolumeSource } from './models/ImageVolumeSource'; export type { InlineApplicationProviderSpec } from './models/InlineApplicationProviderSpec'; diff --git a/libs/types/models/CatalogItemRefApplicationProviderSpec.ts b/libs/types/models/CatalogItemRefApplicationProviderSpec.ts new file mode 100644 index 0000000000..4cd77bb818 --- /dev/null +++ b/libs/types/models/CatalogItemRefApplicationProviderSpec.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CatalogItemRefSpec } from './CatalogItemRefSpec'; +export type CatalogItemRefApplicationProviderSpec = { + catalogItemRef: CatalogItemRefSpec; +}; + diff --git a/libs/types/models/CatalogItemRefSpec.ts b/libs/types/models/CatalogItemRefSpec.ts new file mode 100644 index 0000000000..89acb7e753 --- /dev/null +++ b/libs/types/models/CatalogItemRefSpec.ts @@ -0,0 +1,26 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A reference to a catalog item, along with its configuration. + */ +export type CatalogItemRefSpec = { + /** + * The catalog name that the item is part of. + */ + catalog: string; + /** + * The name of the catalog item itself. + */ + item: string; + /** + * A valid version that currently exists in the catalog item. + */ + version: string; + /** + * An optional update channel which will be used to provide update cues when available. + */ + channel?: string; +}; + diff --git a/libs/types/models/ComposeApplication.ts b/libs/types/models/ComposeApplication.ts index 9b2ab5071e..8e0a360195 100644 --- a/libs/types/models/ComposeApplication.ts +++ b/libs/types/models/ComposeApplication.ts @@ -5,7 +5,8 @@ import type { ApplicationEnvVars } from './ApplicationEnvVars'; import type { ApplicationProviderBase } from './ApplicationProviderBase'; import type { ApplicationVolumeProviderSpec } from './ApplicationVolumeProviderSpec'; +import type { CatalogItemRefApplicationProviderSpec } from './CatalogItemRefApplicationProviderSpec'; import type { ImageApplicationProviderSpec } from './ImageApplicationProviderSpec'; import type { InlineApplicationProviderSpec } from './InlineApplicationProviderSpec'; -export type ComposeApplication = (ApplicationProviderBase & ApplicationEnvVars & ApplicationVolumeProviderSpec & (ImageApplicationProviderSpec | InlineApplicationProviderSpec)); +export type ComposeApplication = (ApplicationProviderBase & ApplicationEnvVars & ApplicationVolumeProviderSpec & (ImageApplicationProviderSpec | CatalogItemRefApplicationProviderSpec | InlineApplicationProviderSpec)); diff --git a/libs/types/models/ContainerApplication.ts b/libs/types/models/ContainerApplication.ts index a5c47f8482..818debd78b 100644 --- a/libs/types/models/ContainerApplication.ts +++ b/libs/types/models/ContainerApplication.ts @@ -8,11 +8,9 @@ import type { ApplicationProviderBase } from './ApplicationProviderBase'; import type { ApplicationResources } from './ApplicationResources'; import type { ApplicationUser } from './ApplicationUser'; import type { ApplicationVolumeProviderSpec } from './ApplicationVolumeProviderSpec'; -export type ContainerApplication = (ApplicationProviderBase & ApplicationEnvVars & ApplicationUser & ApplicationVolumeProviderSpec & { - /** - * Reference to the image for this container. - */ - image: string; +import type { CatalogItemRefApplicationProviderSpec } from './CatalogItemRefApplicationProviderSpec'; +import type { ImageApplicationProviderSpec } from './ImageApplicationProviderSpec'; +export type ContainerApplication = (ApplicationProviderBase & ApplicationEnvVars & ApplicationUser & ApplicationVolumeProviderSpec & (ImageApplicationProviderSpec | CatalogItemRefApplicationProviderSpec) & { /** * Port mappings. */ diff --git a/libs/types/models/DeviceOsSpec.ts b/libs/types/models/DeviceOsSpec.ts index 2dc1b46a7b..03154a6335 100644 --- a/libs/types/models/DeviceOsSpec.ts +++ b/libs/types/models/DeviceOsSpec.ts @@ -2,13 +2,5 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -/** - * DeviceOsSpec describes the target OS for the device. - */ -export type DeviceOsSpec = { - /** - * The target OS image name or URL. - */ - image: string; -}; - +import type { ImageOrCatalogItemRefSpec } from './ImageOrCatalogItemRefSpec'; +export type DeviceOsSpec = ImageOrCatalogItemRefSpec; diff --git a/libs/types/models/Event.ts b/libs/types/models/Event.ts index d126bb9033..217c6322c6 100644 --- a/libs/types/models/Event.ts +++ b/libs/types/models/Event.ts @@ -105,6 +105,8 @@ export namespace Event { DEPENDENCY_CHANGE_DETECTED = 'DependencyChangeDetected', DEPENDENCY_SYNC_PROBE_FAILED = 'DependencySyncProbeFailed', SYSTEM_RESTORED = 'SystemRestored', + ENCRYPTION_MIGRATION_STARTED = 'EncryptionMigrationStarted', + ENCRYPTION_MIGRATION_COMPLETED = 'EncryptionMigrationCompleted', APPLICATION_LIFECYCLE_CHANGED = 'ApplicationLifecycleChanged', } /** diff --git a/libs/types/models/HelmApplication.ts b/libs/types/models/HelmApplication.ts index c569097398..e297364453 100644 --- a/libs/types/models/HelmApplication.ts +++ b/libs/types/models/HelmApplication.ts @@ -3,11 +3,9 @@ /* tslint:disable */ /* eslint-disable */ import type { ApplicationProviderBase } from './ApplicationProviderBase'; -export type HelmApplication = (ApplicationProviderBase & { - /** - * Reference to the chart for this helm application. - */ - image: string; +import type { CatalogItemRefApplicationProviderSpec } from './CatalogItemRefApplicationProviderSpec'; +import type { ImageApplicationProviderSpec } from './ImageApplicationProviderSpec'; +export type HelmApplication = (ApplicationProviderBase & (ImageApplicationProviderSpec | CatalogItemRefApplicationProviderSpec) & { /** * The target namespace for the application deployment. */ diff --git a/libs/types/models/ImageApplicationProviderSpec.ts b/libs/types/models/ImageApplicationProviderSpec.ts index cdda8b5ef6..c10aeb4507 100644 --- a/libs/types/models/ImageApplicationProviderSpec.ts +++ b/libs/types/models/ImageApplicationProviderSpec.ts @@ -2,10 +2,5 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type ImageApplicationProviderSpec = { - /** - * Reference to the OCI image or artifact for the application package. - */ - image: string; -}; - +import type { ImageSpec } from './ImageSpec'; +export type ImageApplicationProviderSpec = ImageSpec; diff --git a/libs/types/models/ImageOrCatalogItemRefSpec.ts b/libs/types/models/ImageOrCatalogItemRefSpec.ts new file mode 100644 index 0000000000..348ad642cb --- /dev/null +++ b/libs/types/models/ImageOrCatalogItemRefSpec.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CatalogItemRefSpec } from './CatalogItemRefSpec'; +/** + * Either a specific OCI image reference, or a reference to a catalog item version that can be resolved to an OCI image ref. + */ +export type ImageOrCatalogItemRefSpec = ({ + /** + * Reference to an OCI image or artifact with tag. + */ + image?: string; +} & { + catalogItemRef?: CatalogItemRefSpec; +}); + diff --git a/libs/types/models/ImageSpec.ts b/libs/types/models/ImageSpec.ts new file mode 100644 index 0000000000..8dec8af69d --- /dev/null +++ b/libs/types/models/ImageSpec.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Reference to an OCI image or artifact with tag. + */ +export type ImageSpec = { + /** + * Reference to an OCI image or artifact with tag. + */ + image: string; +}; + diff --git a/libs/types/models/ImageVolumeSource.ts b/libs/types/models/ImageVolumeSource.ts index 8c9989d0b2..9474f59686 100644 --- a/libs/types/models/ImageVolumeSource.ts +++ b/libs/types/models/ImageVolumeSource.ts @@ -2,15 +2,17 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { CatalogItemRefSpec } from './CatalogItemRefSpec'; import type { ImagePullPolicy } from './ImagePullPolicy'; /** - * Describes the source of an OCI-compliant image or artifact. + * Describes the source of an OCI-compliant image or artifact. Exactly one of 'reference' or 'catalogItemRef' must be specified. */ export type ImageVolumeSource = { /** * Reference to an OCI-compliant image or artifact in a registry. This may be a container image or another type of OCI artifact, as long as it conforms to the OCI image specification. */ - reference: string; + reference?: string; + catalogItemRef?: CatalogItemRefSpec; pullPolicy?: ImagePullPolicy; }; diff --git a/libs/types/models/QuadletApplication.ts b/libs/types/models/QuadletApplication.ts index 81a9d261e0..c73f83ee6a 100644 --- a/libs/types/models/QuadletApplication.ts +++ b/libs/types/models/QuadletApplication.ts @@ -6,7 +6,8 @@ import type { ApplicationEnvVars } from './ApplicationEnvVars'; import type { ApplicationProviderBase } from './ApplicationProviderBase'; import type { ApplicationUser } from './ApplicationUser'; import type { ApplicationVolumeProviderSpec } from './ApplicationVolumeProviderSpec'; +import type { CatalogItemRefApplicationProviderSpec } from './CatalogItemRefApplicationProviderSpec'; import type { ImageApplicationProviderSpec } from './ImageApplicationProviderSpec'; import type { InlineApplicationProviderSpec } from './InlineApplicationProviderSpec'; -export type QuadletApplication = (ApplicationProviderBase & ApplicationEnvVars & ApplicationUser & ApplicationVolumeProviderSpec & (ImageApplicationProviderSpec | InlineApplicationProviderSpec)); +export type QuadletApplication = (ApplicationProviderBase & ApplicationEnvVars & ApplicationUser & ApplicationVolumeProviderSpec & (ImageApplicationProviderSpec | CatalogItemRefApplicationProviderSpec | InlineApplicationProviderSpec)); diff --git a/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/ReviewStep.tsx b/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/ReviewStep.tsx index e2b5259e18..e1d5b3ba5f 100644 --- a/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/ReviewStep.tsx +++ b/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/ReviewStep.tsx @@ -13,12 +13,11 @@ import { } from '@patternfly/react-core'; import { useFormikContext } from 'formik'; +import type { AddCatalogItemFormValues } from '../types'; import { useTranslation } from '../../../../hooks/useTranslation'; import { getErrorMessage } from '../../../../utils/error'; -import { AddCatalogItemFormValues } from '../types'; -import { appTypeIds } from '../../useCatalogs'; -import { getArtifactLabel } from '../../utils'; -import { CatalogItemArtifactType } from '@flightctl/types/alpha'; +import { appTypeIds } from '../../useCatalogItems'; +import { getArtifactLabel } from '../../../../utils/catalog'; export const reviewStepId = 'review'; @@ -74,11 +73,7 @@ const ReviewStep = ({ error, isEdit, isReadOnly }: { error: unknown; isEdit?: bo {values.artifacts.map((a) => { - return ( - {`${getArtifactLabel(t, a.type as CatalogItemArtifactType, a.name)} - ${a.uri}`} - ); + return {`${getArtifactLabel(t, a)} - ${a.uri}`}; })} diff --git a/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/TypeConfigStep.tsx b/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/TypeConfigStep.tsx index 4972d00f98..6d52b70ca0 100644 --- a/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/TypeConfigStep.tsx +++ b/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/TypeConfigStep.tsx @@ -14,7 +14,7 @@ import FormSelect from '../../../form/FormSelect'; import FlightCtlForm from '../../../form/FlightCtlForm'; import ExpandableFormSection from '../../../form/ExpandableFormSection'; import UploadField from '../../../form/UploadField'; -import { getArtifactLabel, getCatalogItemBadge } from '../../utils'; +import { getArtifactLabel, getCatalogItemBadge } from '../../../../utils/catalog'; export const typeConfigStepId = 'type-config'; @@ -24,6 +24,12 @@ export const isTypeConfigStepValid = (errors: FormikErrors ({ + type: artifactType, + name: '', + uri: '', +})); + const catalogItemTypeLabels = ( t: TFunction, ): Record< @@ -38,8 +44,8 @@ const catalogItemTypeLabels = ( [CatalogItemType.CatalogItemTypeData]: getCatalogItemBadge(CatalogItemType.CatalogItemTypeData, t), }); -const getArtifactTitle = (artifact: ArtifactFormValue, index: number, t: ReturnType['t']) => { - const typeLabel = artifact.type ? getArtifactLabel(t, artifact.type, artifact.name) : artifact.name; +const getArtifactTitle = (artifact: ArtifactFormValue, index: number, t: TFunction) => { + const typeLabel = getArtifactLabel(t, artifact); return typeLabel || t('Artifact {{ num }}', { num: index + 1 }); }; @@ -118,8 +124,8 @@ const TypeConfigStep = ({ isEdit, isReadOnly }: { isEdit?: boolean; isReadOnly?: { - acc[curr] = getArtifactLabel(t, curr); + items={artifactTypes.reduce((acc, artifact) => { + acc[artifact.type] = getArtifactLabel(t, artifact); return acc; }, {})} placeholderText={t('Select a type')} diff --git a/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/VersionStep.tsx b/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/VersionStep.tsx index 5ae85e7f8b..a9ba466eef 100644 --- a/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/VersionStep.tsx +++ b/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/steps/VersionStep.tsx @@ -7,9 +7,9 @@ import { FieldArray, FormikErrors, useField, useFormikContext } from 'formik'; import { CatalogItemArtifactType } from '@flightctl/types/alpha'; import { useTranslation } from '../../../../hooks/useTranslation'; -import { AddCatalogItemFormValues, VersionFormValues, configurableAppTypes } from '../types'; +import { type AddCatalogItemFormValues, type VersionFormValues, configurableAppTypes } from '../types'; import { getEmptyVersion } from '../utils'; -import { appTypeIds } from '../../useCatalogs'; +import { appTypeIds } from '../../useCatalogItems'; import TextField from '../../../form/TextField'; import TextAreaField from '../../../form/TextAreaField'; import CheckboxField from '../../../form/CheckboxField'; @@ -18,7 +18,7 @@ import ExpandableFormSection from '../../../form/ExpandableFormSection'; import ChannelsSelect from '../ChannelsSelect'; import UploadField from '../../../form/UploadField'; import ErrorHelperText from '../../../form/FieldHelperText'; -import { getArtifactLabel } from '../../utils'; +import { getArtifactLabel } from '../../../../utils/catalog'; export const versionStepId = 'version'; @@ -153,10 +153,7 @@ const ReferencesField = ({ index, isReadOnly }: { index: number; isReadOnly?: bo {artifactsWithType.map((artifact) => { return ( - + void; + refetch: VoidFunction; + onClose: VoidFunction; +}; + +const CatalogItemDetailsModal = ({ item, - onClose, - canInstall, - targetHasOwner, + itemModalOpen, + setItemModalOpen, refetch, - showCatalogMgmt, - targetSet, -}: CatalogItemDetailsPanelProps) => { + onClose, +}: CatalogItemDetailsModalProps) => { const { t } = useTranslation(); - const navigate = useNavigate(); const { patch, remove } = useFetch(); - const [isDeprecateModalOpen, setIsDeprecateModalOpen] = React.useState(false); - const [isRestoreModalOpen, setIsRestoreModalOpen] = React.useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false); - const isDeprecated = !!item.spec.deprecation; + const displayName = item.spec.displayName || (item.metadata.name as string); + const itemEndpoint = `catalogs/${item.metadata.catalog}/items/${item.metadata.name}`; + switch (itemModalOpen) { + case 'deprecate': + return ( + setItemModalOpen(undefined)} + onDeprecate={async (message) => { + const isDeprecated = !!item.spec.deprecation; + await patch(itemEndpoint, [ + { + op: isDeprecated ? 'replace' : 'add', + path: '/spec/deprecation', + value: { message }, + }, + ]); + refetch(); + setItemModalOpen(undefined); + }} + /> + ); + case 'restore': + return ( + setItemModalOpen(undefined)} + onRestore={async () => { + await patch(itemEndpoint, [ + { + op: 'remove', + path: '/spec/deprecation', + }, + ]); + refetch(); + setItemModalOpen(undefined); + }} + /> + ); + case 'delete': + return ( + setItemModalOpen(undefined)} + onDelete={async () => { + await remove(itemEndpoint); + setItemModalOpen(undefined); + refetch(); + onClose(); + }} + /> + ); + } + + return null; +}; + +const CatalogItemPanelLink = ({ link }: { link: string | undefined }) => { + const { t } = useTranslation(); + if (!link) { + return t('N/A'); + } + return ( + + ); +}; + +type CatalogItemDeployButtonProps = { + item: CatalogItem; + canInstall: boolean; + targetHasOwner?: boolean; + targetSet: boolean; +}; + +const CatalogItemDeployButton = ({ item, canInstall, targetHasOwner, targetSet }: CatalogItemDeployButtonProps) => { + const { t } = useTranslation(); const { - values: { version, channel }, submitForm, + values: { version, channel }, } = useFormikContext(); - const deployDisabledReasons: string[] = []; + const disabledReasons: string[] = []; if (targetHasOwner) { - deployDisabledReasons.push(t('This resource is managed by an owner and cannot be modified directly')); + disabledReasons.push(t('This resource is managed by an owner and cannot be modified directly')); } else if (!canInstall) { - deployDisabledReasons.push(t('You do not have permission to deploy')); + disabledReasons.push(t('You do not have permission to deploy')); } if (!channel) { - deployDisabledReasons.push(t('A channel must be selected')); + disabledReasons.push(t('A channel must be selected')); } if (!version) { - deployDisabledReasons.push(t('A version must be selected')); + disabledReasons.push(t('A version must be selected')); } const catalogItemVersion = item.spec.versions.find((v) => v.version === version); @@ -124,14 +207,57 @@ const CatalogItemDetailsPanel = ({ (targetSet || item.spec.type !== CatalogItemType.CatalogItemTypeOS) && !getFullContainerURI(item.spec.artifacts, catalogItemVersion) ) { - deployDisabledReasons.push('This catalog item does not have a deployable artifact'); + disabledReasons.push(t('This catalog item does not have a deployable artifact')); } } + return ( + + {disabledReasons.map((reason, index) => ( + {reason} + ))} + + } + > + + + ); +}; + +const CatalogItemDetailsPanel = ({ + item, + onClose, + canInstall, + targetHasOwner, + refetch, + showCatalogMgmt, + targetSet, +}: CatalogItemDetailsPanelProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [itemModalOpen, setItemModalOpen] = React.useState(undefined); + const isInUse = useItemIsInUse(item); + + const isDeprecated = !!item.spec.deprecation; const isManaged = !!item.metadata.owner; + const managedActionDisabledReason = isManaged + ? { + content: t( + "This catalog item is managed by a resource sync and cannot be modified directly. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.", + ), + } + : undefined; - const regularActions: IAction[] = [ - { + const regularActions: IAction[] = []; + const dangerActions: IAction[] = []; + if (showCatalogMgmt) { + regularActions.push({ title: isManaged ? t('View') : t('Edit'), onClick: () => { navigate({ @@ -139,145 +265,93 @@ const CatalogItemDetailsPanel = ({ postfix: `${item.metadata.catalog}/${item.metadata.name}`, }); }, - }, - isDeprecated - ? { - title: t('Restore'), - onClick: () => setIsRestoreModalOpen(true), - tooltipProps: isManaged - ? { - content: t( - "This catalog item is managed by a resource sync and cannot be directly restored. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.", - ), - } - : undefined, - isAriaDisabled: isManaged, - } - : { - title: t('Deprecate'), - onClick: () => setIsDeprecateModalOpen(true), - tooltipProps: isManaged - ? { - content: t( - "This catalog item is managed by a resource sync and cannot be directly deprecated. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.", - ), - } - : undefined, - isAriaDisabled: isManaged, - }, - ]; - const dangerActions: IAction[] = [ - { + }); + if (isDeprecated) { + regularActions.push({ + title: t('Restore'), + onClick: () => setItemModalOpen('restore'), + tooltipProps: managedActionDisabledReason, + isAriaDisabled: !!managedActionDisabledReason, + }); + } else { + regularActions.push({ + title: t('Deprecate'), + onClick: () => setItemModalOpen('deprecate'), + tooltipProps: managedActionDisabledReason, + isAriaDisabled: !!managedActionDisabledReason, + }); + } + + // Adding delete action + let disabledProps = managedActionDisabledReason; + const isDisabled = isManaged || isInUse; + if (!disabledProps && isInUse) { + disabledProps = { content: t('This catalog item is being used in at least one fleet or device.') }; + } + dangerActions.push({ title: t('Delete'), - onClick: () => setIsDeleteModalOpen(true), - tooltipProps: isManaged - ? { - content: t( - "This catalog item is managed by a resource sync and cannot be directly deleted. Either remove this catalog's definition from the resource sync configuration, or delete the resource sync first.", - ), - } - : undefined, - isAriaDisabled: isManaged, - }, - ]; - const catalogItemActions = buildAllDropdownActions(regularActions, dangerActions); + onClick: () => setItemModalOpen('delete'), + tooltipProps: disabledProps, + isAriaDisabled: isDisabled, + }); + } - const panelContent = ( - <> - - - - {showCatalogMgmt && } - - - - - - - - - - - {item.spec.type === CatalogItemType.CatalogItemTypeData ? ( - - ) : ( - - - {deployDisabledReasons.map((reason, index) => ( - {reason} - ))} - - } - > - - - - )} - - - - - - - - - - ); + const catalogItemActions = buildAllDropdownActions(regularActions, dangerActions); return ( <> - - {isDeprecateModalOpen && ( - setIsDeprecateModalOpen(false)} - onDeprecate={async (message) => { - await patch(`catalogs/${item.metadata.catalog}/items/${item.metadata.name}`, [ - { - op: isDeprecated ? 'replace' : 'add', - path: '/spec/deprecation', - value: { message }, - }, - ]); - refetch(); - setIsDeprecateModalOpen(false); - }} - /> - )} - {isRestoreModalOpen && ( - setIsRestoreModalOpen(false)} - onRestore={async () => { - await patch(`catalogs/${item.metadata.catalog}/items/${item.metadata.name}`, [ - { - op: 'remove', - path: '/spec/deprecation', - }, - ]); - refetch(); - setIsRestoreModalOpen(false); - }} - /> - )} - {isDeleteModalOpen && ( - setIsDeleteModalOpen(false)} - onDelete={async () => { - await remove(`catalogs/${item.metadata.catalog}/items/${item.metadata.name}`); - refetch(); - setIsDeleteModalOpen(false); - onClose(); - }} - /> - )} + + + + + {catalogItemActions.length > 0 && } + + + + + + + + + + + {item.spec.type === CatalogItemType.CatalogItemTypeData ? ( + + ) : ( + + + + )} + + + + + + + + + + } + /> + ); }; @@ -318,37 +392,19 @@ export const CatalogItemDetailsContent = ({ item }: CatalogItemDetailsContentPro {t('Documentation URL')} - {item.spec.documentationUrl ? ( - - ) : ( - t('N/A') - )} + {t('Support URL')} - {item.spec.support ? ( - - ) : ( - t('N/A') - )} + {t('Homepage')} - {item.spec.homepage ? ( - - ) : ( - t('N/A') - )} + diff --git a/libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx b/libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx new file mode 100644 index 0000000000..b171a5c674 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { + Button, + Content, + ContentVariants, + Flex, + FlexItem, + Icon, + Popover, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons/dist/js/icons/exclamation-circle-icon'; +import type { TFunction } from 'i18next'; + +import type { CatalogItemRefSpec } from '@flightctl/types'; +import type { CatalogItem } from '@flightctl/types/alpha'; +import { useTranslation } from '../../hooks/useTranslation'; +import { getCatalogItemIcon } from '../../utils/catalog'; + +const formatVersionLine = (t: TFunction, version?: string, channel?: string) => { + if (!version) { + return undefined; + } + return channel + ? t('Version: {{ version }}, Channel: {{ channel }}', { version, channel }) + : t('Version: {{ version }}', { version }); +}; + +type CatalogTitleLayoutProps = { + icon: React.ReactNode; + title: string; + description?: string; + version?: string; + channel?: string; +}; + +const CatalogTitleLayout = ({ icon, title, description, version, channel }: CatalogTitleLayoutProps) => { + const { t } = useTranslation(); + const versionLine = formatVersionLine(t, version, channel); + return ( + + {icon} + + + + {title} + + {description && ( + + {description} + + )} + {versionLine && ( + + {versionLine} + + )} + + + + ); +}; + +export const BrokenCatalogItemTitle = ({ + catalogRef, + description, +}: { + catalogRef: CatalogItemRefSpec; + description?: string; +}) => { + const { t } = useTranslation(); + return ( + + @@ -149,7 +243,7 @@ const DeleteCatalogModal = ({ catalogId, catalogDisplayName, onClose, onDeleteSu isLoading={isLoadingItems || isDeleting} onClick={deleteAction} > - {t('Delete catalog')} + {hasFailedToDeleteItems ? t('Retry delete') : t('Delete catalog')} )} - ) : ( - - ); -}; - -export const CatalogItemTitle = ({ - item, - appName, - version, - channel, -}: { - item: CatalogItem; - appName?: string; - version?: string; - channel: string; -}) => { - const { t } = useTranslation(); - return ( - - - {`${item.metadata.name} - - - - - {item.spec.displayName || item.metadata.name} - - {appName && ( - - {appName} - - )} - {version && ( - - - {t('Version: {{version}}, Channel: {{channel}}', { version, channel })} - - - )} - - - - ); +const getOsDisplayName = (os: SpecOsCatalogItem) => { + const osItem = os.data?.item; + return osItem?.spec.displayName || osItem?.metadata.name || formatCatalogItemRef(os.ref); }; type InstalledSoftwareProps = { - labels: Record | undefined; hasPackageMode?: boolean; spec: DeviceSpec | undefined; onDeleteOs: () => Promise; @@ -116,10 +33,7 @@ type InstalledSoftwareProps = { canEdit: boolean; }; -type AppItem = { item: CatalogItem; name: string }; - const InstalledSoftware = ({ - labels, spec, onDeleteOs, onDeleteApp, @@ -128,101 +42,21 @@ const InstalledSoftware = ({ hasPackageMode, }: InstalledSoftwareProps) => { const { t } = useTranslation(); - const [appItems, setAppItems] = React.useState(); - const [appsLoading, setAppsLoading] = React.useState(true); const [deleteOs, setDeleteOs] = React.useState(false); const [appToDelete, setAppToDelete] = React.useState(); - const osItemId = labels?.[OS_ITEM_LABEL_KEY]; - const osChannel = labels?.[OS_CHANNEL_LABEL_KEY]; - const osCatalog = labels?.[OS_CATALOG_LABEL_KEY]; - - const { get } = useFetch(); - - const apps = React.useMemo(() => { - if (!labels) { - return []; - } - return Object.keys(labels).reduce( - (acc, key) => { - if (key.endsWith(APP_ITEM_LABEL_KEY)) { - const appName = key.slice(0, -(APP_ITEM_LABEL_KEY.length + 1)); - const item = labels[`${appName}.${APP_ITEM_LABEL_KEY}`]; - const catalog = labels[`${appName}.${APP_CATALOG_LABEL_KEY}`]; - const channel = labels[`${appName}.${APP_CHANNEL_LABEL_KEY}`]; - if (item && catalog && channel && spec?.applications?.find((a) => a.name === appName)) { - acc.push({ - item, - catalog, - channel, - name: appName, - }); - } - } - return acc; - }, - [] as { - item: string; - catalog: string; - channel: string; - name: string; - }[], - ); - }, [labels, spec?.applications]); - - React.useEffect(() => { - (async () => { - const appRequests = apps.map((app) => get(`catalogs/${app.catalog}/items/${app.item}`)); - const results = await Promise.allSettled(appRequests); + const { os, apps, error, isLoading } = useSpecCatalogItems(spec); - const items: AppItem[] = []; - results.forEach((r, idx) => { - if (r.status === 'rejected') { - // eslint-disable-next-line no-console - console.warn(`Failed to fetch catalog item ${apps[idx].catalog}/${apps[idx].item}`); - } else { - items.push({ - item: r.value, - name: apps[idx].name, - }); - } - }); - setAppItems(items); - setAppsLoading(false); - })(); - }, [apps, get]); - - const [osItem, osLoading] = useCatalogItem(osCatalog, osItemId); - - if (osLoading || appsLoading) { + if (isLoading) { return ; } + if (error) { + return ; + } - const catalogItemVersion = osItem?.spec.versions.find((v) => { - const imageUri = getFullContainerURI(osItem.spec.artifacts, v); - return !!imageUri && !!spec?.os?.image && imageUri === spec.os.image && v.channels.includes(osChannel || ''); - }); - - const hasOs = !!(osItem && osCatalog && osChannel && catalogItemVersion && spec); - const hasApps = !!(appItems && appItems.length > 0); + const hasOs = !!os; + const hasApps = apps.length > 0; const isEmpty = !hasOs && !hasApps; - const osActions = osItem - ? buildAllDropdownActions( - [ - { - title: t('Edit'), - onClick: () => onEdit(osItem.metadata.catalog, osItem.metadata.name || ''), - }, - ], - [ - { - title: t('Delete'), - onClick: () => setDeleteOs(true), - }, - ], - ) - : []; - return ( <> @@ -238,127 +72,37 @@ const InstalledSoftware = ({ ) : ( - {osItem && osCatalog && osChannel && catalogItemVersion && spec && ( - - - - - - - onEdit(osItem.metadata.catalog, osItem.metadata.name || '')} - canEdit={canEdit} - /> - - {(osItem.spec.deprecation || catalogItemVersion.deprecation) && ( - - - - - - )} - {canEdit && ( - - - - )} - - + {hasOs && ( + onEdit(os.ref.catalog, os.ref.item || '')} + onDelete={() => setDeleteOs(true)} + canEdit={canEdit} + /> )} - {appItems?.map((app, index) => { - const appChannel = labels?.[`${app.name}.${APP_CHANNEL_LABEL_KEY}`] || ''; - const appSpec = spec?.applications?.find((a) => a.name === app.name); - const itemVersion = - appSpec && - app.item.spec.versions.find((v) => { - const refUri = getFullContainerURI(app.item.spec.artifacts, v); - const imageMatches = refUri === (appSpec as ContainerApplication).image; - return imageMatches && v.channels.includes(appChannel); - }); - const regularActions: IAction[] = itemVersion - ? [ - { - title: t('Edit'), - onClick: () => onEdit(app.item.metadata.catalog, app.item.metadata.name || '', app.name), - }, - ] - : []; - const dangerActions: IAction[] = [ - { - title: t('Delete'), - onClick: () => setAppToDelete(app.name), - }, - ]; - const actions = buildAllDropdownActions(regularActions, dangerActions); - - return ( - - {(hasOs || index > 0) && } - - - - - - - {itemVersion && ( - onEdit(app.item.metadata.catalog, app.item.metadata.name || '', app.name)} - canEdit={canEdit} - /> - )} - - {(app.item.spec.deprecation || itemVersion?.deprecation) && ( - - - - - - )} - {canEdit && ( - - - - )} - - - - ); - })} + {apps.map((app, index) => ( + + {(hasOs || index > 0) && } + onEdit(app.ref.catalog, app.ref.item || '', app.appName)} + onDelete={() => setAppToDelete(app.appName)} + canEdit={canEdit} + /> + + ))} )} - {deleteOs && ( + {deleteOs && os && ( setDeleteOs(false)} onDelete={async () => { await onDeleteOs(); setDeleteOs(false); }} - resourceName={osItem?.spec.displayName || osItem?.metadata.name || ''} + resourceName={getOsDisplayName(os)} resourceType={t('operating system')} /> )} diff --git a/libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx b/libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx new file mode 100644 index 0000000000..a4f116f025 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx @@ -0,0 +1,147 @@ +import * as React from 'react'; +import { ArrowCircleUpIcon } from '@patternfly/react-icons/dist/js/icons/arrow-circle-up-icon'; +import { ActionsColumn, IAction } from '@patternfly/react-table'; +import { Button, Flex, FlexItem, Label, Popover, StackItem } from '@patternfly/react-core'; + +import { getUpdates } from '../../utils/catalog'; +import { useTranslation } from '../../hooks/useTranslation'; +import { buildAllDropdownActions } from '../common/ActionsDropdownList'; +import { ResolvedCatalogItemData, type SpecAppCatalogItem, type SpecOsCatalogItem } from './useSpecCatalogItems'; +import CatalogItemTitle, { BrokenCatalogItemTitle } from './CatalogItemTitle'; + +const isAppCatalogItem = (entry: SpecOsCatalogItem | SpecAppCatalogItem): entry is SpecAppCatalogItem => { + return 'appName' in entry; +}; + +const SoftwareItemTitle = ({ entry }: { entry: SpecOsCatalogItem | SpecAppCatalogItem }) => { + const { ref: catalogRef, data } = entry; + const description = isAppCatalogItem(entry) && entry.appName ? entry.appName : ''; + + if (!data) { + return ; + } + return ( + + ); +}; + +const SoftwareItemVersionInfo = ({ + data, + onEdit, + canEdit, +}: { + data: ResolvedCatalogItemData; + onEdit: VoidFunction; + canEdit: boolean; +}) => { + const { t } = useTranslation(); + const { item, version, channel } = data; + if (!version) { + return null; + } + + const updates = getUpdates(item, channel, version.version); + if (!updates.length) { + return null; + } + + return canEdit ? ( + + ) : ( + + ); +}; + +const SoftwareItemDeprecation = ({ data }: { data: ResolvedCatalogItemData }) => { + const { t } = useTranslation(); + const deprecationMessage = data.item.spec.deprecation?.message || data.version?.deprecation?.message; + if (!deprecationMessage) { + return null; + } + + return ( + + + + ); +}; + +type InstalledSoftwareItemProps = { + entry: SpecOsCatalogItem | SpecAppCatalogItem; + onEdit: VoidFunction; + onDelete: VoidFunction; + canEdit: boolean; +}; + +const InstalledSoftwareItem = ({ entry, onEdit, onDelete, canEdit }: InstalledSoftwareItemProps) => { + const { t } = useTranslation(); + + const { data } = entry; + const isValidCatalogItem = !!data; + + const regularActions: IAction[] = []; + const dangerActions: IAction[] = []; + if (canEdit) { + const invalidItemProps = isValidCatalogItem + ? undefined + : { + isAriaDisabled: true, + tooltipProps: { + content: t('The referenced catalog item is invalid. This catalog item cannot be edited.'), + }, + }; + regularActions.push({ + title: t('Edit'), + onClick: onEdit, + ...invalidItemProps, + }); + } + // We allow deleting catalog items also when their references are broken + if (canEdit) { + dangerActions.push({ + title: t('Delete'), + onClick: onDelete, + }); + } + + const actions = buildAllDropdownActions(regularActions, dangerActions); + return ( + + + + + + + {isValidCatalogItem && ( + <> + + + + + + + + )} + + {actions.length > 0 && ( + + + + )} + + + ); +}; + +export default InstalledSoftwareItem; diff --git a/libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.tsx b/libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.tsx index 4758e4bf4c..c9f9638150 100644 --- a/libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.tsx +++ b/libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.tsx @@ -3,12 +3,12 @@ import * as React from 'react'; import { Stack, StackItem } from '@patternfly/react-core'; import { CatalogItem } from '@flightctl/types/alpha'; -import { getRemoveAppPatches, getRemoveOsPatches } from '../../Catalog/utils'; -import { CatalogPageContent } from '../../Catalog/CatalogPage'; -import InstalledSoftware from '../../Catalog/InstalledSoftware'; -import { usePermissionsContext } from '../../common/PermissionsContext'; +import { getRemoveAppPatches, getRemoveOsPatches } from '../../../utils/catalog'; import { RESOURCE, VERB } from '../../../types/rbac'; +import { usePermissionsContext } from '../../common/PermissionsContext'; import PageWithPermissions from '../../common/PageWithPermissions'; +import { CatalogPageContent } from '../../Catalog/CatalogPage'; +import InstalledSoftware from '../../Catalog/InstalledSoftware'; import './ResourceCatalogPage.css'; @@ -18,7 +18,6 @@ type ResourceCatalogPageProps = { hasOwner?: boolean; hasPackageMode?: boolean; spec: DeviceSpec | undefined; - currentLabels: Record | undefined; onPatch: (allPatches: PatchRequest) => Promise; onEdit: (catalogId: string, catalogItemId: string, appName?: string) => void; onInstall: (installItem: { item: CatalogItem; channel: string; version: string }) => void; @@ -30,7 +29,6 @@ const catalogPagePermissions = [ ]; const ResourceCatalogPage = ({ - currentLabels, spec, onPatch, specPath, @@ -43,7 +41,7 @@ const ResourceCatalogPage = ({ const { checkPermissions, loading } = usePermissionsContext(); const [canListItems, canListCatalogs] = checkPermissions(catalogPagePermissions); const onDeleteOs = async () => { - const allPatches = getRemoveOsPatches({ currentLabels, specPath }); + const allPatches = getRemoveOsPatches({ specPath }); await onPatch(allPatches); }; @@ -51,7 +49,6 @@ const ResourceCatalogPage = ({ const allPatches = getRemoveAppPatches({ appName, currentApps: spec?.applications, - currentLabels, specPath, }); await onPatch(allPatches); @@ -63,7 +60,6 @@ const ResourceCatalogPage = ({ { - const appPrefix = appName ? `${appName}.` : ''; - return `${appPrefix}${volumeName}.${label}`; -}; diff --git a/libs/ui-components/src/components/Catalog/useCatalogs.ts b/libs/ui-components/src/components/Catalog/useCatalogItems.ts similarity index 75% rename from libs/ui-components/src/components/Catalog/useCatalogs.ts rename to libs/ui-components/src/components/Catalog/useCatalogItems.ts index 7488596be4..b3a9f50a26 100644 --- a/libs/ui-components/src/components/Catalog/useCatalogs.ts +++ b/libs/ui-components/src/components/Catalog/useCatalogItems.ts @@ -1,22 +1,11 @@ import * as React from 'react'; import { useDebounce } from 'use-debounce'; -import { CatalogItem, CatalogItemList } from '@flightctl/types/alpha'; +import { CatalogItem, CatalogItemDeploymentList, CatalogItemList } from '@flightctl/types/alpha'; import { CatalogItemCategory, CatalogItemType } from '@flightctl/types/alpha'; import { useFetchPeriodically } from '../../hooks/useFetchPeriodically'; import { PaginationDetails, useTablePagination } from '../../hooks/useTablePagination'; import { PAGE_SIZE } from '../../constants'; -export const useCatalogItem = ( - catalog: string | undefined, - item: string | undefined, -): [CatalogItem | undefined, boolean, unknown, boolean, VoidFunction] => { - const [catalogItem, loading, error, refetch, updating] = useFetchPeriodically({ - endpoint: catalog && item ? `catalogs/${catalog}/items/${item}` : '', - }); - - return [catalogItem, loading, error, updating, refetch]; -}; - export const appTypeIds = [ CatalogItemType.CatalogItemTypeContainer, CatalogItemType.CatalogItemTypeHelm, @@ -38,8 +27,9 @@ const buildCatalogItemsFieldSelector = ( let selectedTypes: CatalogItemType[] = []; const allTypesSelected = [...systemTypeIds, ...appTypeIds].every((id) => itemType?.includes(id)); + if (!allTypesSelected) { - selectedTypes = itemType ? itemType.filter((t) => !excludeItemType || t !== excludeItemType) : []; + selectedTypes = itemType ? [...itemType] : []; const categories: CatalogItemCategory[] = []; if (appTypeIds.every((id) => selectedTypes.includes(id))) { @@ -52,14 +42,20 @@ const buildCatalogItemsFieldSelector = ( } } - if (selectedTypes.length > 0) { - parts.push(`spec.type in (${selectedTypes.join(',')})`); + const isInvalidSelection = selectedTypes.length === 1 && selectedTypes[0] === excludeItemType; + if (isInvalidSelection) { + // When there's a single type to filter for, and at the same time it's been excluded, + // the query should return no catalog items. (Forced this by querying for a required field not being present) + parts.push('!spec.type'); + } else if (selectedTypes.length > 0) { + const typesToQuery = excludeItemType ? selectedTypes.filter((t) => t !== excludeItemType) : selectedTypes; + parts.push(`spec.type in (${typesToQuery.join(',')})`); } else if (excludeItemType) { parts.push(`spec.type != ${excludeItemType}`); } - if (nameFilter?.trim()) { - parts.push(`metadata.name contains ${nameFilter.trim()}`); + if (nameFilter) { + parts.push(`metadata.name contains ${nameFilter}`); } if (catalogs.length) { parts.push(`metadata.catalog in (${catalogs.join(',')})`); @@ -88,6 +84,7 @@ export const useCatalogItems = ({ ] => { const pagination = useTablePagination(); const { itemType, nameFilter, catalogs } = catalogFilter; + const fieldSelector = React.useMemo( () => itemType || nameFilter || catalogs || excludeItemType @@ -115,7 +112,7 @@ export const useCatalogItems = ({ React.useEffect(() => { pagination.setCurrentPage(1); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nameFilter, itemType, excludeItemType]); + }, [nameFilter, itemType, catalogs, excludeItemType]); const [catalogItemsList, loading, error, refetch, isFetchUpdating] = useFetchPeriodically( { endpoint: endpointDebounced }, @@ -126,3 +123,11 @@ export const useCatalogItems = ({ return [catalogItemsList?.items || [], loading, error, pagination, isUpdating, refetch]; }; + +export const useItemIsInUse = (catalogItem: CatalogItem): boolean => { + const [deployments] = useFetchPeriodically({ + endpoint: `catalogs/${catalogItem.metadata.catalog}/items/${catalogItem.metadata.name}/deployments?limit=1`, + }); + + return (deployments?.items?.length ?? 0) > 0; +}; diff --git a/libs/ui-components/src/components/Catalog/useCatalogItemsLookup.ts b/libs/ui-components/src/components/Catalog/useCatalogItemsLookup.ts new file mode 100644 index 0000000000..e7e1352715 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/useCatalogItemsLookup.ts @@ -0,0 +1,148 @@ +import * as React from 'react'; +import type { CatalogItem } from '@flightctl/types/alpha'; + +import { useFetch } from '../../hooks/useFetch'; +import { type CatalogItemId, catalogItemCacheKey } from '../../utils/catalog'; + +const catalogItemEndpoint = (id: CatalogItemId): string => + `catalogs/${encodeURIComponent(id.catalog)}/items/${encodeURIComponent(id.item)}`; + +/** Matches K8s DNS subdomain resource names used for Catalog / CatalogItem metadata.name. */ +const CATALOG_PATH_SEGMENT_MAX_LENGTH = 253; +const CATALOG_PATH_SEGMENT_REGEXP = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/; + +export const isValidCatalogPathSegment = (segment: string): boolean => + segment.length > 0 && segment.length <= CATALOG_PATH_SEGMENT_MAX_LENGTH && CATALOG_PATH_SEGMENT_REGEXP.test(segment); + +const isValidCatalogItemId = (id: CatalogItemId): boolean => + isValidCatalogPathSegment(id.catalog) && isValidCatalogPathSegment(id.item); + +export type CatalogItemsLookupResult = { + getItem: (catalog: string, item: string) => CatalogItem | undefined; + isLoading: boolean; + error?: unknown; +}; + +/** + * Dedupes, sorts, and validates ids into a stable effect dependency key. + * Returns null if any ids are invalid. + */ +const getNeededCatalogItemIdsKey = (ids: CatalogItemId[]): string | null => { + const byKey = new Map(); + let hasError = false; + ids.forEach((id) => { + const isValid = isValidCatalogItemId(id); + if (isValid) { + byKey.set(catalogItemCacheKey(id), id); + } else { + hasError = true; + return; + } + }); + if (hasError) { + return null; + } + return JSON.stringify( + [...byKey.values()].sort((a, b) => a.catalog.localeCompare(b.catalog) || a.item.localeCompare(b.item)), + ); +}; + +export const useCatalogItemFromParams = (params: { catalogId: string; itemId: string }) => { + const { catalogId, itemId } = params; + const { getItem, isLoading, error } = useCatalogItemsLookup([{ catalog: catalogId, item: itemId }]); + const item = getItem(catalogId, itemId); + return { item, isLoading, error }; +}; + +/** + * Fetches and caches CatalogItems by catalog/item id. + * Dedupes, fetches only missing ids, and soft-fails individual requests (exposes aggregate error). + */ +export const useCatalogItemsLookup = (ids: CatalogItemId[]): CatalogItemsLookupResult => { + const { get: fetchGet } = useFetch(); + const [catalogItemsByKey, setCatalogItemsByKey] = React.useState>(() => new Map()); + const [isLoading, setIsLoading] = React.useState(true); + const [error, setError] = React.useState(); + const catalogItemsCacheRef = React.useRef>(new Map()); + + const neededCatalogItemIdsKey = getNeededCatalogItemIdsKey(ids); + + React.useEffect(() => { + let cancelled = false; + + const cache = catalogItemsCacheRef.current; + + const publishCache = (nextError?: unknown) => { + setCatalogItemsByKey(new Map(cache)); + setError(nextError); + setIsLoading(false); + }; + + if (neededCatalogItemIdsKey === null) { + publishCache(new Error('Catalog item ids are invalid')); + return; + } + + const neededIds = JSON.parse(neededCatalogItemIdsKey) as CatalogItemId[]; + const neededKeys = new Set(neededIds.map(catalogItemCacheKey)); + + for (const key of [...cache.keys()]) { + if (!neededKeys.has(key)) { + cache.delete(key); + } + } + + const missingIds = neededIds.filter((id) => !cache.has(catalogItemCacheKey(id))); + + if (neededIds.length === 0) { + publishCache(undefined); + return; + } + + if (missingIds.length === 0) { + publishCache(undefined); + return; + } + + setIsLoading(true); + + (async () => { + const results = await Promise.allSettled( + missingIds.map((id) => + fetchGet(catalogItemEndpoint(id)).then((value) => ({ + key: catalogItemCacheKey(id), + value, + })), + ), + ); + if (cancelled) { + return; + } + + let firstError: unknown; + results.forEach((r, idx) => { + if (r.status === 'rejected') { + // eslint-disable-next-line no-console + console.warn(`Failed to fetch catalog item ${missingIds[idx].catalog}/${missingIds[idx].item}`); + if (firstError === undefined) { + firstError = r.reason; + } + } else { + cache.set(r.value.key, r.value.value); + } + }); + publishCache(firstError); + })(); + + return () => { + cancelled = true; + }; + }, [neededCatalogItemIdsKey, fetchGet]); + + const getItem = React.useCallback( + (catalog: string, item: string) => catalogItemsByKey.get(catalogItemCacheKey({ catalog, item })), + [catalogItemsByKey], + ); + + return { getItem, isLoading, error }; +}; diff --git a/libs/ui-components/src/components/Catalog/useResolvedCatalogRef.ts b/libs/ui-components/src/components/Catalog/useResolvedCatalogRef.ts new file mode 100644 index 0000000000..d34e7de498 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/useResolvedCatalogRef.ts @@ -0,0 +1,43 @@ +import * as React from 'react'; +import type { CatalogItemRefSpec } from '@flightctl/types'; +import type { CatalogItem, CatalogItemVersion } from '@flightctl/types/alpha'; + +import { type ResolvedCatalogRef, resolveCatalogRef, toCatalogItemId } from '../../utils/catalog'; +import { useOptionalCatalogItemsContext } from './CatalogItemsContext'; +import { useCatalogItemsLookup } from './useCatalogItemsLookup'; + +export type UseResolvedCatalogRefResult = { + item: CatalogItem | undefined; + version: CatalogItemVersion | undefined; + channel: string; + imageUri: string | undefined; + isLoading: boolean; + error?: unknown; +}; + +/** + * Resolves one catalogItemRef to display label and optional OCI URI. + * Uses CatalogItemsProvider when present; otherwise fetches locally. + */ +export const useResolvedCatalogRef = (ref: CatalogItemRefSpec | undefined): UseResolvedCatalogRefResult | undefined => { + const contextLookup = useOptionalCatalogItemsContext(); + const localIds = React.useMemo(() => (!contextLookup && ref ? [toCatalogItemId(ref)] : []), [contextLookup, ref]); + const localLookup = useCatalogItemsLookup(localIds); + + if (!ref) { + return undefined; + } + + const lookup = contextLookup ?? localLookup; + const item = lookup.getItem(ref.catalog, ref.item); + const resolved: ResolvedCatalogRef | undefined = item ? resolveCatalogRef(item, ref) : undefined; + + return { + item, + version: resolved?.version, + channel: resolved?.channel || ref.channel || '', + imageUri: resolved?.imageUri, + isLoading: lookup.isLoading, + error: lookup.error, + }; +}; diff --git a/libs/ui-components/src/components/Catalog/useSpecCatalogItems.ts b/libs/ui-components/src/components/Catalog/useSpecCatalogItems.ts new file mode 100644 index 0000000000..1a3ad9ffb7 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/useSpecCatalogItems.ts @@ -0,0 +1,97 @@ +import * as React from 'react'; +import type { ApplicationProviderSpec, CatalogItemRefSpec, DeviceSpec } from '@flightctl/types'; +import type { CatalogItem, CatalogItemVersion } from '@flightctl/types/alpha'; + +import { + type CatalogItemId, + extractCatalogItemIdsFromSpec, + getAppCatalogItemRef, + resolveCatalogRef, +} from '../../utils/catalog'; +import { useOptionalCatalogItemsContext } from './CatalogItemsContext'; +import { useCatalogItemsLookup } from './useCatalogItemsLookup'; + +export type ResolvedCatalogItemData = { + item: CatalogItem; + version: CatalogItemVersion | undefined; + channel: string; +}; + +export type SpecOsCatalogItem = { + ref: CatalogItemRefSpec; + data?: ResolvedCatalogItemData & { imageUri?: string }; +}; + +export type SpecAppCatalogItem = { + ref: CatalogItemRefSpec; + appName: string; + data?: ResolvedCatalogItemData; +}; + +export type UseSpecCatalogItemsResult = { + isLoading: boolean; + error?: unknown; + os?: SpecOsCatalogItem; + apps: SpecAppCatalogItem[]; +}; + +/** + * Resolves all catalog-backed OS and applications on a Device/Fleet template spec. + */ +export const useSpecCatalogItems = (spec: DeviceSpec | undefined): UseSpecCatalogItemsResult => { + const contextLookup = useOptionalCatalogItemsContext(); + const ids = React.useMemo(() => extractCatalogItemIdsFromSpec(spec), [spec]); + const localIds = React.useMemo((): CatalogItemId[] => (contextLookup ? [] : ids), [contextLookup, ids]); + const localLookup = useCatalogItemsLookup(localIds); + const lookup = contextLookup ?? localLookup; + + const osRef = spec?.os?.catalogItemRef; + let os: SpecOsCatalogItem | undefined; + if (osRef) { + const item = lookup.getItem(osRef.catalog, osRef.item); + let osData: SpecOsCatalogItem['data'] | undefined; + if (item) { + const resolved = resolveCatalogRef(item, osRef); + osData = { + item, + version: resolved.version, + channel: resolved.channel, + imageUri: resolved.imageUri, + }; + } + os = { + ref: osRef, + data: osData, + }; + } + + const apps: SpecAppCatalogItem[] = []; + (spec?.applications || []).forEach((app: ApplicationProviderSpec) => { + const appRef = getAppCatalogItemRef(app); + if (!appRef || !app.name) { + return; + } + const item = lookup.getItem(appRef.catalog, appRef.item); + let appData: SpecAppCatalogItem['data'] | undefined; + if (item) { + const resolved = resolveCatalogRef(item, appRef); + appData = { + item, + version: resolved.version, + channel: resolved.channel, + }; + } + apps.push({ + appName: app.name, + ref: appRef, + data: appData, + }); + }); + + return { + isLoading: lookup.isLoading, + error: lookup.error, + os, + apps, + }; +}; diff --git a/libs/ui-components/src/components/Catalog/utils.ts b/libs/ui-components/src/components/Catalog/utils.ts deleted file mode 100644 index 20fdcbf754..0000000000 --- a/libs/ui-components/src/components/Catalog/utils.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { AppType, ApplicationProviderSpec, ContainerApplication, PatchRequest } from '@flightctl/types'; -import { - CatalogItem, - CatalogItemArtifact, - CatalogItemArtifactType, - CatalogItemCategory, - CatalogItemType, - CatalogItemVersion, -} from '@flightctl/types/alpha'; -import { TFunction } from 'i18next'; -import semver from 'semver'; - -import { appendJSONPatch, getLabelPatches } from '../../utils/patch'; -import { - APP_CATALOG_LABEL_KEY, - APP_CHANNEL_LABEL_KEY, - APP_ITEM_LABEL_KEY, - APP_VOLUME_CATALOG_LABEL_KEY, - APP_VOLUME_CHANNEL_LABEL_KEY, - APP_VOLUME_ITEM_LABEL_KEY, - OS_CATALOG_LABEL_KEY, - OS_CHANNEL_LABEL_KEY, - OS_ITEM_LABEL_KEY, - getAppVolumeName, -} from './const'; -import { fromAPILabel } from '../../utils/labels'; -import { AssetSelection } from '../DynamicForm/DynamicForm'; - -import appIcon from '../../../assets/application.svg'; -import osIcon from '../../../assets/os.svg'; - -const tagRegex = /^[\w][\w.-]{0,127}$/; - -export const getFullArtifactURI = (artifact: CatalogItemArtifact, version: CatalogItemVersion) => { - const versionRef = version.references[artifact.type]; - if (!versionRef) { - return undefined; - } - - // tag, nor digest can contain '/' - if (versionRef.includes('/')) { - return versionRef; - } - - if (tagRegex.test(versionRef)) { - return `${artifact.uri}:${versionRef}`; - } - - return `${artifact.uri}@${versionRef}`; -}; - -export const getFullContainerURI = (artifacts: CatalogItemArtifact[], version: CatalogItemVersion) => { - const containerArtifact = artifacts.find((a) => a.type === CatalogItemArtifactType.CatalogItemArtifactTypeContainer); - if (!containerArtifact) { - return undefined; - } - - return getFullArtifactURI(containerArtifact, version); -}; - -export const getCatalogItemBadge = (itemType: CatalogItemType | undefined, t: TFunction) => { - switch (itemType) { - case CatalogItemType.CatalogItemTypeCompose: { - return t('Compose'); - } - case CatalogItemType.CatalogItemTypeContainer: { - return t('Container'); - } - case CatalogItemType.CatalogItemTypeData: { - return t('Data'); - } - case CatalogItemType.CatalogItemTypeHelm: { - return t('Helm'); - } - case CatalogItemType.CatalogItemTypeQuadlet: { - return t('Quadlet'); - } - case CatalogItemType.CatalogItemTypeOS: { - return t('OS image'); - } - default: { - return t('Unknown'); - } - } -}; - -export const getRemoveOsPatches = ({ - specPath, - currentLabels, -}: { - specPath: string; - currentLabels: Record | undefined; -}) => { - const allPatches: PatchRequest = []; - allPatches.push({ - path: `${specPath}spec/os`, - op: 'remove', - }); - - const newLabels = currentLabels - ? { - ...currentLabels, - } - : {}; - delete newLabels[OS_ITEM_LABEL_KEY]; - delete newLabels[OS_CHANNEL_LABEL_KEY]; - delete newLabels[OS_CATALOG_LABEL_KEY]; - const labelPatches = getLabelPatches('/metadata/labels', currentLabels || {}, fromAPILabel(newLabels)); - - if (labelPatches.length) { - allPatches.push(...labelPatches); - } - - return allPatches; -}; - -const removeAppLabels = (currentLabels: Record, appName: string) => { - const apiLabels = fromAPILabel(currentLabels); - const newLabels = apiLabels.filter(({ key }) => { - return ( - ![ - `${appName}.${APP_ITEM_LABEL_KEY}`, - `${appName}.${APP_CHANNEL_LABEL_KEY}`, - `${appName}.${APP_CATALOG_LABEL_KEY}`, - ].includes(key) && - !( - key.startsWith(`${appName}.`) && - (key.endsWith(`.${APP_VOLUME_ITEM_LABEL_KEY}`) || - key.endsWith(`.${APP_VOLUME_CATALOG_LABEL_KEY}`) || - key.endsWith(`.${APP_VOLUME_CHANNEL_LABEL_KEY}`)) - ) - ); - }); - return newLabels; -}; - -export const getRemoveAppPatches = ({ - appName, - specPath, - currentLabels, - currentApps, -}: { - appName: string; - specPath: string; - currentLabels: Record | undefined; - currentApps: ApplicationProviderSpec[] | undefined; -}) => { - const allPatches: PatchRequest = []; - const appIndex = currentApps?.findIndex((a) => a.name === appName); - - if (currentApps?.length && appIndex !== -1) { - allPatches.push({ - path: `${specPath}spec/applications/${appIndex}`, - op: 'remove', - }); - } - - if (currentLabels) { - const newLabels = removeAppLabels(currentLabels, appName); - const labelPatches = getLabelPatches('/metadata/labels', currentLabels || {}, newLabels); - - if (labelPatches.length) { - allPatches.push(...labelPatches); - } - } - - return allPatches; -}; - -export const getOsPatches = ({ - currentOsImage, - currentLabels, - catalogItem, - catalogItemVersion, - channel, - specPath, -}: { - currentOsImage: string | undefined; - currentLabels: Record | undefined; - catalogItem: CatalogItem; - catalogItemVersion: CatalogItemVersion; - channel: string; - specPath: string; -}) => { - const allPatches: PatchRequest = []; - const newOsImage = getFullContainerURI(catalogItem.spec.artifacts, catalogItemVersion); - if (!currentOsImage) { - allPatches.push({ - path: `${specPath}spec/os`, - op: 'add', - value: { image: newOsImage }, - }); - } else if (currentOsImage !== newOsImage) { - appendJSONPatch({ - path: `${specPath}spec/os/image`, - patches: allPatches, - newValue: newOsImage, - originalValue: currentOsImage, - }); - } - - const newLabels = fromAPILabel({ - ...(currentLabels || {}), - [OS_CHANNEL_LABEL_KEY]: channel, - [OS_CATALOG_LABEL_KEY]: catalogItem.metadata.catalog, - [OS_ITEM_LABEL_KEY]: catalogItem.metadata.name || '', - }); - - const labelPatches = getLabelPatches('/metadata/labels', currentLabels || {}, newLabels); - - if (labelPatches.length) { - allPatches.push(...labelPatches); - } - - return allPatches; -}; - -const getAppType = (catalogItem: CatalogItem): AppType | undefined => { - switch (catalogItem.spec.type) { - case CatalogItemType.CatalogItemTypeCompose: - return AppType.AppTypeCompose; - case CatalogItemType.CatalogItemTypeQuadlet: - return AppType.AppTypeQuadlet; - case CatalogItemType.CatalogItemTypeHelm: - return AppType.AppTypeHelm; - case CatalogItemType.CatalogItemTypeContainer: - return AppType.AppTypeContainer; - default: - return undefined; - } -}; - -export const getAppPatches = ({ - appName, - currentApps, - currentLabels, - catalogItem, - catalogItemVersion, - channel, - formValues, - specPath, - selectedAssets, -}: { - appName: string; - currentApps: ApplicationProviderSpec[] | undefined; - currentLabels: Record | undefined; - catalogItem: CatalogItem; - catalogItemVersion: CatalogItemVersion; - channel: string; - formValues: Record | undefined; - specPath: string; - selectedAssets: AssetSelection[]; -}) => { - const allPatches: PatchRequest = []; - - const appType = getAppType(catalogItem); - if (!appType) { - throw new Error('Unknown application type'); - } - - const image = getFullContainerURI(catalogItem.spec.artifacts, catalogItemVersion); - if (!image) { - throw new Error(`Failed to create image uri for ${appName}`); - } - - const appSpec: ApplicationProviderSpec = { - ...formValues, - name: appName, - appType, - image, - }; - const existingAppIndex = currentApps?.findIndex((app) => app.name === appSpec.name); - - if (!currentApps) { - allPatches.push({ - path: `${specPath}spec/applications`, - op: 'add', - value: [appSpec], - }); - } else if (existingAppIndex === -1) { - allPatches.push({ - path: `${specPath}spec/applications/-`, - op: 'add', - value: appSpec, - }); - } else { - allPatches.push({ - path: `${specPath}spec/applications/${existingAppIndex}`, - op: 'replace', - value: appSpec, - }); - } - - const volumeLabels = selectedAssets.reduce((acc, { assetChannel, assetItemName, assetCatalog, volumeIndex }) => { - const volumes = (appSpec as ContainerApplication).volumes; - if (!volumes || volumes.length <= volumeIndex) { - return acc; - } - const volumeName = volumes[volumeIndex].name; - - return { - ...acc, - [`${getAppVolumeName(appSpec.name, volumeName, APP_VOLUME_ITEM_LABEL_KEY)}`]: assetItemName, - [`${getAppVolumeName(appSpec.name, volumeName, APP_VOLUME_CHANNEL_LABEL_KEY)}`]: assetChannel, - [`${getAppVolumeName(appSpec.name, volumeName, APP_VOLUME_CATALOG_LABEL_KEY)}`]: assetCatalog, - }; - }, {}); - - const newLabels = removeAppLabels(currentLabels || {}, appName); - const appLabels = fromAPILabel({ - [`${appSpec.name}.${APP_CHANNEL_LABEL_KEY}`]: channel, - [`${appSpec.name}.${APP_CATALOG_LABEL_KEY}`]: catalogItem.metadata.catalog, - [`${appSpec.name}.${APP_ITEM_LABEL_KEY}`]: catalogItem.metadata.name || '', - ...volumeLabels, - }); - - newLabels.push(...appLabels); - - const labelPatches = getLabelPatches('/metadata/labels', currentLabels || {}, newLabels); - - if (labelPatches.length) { - allPatches.push(...labelPatches); - } - - return allPatches; -}; - -export const getUpdates = (catalogItem: CatalogItem, currentChannel: string, currentVersion: string) => { - const updateVersions = catalogItem.spec.versions.filter((version) => { - if (!version.channels.includes(currentChannel)) return false; - - // Check if current version can upgrade to this version via: - // 1. replaces - direct replacement (now a single string) - if (version.replaces === currentVersion) return true; - - // 2. skips - array of specific versions that can be skipped - if (version.skips?.includes(currentVersion)) return true; - - // 3. skipRange - semver range check - if (version.skipRange && semver.satisfies(currentVersion, version.skipRange, { includePrerelease: true })) { - return true; - } - - return false; - }); - - // only versions which have container - return updateVersions.filter((v) => !!getFullContainerURI(catalogItem.spec.artifacts, v)); -}; - -export const getCatalogItemIcon = (catalogItem: CatalogItem): string => - catalogItem.spec.icon || - ((catalogItem.spec.category === CatalogItemCategory.CatalogItemCategorySystem ? osIcon : appIcon) as string); - -export const getArtifactLabel = (t: TFunction, type: CatalogItemArtifactType, name?: string) => { - let artifactType: CatalogItemArtifactType; - switch (type) { - case CatalogItemArtifactType.CatalogItemArtifactTypeQcow2: - artifactType = t('QCOW2'); - break; - case CatalogItemArtifactType.CatalogItemArtifactTypeIso: - artifactType = t('Bare Metal'); - break; - case CatalogItemArtifactType.CatalogItemArtifactTypeAmi: - artifactType = t('Amazon Web Services'); - break; - case CatalogItemArtifactType.CatalogItemArtifactTypeAnacondaIso: - artifactType = t('Anaconda Installer'); - break; - case CatalogItemArtifactType.CatalogItemArtifactTypeGce: - artifactType = t('Google Cloud'); - break; - case CatalogItemArtifactType.CatalogItemArtifactTypeRaw: - artifactType = t('KVM/custom cloud import'); - break; - case CatalogItemArtifactType.CatalogItemArtifactTypeVhd: - artifactType = t('Microsoft Hyper-V'); - break; - case CatalogItemArtifactType.CatalogItemArtifactTypeVmdk: - artifactType = t('VMware vSphere'); - break; - case CatalogItemArtifactType.CatalogItemArtifactTypeContainer: - artifactType = t('Cloud native'); - break; - case CatalogItemArtifactType.CatalogItemArtifactTypeQcow2DiskContainer: - artifactType = t('OpenShift Virtualization'); - } - - if (name) { - return `${name} (${type})`; - } - if (artifactType) { - return `${artifactType} (${type})`; - } - - return type; -}; diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsCatalog.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsCatalog.tsx index 61035525a0..bc2556c772 100644 --- a/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsCatalog.tsx +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsCatalog.tsx @@ -48,7 +48,6 @@ const DeviceDetailsCatalog = ({ device, refetch, canEdit }: DeviceDetailsCatalog canEdit={false} hasOwner hasPackageMode={hasPackageMode} - currentLabels={ownerFleet?.metadata?.labels} onPatch={async () => {}} spec={ownerFleet?.spec.template.spec} specPath="/spec/template" @@ -58,7 +57,6 @@ const DeviceDetailsCatalog = ({ device, refetch, canEdit }: DeviceDetailsCatalog ) : ( { } > {device && ( - - } /> - - {children} - - } - /> - {isEnrolled && ( + + + } /> } + path="details" + element={ + + {children} + + } /> - )} - } /> - } - /> - {canViewTerminal && } />} - {canViewLogs && } />} - } /> - + )} + + } + /> + {canViewTerminal && } />} + {canViewLogs && } />} + } /> + + )} {deleteModal || decommissionModal || resumeModal} diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsTabContent/ConfigurationsContent.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsTabContent/ConfigurationsContent.tsx index 0e41129e54..6fe8213afc 100644 --- a/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsTabContent/ConfigurationsContent.tsx +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsTabContent/ConfigurationsContent.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Alert, CardBody, CardTitle, Divider, Spinner, Stack, StackItem } from '@patternfly/react-core'; -import type { Device, Fleet } from '@flightctl/types'; +import type { Device, Fleet, ImageOrCatalogItemRefSpec } from '@flightctl/types'; import { useTranslation } from '../../../../hooks/useTranslation'; import { useDeviceOwnerFleet } from '../../../../hooks/useDeviceOwnerFleet'; import { hasPackageModeCapability } from '../../../../utils/capabilities'; @@ -32,12 +32,12 @@ const DevicePackageModeOsImage = () => { const DeviceRunningOsImage = ({ ownerFleetError, - specOsImage, + osSpec, statusOsImage, }: { ownerFleetError: boolean; statusOsImage: string | undefined; - specOsImage: string | undefined; + osSpec: ImageOrCatalogItemRefSpec | undefined; }) => { const { t } = useTranslation(); @@ -54,7 +54,7 @@ const DeviceRunningOsImage = ({ {t('System image (running)')} - + @@ -70,11 +70,12 @@ const DeviceOsImageCard = ({ ownerFleet?: Fleet; ownerFleetError: unknown; }) => { - const deviceSpec = ownerFleet?.spec?.template?.spec || device.spec; + const osSpec = ownerFleet?.spec?.template?.spec?.os || device.spec?.os; + const hasImageInSpec = osSpec?.image || osSpec?.catalogItemRef; const isPackageMode = hasPackageModeCapability(device); const showPackageModeInfo = isPackageMode && !ownerFleetError; - if (showPackageModeInfo && !deviceSpec?.os?.image) { + if (showPackageModeInfo && !hasImageInSpec) { // There is no conflict since the device can fully satisfy its fleet spec return null; } @@ -87,7 +88,7 @@ const DeviceOsImageCard = ({ content = ( ); diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceOs.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceOs.tsx index ca07f84773..b0b510f75f 100644 --- a/libs/ui-components/src/components/Device/DeviceDetails/DeviceOs.tsx +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceOs.tsx @@ -1,39 +1,68 @@ import * as React from 'react'; -import { Icon, Popover, PopoverPosition } from '@patternfly/react-core'; +import { Icon, Popover, PopoverPosition, Stack, StackItem } from '@patternfly/react-core'; import ExclamationTriangleIcon from '@patternfly/react-icons/dist/js/icons/exclamation-triangle-icon'; +import type { CatalogItemRefSpec, ImageOrCatalogItemRefSpec } from '@flightctl/types'; import { useTranslation } from '../../../hooks/useTranslation'; +import { useSystemImage } from '../EditDeviceWizard/useSystemImage'; +import { SystemImageCatalogLabel } from '../EditDeviceWizard/SystemImageDescriptionGroup'; +import { formatCatalogItemRef } from '../../../utils/catalog'; + +const RenderedOsImage = ({ isFromCatalog, image }: { isFromCatalog: boolean; image: string | undefined }) => { + const { t } = useTranslation(); + + const label = image || t('Running system image is unknown'); + + return ( + + {label} {isFromCatalog && } + + ); +}; const DeviceOs = ({ - desiredOsImage, + osSpec, renderedOsImage, }: { - desiredOsImage: string | undefined; + osSpec: ImageOrCatalogItemRefSpec | undefined; renderedOsImage: string | undefined; }) => { const { t } = useTranslation(); - if (!desiredOsImage && !renderedOsImage) { + const { imageUri: desiredOsImage, isLoading } = useSystemImage(osSpec); + + const hasCatalogRef = !!osSpec?.catalogItemRef; + const hasDesiredOsImage = Boolean(desiredOsImage || hasCatalogRef); + if (!hasDesiredOsImage && !renderedOsImage) { return null; } + const desiredOsImageContent = + desiredOsImage || (hasCatalogRef ? formatCatalogItemRef(osSpec?.catalogItemRef as CatalogItemRefSpec) : undefined); return (
- {renderedOsImage || t('Running system image is unknown')} - {desiredOsImage && desiredOsImage !== renderedOsImage && ( + + {hasDesiredOsImage && desiredOsImage !== renderedOsImage && !isLoading && ( + + {t('Desired system image')} + {hasCatalogRef && } + + {desiredOsImageContent} + + } withFocusTrap={false} > - <> - {' '} + - + )}
diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/EditDeviceWizard.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/EditDeviceWizard.tsx index 90b9bf4794..722b2e0f8f 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/EditDeviceWizard.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/EditDeviceWizard.tsx @@ -21,8 +21,10 @@ import { getEditDisabledReason } from '../../../utils/devices'; import { hasPackageModeCapability } from '../../../utils/capabilities'; import { useTranslation } from '../../../hooks/useTranslation'; import { Link, ROUTE, useNavigate } from '../../../hooks/useNavigate'; +import { extractCatalogItemIdsFromSpec } from '../../../utils/catalog'; import LeaveFormConfirmation from '../../common/LeaveFormConfirmation'; import ErrorBoundary from '../../common/ErrorBoundary'; +import { CatalogItemsProvider } from '../../Catalog/CatalogItemsContext'; import GeneralInfoStep, { generalInfoStepId, isGeneralInfoStepValid } from './steps/GeneralInfoStep'; import DeviceTemplateStep, { deviceTemplateStepId, isDeviceTemplateStepValid } from './steps/DeviceTemplateStep'; import DeviceUpdateStep, { deviceUpdatePolicyStepId, isUpdatePolicyStepValid } from './steps/DeviceUpdateStep'; @@ -99,87 +101,87 @@ const EditDeviceWizard = () => { const updatePolicyValues = getUpdatePolicyValues(device.spec?.updatePolicy); const isOsPackageMode = hasPackageModeCapability(device); body = ( - - initialValues={{ - deviceAlias, - osImage: device.spec?.os?.image || '', - labels: fromAPILabel(device.metadata.labels || {}).filter((label) => label.key !== 'alias'), - configTemplates: getConfigTemplatesValues(device.spec, registerMicroShift), - fleetMatch: '', // Initially this is always a fleetless device - applications: getApplicationValues(device.spec), - systemdUnits: getSystemdUnitsValues(device.spec), - registerMicroShift, - updatePolicy: { - ...updatePolicyValues, - isAdvanced: true, - }, - useBasicUpdateConfig: !updatePolicyValues.isAdvanced, - }} - validationSchema={getValidationSchema(t)} - validateOnMount - onSubmit={async (values) => { - setSubmitError(undefined); - try { - const patches = getDevicePatches(device, values); - if (patches.length > 0) { - await patch(`devices/${deviceId}`, patches); + + + initialValues={{ + deviceAlias, + osSpec: device.spec?.os, + labels: fromAPILabel(device.metadata.labels || {}).filter((label) => label.key !== 'alias'), + configTemplates: getConfigTemplatesValues(device.spec, registerMicroShift), + fleetMatch: '', // Initially this is always a fleetless device + applications: getApplicationValues(device.spec), + systemdUnits: getSystemdUnitsValues(device.spec), + registerMicroShift, + updatePolicy: { + ...updatePolicyValues, + isAdvanced: true, + }, + useBasicUpdateConfig: !updatePolicyValues.isAdvanced, + }} + validationSchema={getValidationSchema(t)} + validateOnMount + onSubmit={async (values) => { + setSubmitError(undefined); + try { + const patches = getDevicePatches(device, values); + if (patches.length > 0) { + await patch(`devices/${deviceId}`, patches); + } + navigate({ route: ROUTE.DEVICE_DETAILS, postfix: deviceId }); + } catch (e) { + setSubmitError(getErrorMessage(e)); } - navigate({ route: ROUTE.DEVICE_DETAILS, postfix: deviceId }); - } catch (e) { - setSubmitError(getErrorMessage(e)); - } - }} - > - {({ values, errors: formikErrors }) => { - const validStepIds = getValidStepIds(formikErrors); + }} + > + {({ values, errors: formikErrors }) => { + const validStepIds = getValidStepIds(formikErrors); - const isFleetless = !values.fleetMatch; + const isFleetless = !values.fleetMatch; - return ( - <> - - } - nav={} - onStepChange={() => { - if (submitError) { - setSubmitError(undefined); - } - }} - > - - - - + + } + nav={} + onStepChange={() => { + if (submitError) { + setSubmitError(undefined); + } + }} > - - - - - - - - - - - ); - }} - + + + + + + + + + + + + + + + ); + }} + + ); } diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/SystemImageDescriptionGroup.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/SystemImageDescriptionGroup.tsx new file mode 100644 index 0000000000..2b5c485a24 --- /dev/null +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/SystemImageDescriptionGroup.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + Label, + Spinner, +} from '@patternfly/react-core'; + +import type { CatalogItemRefSpec, ImageOrCatalogItemRefSpec } from '@flightctl/types'; +import { useTranslation } from '../../../hooks/useTranslation'; +import { formatCatalogItemRef } from '../../../utils/catalog'; +import { useSystemImage } from './useSystemImage'; + +export const SystemImageCatalogLabel = () => { + const { t } = useTranslation(); + return ( + + ); +}; + +export const SystemImageDisplay = ({ + catalogItemRef, + imageUri, +}: { + catalogItemRef: CatalogItemRefSpec | undefined; + imageUri?: string; +}) => { + if (catalogItemRef) { + return ( + + + + + {imageUri || formatCatalogItemRef(catalogItemRef)} + + ); + } + return imageUri || '-'; +}; + +const SystemImageDescriptionGroup = ({ + osSpec, + isFleet, +}: { + osSpec: ImageOrCatalogItemRefSpec | undefined; + isFleet: boolean; +}) => { + const { t } = useTranslation(); + const imageResult = useSystemImage(osSpec); + const isCatalogItemRef = osSpec?.catalogItemRef !== undefined; + + let osContent: React.ReactNode; + if (imageResult.isLoading) { + osContent = ; + } else if (imageResult.imageUri || isCatalogItemRef) { + osContent = imageResult.imageUri || formatCatalogItemRef(osSpec?.catalogItemRef as CatalogItemRefSpec); + } else { + osContent = isFleet ? t('The fleet will not manage system image') : t('The device will not manage system image'); + } + + return ( + + + {t('System image')} + {isCatalogItemRef && } + + {osContent} + + ); +}; + +export default SystemImageDescriptionGroup; diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts index caa3316829..f27580bce6 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts @@ -6,6 +6,7 @@ import { ApplicationResourceLimits, ApplicationVolume, ApplicationVolumeReclaimPolicy, + CatalogItemRefApplicationProviderSpec, ComposeApplication, ConfigProviderSpec, ContainerApplication, @@ -16,8 +17,9 @@ import { HelmApplication, HttpConfigProviderSpec, ImageApplicationProviderSpec, - ImageMountVolumeProviderSpec, + ImageOrCatalogItemRefSpec, ImagePullPolicy, + ImageVolumeSource, InlineApplicationProviderSpec, InlineConfigProviderSpec, KubernetesSecretProviderSpec, @@ -28,7 +30,6 @@ import { import { AppForm, AppSpecType, - ApplicationVolumeForm, ComposeAppForm, ConfigSourceProvider, ConfigType, @@ -50,7 +51,6 @@ import { isGitProviderSpec, isHttpConfigTemplate, isHttpProviderSpec, - isImageVariantApp, isInlineProviderSpec, isInlineVariantApp, isKubeProviderSpec, @@ -62,6 +62,7 @@ import { parseVmYamlForForm, vmYamlHasAdvancedSettings, } from '../../../utils/vmApplications'; +import { FullAppVolume, formVolumesToApi, toFormVolumes } from '../../../utils/volumes'; const DEFAULT_INLINE_FILE_MODE = 420; // In Octal: 0644 const DEFAULT_INLINE_FILE_USER = 'root'; @@ -247,30 +248,36 @@ const haveEnvVarsChanged = (current: Record, updated: Record current[key] !== updated[key]); }; +const hasVolumeImageChanged = ( + current: ImageVolumeSource | undefined, + updated: ImageVolumeSource | undefined, +): boolean => { + if (hasStringChanged(current?.pullPolicy, updated?.pullPolicy, ImagePullPolicy.PullIfNotPresent)) { + return true; + } + + if (hasStringChanged(current?.reference, updated?.reference)) { + return true; + } + return hasCatalogRefChanged(current, updated); +}; + const haveVolumesChanged = (current: ApplicationVolume[], updated: ApplicationVolume[]): boolean => { if (current.length !== updated.length) return true; - return current.some((currentVol, index) => { - const updatedVol = updated[index]; - if (currentVol.name !== updatedVol.name) return true; - if ( - (currentVol.reclaimPolicy || ApplicationVolumeReclaimPolicy.RETAIN) !== - (updatedVol.reclaimPolicy || ApplicationVolumeReclaimPolicy.RETAIN) - ) + return current.some((currentVolume, index) => { + const currentVol = currentVolume as FullAppVolume; + const updatedVol = updated[index] as FullAppVolume; + if (currentVol.name !== updatedVol.name) { return true; + } + if (hasStringChanged(currentVol.reclaimPolicy, updatedVol.reclaimPolicy, ApplicationVolumeReclaimPolicy.RETAIN)) { + return true; + } - const currentFull = currentVol as ApplicationVolume & ImageMountVolumeProviderSpec; - const updatedFull = updatedVol as ApplicationVolume & ImageMountVolumeProviderSpec; - const currentImageRef = currentFull.image?.reference || ''; - const updatedImageRef = updatedFull.image?.reference || ''; - if (currentImageRef !== updatedImageRef) return true; - if (currentImageRef || updatedImageRef) { - if ( - (currentFull.image?.pullPolicy || ImagePullPolicy.PullIfNotPresent) !== - (updatedFull.image?.pullPolicy || ImagePullPolicy.PullIfNotPresent) - ) - return true; + if (hasStringChanged(currentVol.mount?.path, updatedVol.mount?.path)) { + return true; } - return (currentFull.mount?.path || '') !== (updatedFull.mount?.path || ''); + return hasVolumeImageChanged(currentVol.image, updatedVol.image); }); }; @@ -311,24 +318,26 @@ const hasRunAsChanged = (current: string | undefined, updated: string | undefine return current !== updated; }; -// Single container apps always have an image, and it doesn't have an inline variant +// Single container apps always have an image or catalogItemRef, and it doesn't have an inline variant const hasContainerAppChanged = (current: ContainerApplication, updated: ContainerApplication): boolean => hasStringChanged(current.name, updated.name) || - hasStringChanged(current.image, updated.image) || + hasImageOrCatalogRefChanged(current, updated) || havePortsChanged(current.ports || [], updated.ports || []) || haveResourceLimitsChanged(current.resources?.limits, updated.resources?.limits) || haveEnvVarsChanged(current.envVars || {}, updated.envVars || {}) || hasRunAsChanged(current.runAs, updated.runAs) || haveVolumesChanged(current.volumes || [], updated.volumes || []); -// Helm apps always have an image (chart), and it doesn't have an inline variant -const hasHelmAppChanged = (current: HelmApplication, updated: HelmApplication): boolean => - hasStringChanged(current.name, updated.name) || - hasStringChanged(current.image, updated.image) || - hasStringChanged(current.namespace, updated.namespace) || - haveValuesFilesChanged(current.valuesFiles || [], updated.valuesFiles || []) || - haveHelmValuesChanged(current.values || {}, updated.values || {}); - +// Helm apps always have an image (chart) or catalogItemRef, and it doesn't have an inline variant +const hasHelmAppChanged = (current: HelmApplication, updated: HelmApplication): boolean => { + return ( + hasStringChanged(current.name, updated.name) || + hasImageOrCatalogRefChanged(current, updated) || + hasStringChanged(current.namespace, updated.namespace) || + haveValuesFilesChanged(current.valuesFiles || [], updated.valuesFiles || []) || + haveHelmValuesChanged(current.values || {}, updated.values || {}) + ); +}; const hasComposeAppChanged = ( current: ComposeApplication, updated: ComposeApplication, @@ -343,16 +352,13 @@ const hasComposeAppChanged = ( return true; } - if (specType === AppSpecType.OCI_IMAGE) { - return hasStringChanged( - (current as ImageApplicationProviderSpec).image, - (updated as ImageApplicationProviderSpec).image, + if (specType === AppSpecType.INLINE) { + return haveInlineFilesChanged( + (current as InlineApplicationProviderSpec).inline, + (updated as InlineApplicationProviderSpec).inline, ); } - return haveInlineFilesChanged( - (current as InlineApplicationProviderSpec).inline, - (updated as InlineApplicationProviderSpec).inline, - ); + return hasImageOrCatalogRefChanged(current as ImageOrCatalogItemRefSpec, updated as ImageOrCatalogItemRefSpec); }; // Quadlet apps are currently the same as Compose apps, plus an optional "runAs" field. @@ -368,6 +374,37 @@ const hasQuadletAppChanged = ( return hasRunAsChanged(current.runAs, updated.runAs); }; +const hasCatalogRefChanged = ( + current: ImageOrCatalogItemRefSpec | undefined, + updated: ImageOrCatalogItemRefSpec | undefined, +): boolean => { + const currentCatalogRef = current?.catalogItemRef; + const updatedCatalogRef = updated?.catalogItemRef; + + if (Boolean(currentCatalogRef) !== Boolean(updatedCatalogRef)) { + return true; + } + if (!currentCatalogRef || !updatedCatalogRef) { + return false; + } + return ( + hasStringChanged(currentCatalogRef.catalog, updatedCatalogRef.catalog) || + hasStringChanged(currentCatalogRef.item, updatedCatalogRef.item) || + hasStringChanged(currentCatalogRef.version, updatedCatalogRef.version) || + hasStringChanged(currentCatalogRef.channel, updatedCatalogRef.channel) + ); +}; + +const hasImageOrCatalogRefChanged = ( + current: ImageOrCatalogItemRefSpec | undefined, + updated: ImageOrCatalogItemRefSpec | undefined, +): boolean => { + if (hasStringChanged(current?.image, updated?.image)) { + return true; + } + return hasCatalogRefChanged(current, updated); +}; + const hasVmAppChanged = (current: VmApplication, updated: VmApplication): boolean => hasStringChanged(current.name, updated.name) || hasStringChanged(getVmYamlContent(current), getVmYamlContent(updated)) || @@ -378,11 +415,12 @@ const hasApplicationChanged = (current: ApplicationProviderSpec, updated: Applic return true; } - const currentSpectType = isImageVariantApp(current) ? AppSpecType.OCI_IMAGE : AppSpecType.INLINE; - const updatedSpectType = isImageVariantApp(updated) ? AppSpecType.OCI_IMAGE : AppSpecType.INLINE; + const currentSpectType = isInlineVariantApp(current) ? AppSpecType.INLINE : AppSpecType.OCI_IMAGE; + const updatedSpectType = isInlineVariantApp(updated) ? AppSpecType.INLINE : AppSpecType.OCI_IMAGE; if (currentSpectType !== updatedSpectType) { return true; } + switch (current.appType) { case AppType.AppTypeContainer: return hasContainerAppChanged(current as ContainerApplication, updated as ContainerApplication); @@ -412,29 +450,6 @@ const variablesToEnvVars = (variables: { name: string; value: string }[]) => { ); }; -/** - * Converts form volumes to API volumes, ignoring fields that are not allowed for the given app type. - * Quadlet/Compose apps --> can only be image volumes (mount is not allowed) - * Container apps --> can either be mount or image mount volumes - */ -const formVolumesToApi = (volumes: ApplicationVolumeForm[], appType: AppType): ApplicationVolume[] => { - return volumes.map((v) => { - const vol: Partial = { - name: v.name || '', - }; - if (v.imageRef) { - vol.image = { - reference: v.imageRef, - pullPolicy: v.imagePullPolicy || ImagePullPolicy.PullIfNotPresent, - }; - } - if (v.mountPath && appType === AppType.AppTypeContainer) { - vol.mount = { path: v.mountPath }; - } - return vol as ApplicationVolume; - }); -}; - const formFilesToApi = (files: InlineFileForm[]) => files.map((f) => ({ path: f.path, @@ -449,11 +464,24 @@ const toFormFiles = (files: ApplicationContent[]) => base64: file.contentEncoding === EncodingType.EncodingBase64, })); +const imageSpecToApi = ( + imageSpec: ImageOrCatalogItemRefSpec | undefined, +): Partial => { + if (imageSpec?.image) { + return { image: imageSpec.image }; + } + if (imageSpec?.catalogItemRef) { + return { catalogItemRef: imageSpec.catalogItemRef }; + } + return {}; +}; + const toApiHelmApp = (app: HelmAppForm): HelmApplication => { - const helmApp: HelmApplication = { - image: app.image, + const helmApp: Partial = { appType: app.appType, + ...imageSpecToApi(app.imageSpec), }; + if (app.name) { helmApp.name = app.name; } @@ -472,16 +500,17 @@ const toApiHelmApp = (app: HelmAppForm): HelmApplication => { if (fileNames.length > 0) { helmApp.valuesFiles = fileNames; } - return helmApp; + + return helmApp as HelmApplication; }; const toApiContainerApp = (app: SingleContainerAppForm): ContainerApplication => { - const containerApp: ContainerApplication = { - image: app.image, + const containerApp: Partial = { appType: app.appType, runAs: app.runAs || RUN_AS_ROOT_USER, envVars: variablesToEnvVars(app.variables || []), - volumes: formVolumesToApi(app.volumes || [], AppType.AppTypeContainer), + volumes: formVolumesToApi(app.volumes), + ...imageSpecToApi(app.imageSpec), }; if (app.name) { containerApp.name = app.name; @@ -503,24 +532,27 @@ const toApiContainerApp = (app: SingleContainerAppForm): ContainerApplication => containerApp.resources = { limits }; } - return containerApp; + + return containerApp as ContainerApplication; }; const toApiComposeApp = (app: ComposeAppForm): ComposeApplication => { - const formApp: Partial = { + const composeApp: Partial = { appType: app.appType, envVars: variablesToEnvVars(app.variables || []), - volumes: formVolumesToApi(app.volumes || [], app.appType), + volumes: formVolumesToApi(app.volumes), }; if (app.name) { - formApp.name = app.name; + composeApp.name = app.name; } + if (app.specType === AppSpecType.OCI_IMAGE) { - (formApp as ImageApplicationProviderSpec).image = app.image; + Object.assign(composeApp, imageSpecToApi(app.imageSpec)); } else { - (formApp as InlineApplicationProviderSpec).inline = formFilesToApi(app.files); + (composeApp as InlineApplicationProviderSpec).inline = formFilesToApi(app.files); } - return formApp as ComposeApplication; + + return composeApp as ComposeApplication; }; // Quadlet apps are currently the same as Compose apps, plus an optional "runAs" field. @@ -614,19 +646,6 @@ export const toApiApplication = (app: AppForm): ApplicationProviderSpec => { const toFormVariables = (envVars: Record): { name: string; value: string }[] => Object.entries(envVars).map(([name, value]) => ({ name, value: value || '' })); -const toFormVolumes = (volumes?: ApplicationVolume[]): ApplicationVolumeForm[] => { - if (!volumes) return []; - return volumes.map((vol) => { - const fullVolume = vol as ApplicationVolume & ImageMountVolumeProviderSpec; - return { - name: fullVolume.name, - imageRef: fullVolume.image?.reference || '', - mountPath: fullVolume.mount?.path || '', - imagePullPolicy: fullVolume.image?.pullPolicy || ImagePullPolicy.PullIfNotPresent, - }; - }); -}; - const createDefaultVmAppForm = (name: string = ''): VmAppForm => ({ appType: AppType.AppTypeVm, specType: AppSpecType.INLINE, @@ -706,7 +725,11 @@ export const getApplicationPatches = ( } else if (currentLen > 0 && newLen === 0) { patches.push({ path: `${basePath}/applications`, op: 'remove' }); } else if (currentLen !== newLen) { - patches.push({ path: `${basePath}/applications`, op: 'replace', value: updatedApps.map(toApiApplication) }); + patches.push({ + path: `${basePath}/applications`, + op: 'replace', + value: updatedApps.map(toApiApplication), + }); } else { currentApps.forEach((currentApp, index) => { const updatedApp = updatedApps[index]; @@ -723,6 +746,59 @@ export const getApplicationPatches = ( return patches; }; +/** + * Function that generates the patches to update the OS spec of a device/fleet, via the EditDevice/EditFleet form. + * + * Supported use cases: + * - Any modification when the spec uses an image (or it has an unset value): initially defining it, replacing it, or removing it + * + * Unsupported use cases: + * - Changing from Image to CatalogItemRef or vice versa + * - Modifying the catalogItemRef itself (must be done via the Catalog page) + * + * @param osPath - The path to the OS spec in the device/fleet spec + * @param currentOsSpec - The current OS spec in the device/fleet spec + * @param formOsSpec - The new OS spec in the device/fleet spec + * @returns The patches to update the OS spec in the device/fleet spec + */ +export const getFormOsSpecPatches = ( + osPath: string, + currentOsSpec: ImageOrCatalogItemRefSpec | undefined, + formOsSpec: ImageOrCatalogItemRefSpec | undefined, +): PatchRequest => { + if (currentOsSpec?.catalogItemRef) { + return []; + } + + const currentOsImage = currentOsSpec?.image; + const newOsImage = formOsSpec?.image; + const osChanged = hasStringChanged(currentOsImage, newOsImage); + if (!osChanged) { + return []; + } + + const patches: PatchRequest = []; + if (!currentOsImage && newOsImage) { + patches.push({ + path: osPath, + op: 'add', + value: { image: newOsImage }, + }); + } else if (!newOsImage && currentOsImage) { + patches.push({ + path: osPath, + op: 'remove', + }); + } else { + patches.push({ + path: `${osPath}/image`, + op: 'replace', + value: formOsSpec?.image, + }); + } + return patches; +}; + export const getApiConfig = (ct: SpecConfigTemplate): ConfigSourceProvider => { if (isGitConfigTemplate(ct)) { return { @@ -798,6 +874,19 @@ export const toFormPortMapping = (portStr: string): PortMapping => { return { hostPort: hostPort || '', targetPort: targetPort || '' }; }; +const getImageSpec = ( + app: ImageApplicationProviderSpec | CatalogItemRefApplicationProviderSpec | undefined, +): ImageOrCatalogItemRefSpec => { + if (!app) { + return { image: '' }; + } + if ('catalogItemRef' in app && app.catalogItemRef) { + return { catalogItemRef: app.catalogItemRef }; + } + const img = 'image' in app ? app.image : ''; + return { image: img }; +}; + const toContainerAppForm = (containerApp: ContainerApplication | undefined): SingleContainerAppForm => { const ports = containerApp?.ports?.map(toFormPortMapping) || []; const limits = containerApp?.resources?.limits; @@ -806,7 +895,7 @@ const toContainerAppForm = (containerApp: ContainerApplication | undefined): Sin appType: AppType.AppTypeContainer, specType: AppSpecType.OCI_IMAGE, name: containerApp?.name || '', - image: containerApp?.image || '', + imageSpec: getImageSpec(containerApp), variables: toFormVariables(containerApp?.envVars || {}), volumes: toFormVolumes(containerApp?.volumes), ports, @@ -826,7 +915,7 @@ const toHelmAppForm = (helmApp: HelmApplication | undefined): HelmAppForm => { appType: AppType.AppTypeHelm, specType: AppSpecType.OCI_IMAGE, name: helmApp?.name || '', - image: helmApp?.image || '', + imageSpec: getImageSpec(helmApp), namespace: helmApp?.namespace || '', valuesYaml, valuesFiles, @@ -836,7 +925,7 @@ const toHelmAppForm = (helmApp: HelmApplication | undefined): HelmAppForm => { const toComposeAppForm = (app: ComposeApplication | undefined): ComposeAppForm => { const isInlineVariant = app && isInlineVariantApp(app); const specType = isInlineVariant ? AppSpecType.INLINE : AppSpecType.OCI_IMAGE; - const formApp: Partial = { + const formApp: Partial = { appType: AppType.AppTypeCompose, specType, name: app?.name || '', @@ -847,9 +936,9 @@ const toComposeAppForm = (app: ComposeApplication | undefined): ComposeAppForm = // We want to have both fields initialized for the formik form if (isInlineVariant) { formApp.files = toFormFiles(app?.inline || []); - formApp.image = ''; + formApp.imageSpec = { image: '' }; } else { - formApp.image = app?.image || ''; + formApp.imageSpec = getImageSpec(app); formApp.files = []; } return formApp as ComposeAppForm; diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.css b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.css deleted file mode 100644 index 79a0780ec9..0000000000 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.css +++ /dev/null @@ -1,11 +0,0 @@ -/* -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/ApplicationContainerForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx index 7907b8c9a7..47657f75d2 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx @@ -7,8 +7,7 @@ import TextField from '../../../form/TextField'; import ApplicationPortMappingField from '../../../form/ApplicationPortMappingField'; import { useTranslation } from '../../../../hooks/useTranslation'; import { PortMapping, SingleContainerAppForm } from '../../../../types/deviceSpec'; - -import './ApplicationContainerForm.css'; +import ApplicationImageForm from './ApplicationImageForm'; const ApplicationContainerForm = ({ index, isReadOnly }: { index: number; isReadOnly?: boolean }) => { const { t } = useTranslation(); @@ -30,14 +29,8 @@ const ApplicationContainerForm = ({ index, isReadOnly }: { index: number; isRead > - - - + + { const { t } = useTranslation(); @@ -33,18 +34,12 @@ const ApplicationHelmForm = ({ index, isReadOnly }: { index: number; isReadOnly? isChecked /> - - - + /> {t('Fill in the existing values files before you can add more values files.')} - } + content={<>{t('Fill in the existing values files before you can add more values files.')}} > - - - )} - {ports.length > 0 && ( - <> - - {ports.map((port, portIndex) => { - const portText = formatPortText(port); - const isEditing = editingPortIndex === portIndex; - const hasError = isEditing && editingPortError; + let addedPortsContent: React.ReactNode; + if (ports.length > 0) { + addedPortsContent = ( + <> + + {ports.map((port, portIndex) => { + const portText = formatPortText(port); + const isEditing = editingPortIndex === portIndex; + const hasError = isEditing && editingPortError; + + const key = `${port.hostPort}_${port.targetPort}_${port.protocol ?? 'tcp'}_${portIndex}`; + if (isReadOnly) { return ( - - {editingPortError && editingPortIndex !== null && ( - - )} - + } + return ( + + ); + })} + + {editingPortError && editingPortIndex !== null && ( + + )} + + ); + } else if (isReadOnly) { + addedPortsContent = {t('None defined')}; + } + + const fieldContent = !isReadOnly ? ( + + + { + setHostPort(value); + setHostPortTouched(true); + }} + onBlur={() => setHostPortTouched(true)} + onKeyDown={handleKeyDown} + isDisabled={isReadOnly} + validated={hostPortError ? 'error' : 'default'} + /> + {hostPortError ? :
 
} +
+ + { + setTargetPort(value); + setTargetPortTouched(true); + }} + onBlur={() => setTargetPortTouched(true)} + onKeyDown={handleKeyDown} + isDisabled={isReadOnly} + validated={targetPortError ? 'error' : 'default'} + /> + {targetPortError ? :
 
} +
+ {withProtocol && ( + + + )} - - ); + + + +
+ ) : null; return ( - <> - {description} - {fieldContent} - + + {!isReadOnly && ( + <> + + {description} + + {fieldContent} + + )} + {addedPortsContent} + ); }; diff --git a/libs/ui-components/src/components/form/ImageOrCatalogRefField.tsx b/libs/ui-components/src/components/form/ImageOrCatalogRefField.tsx new file mode 100644 index 0000000000..6fc1c5a62d --- /dev/null +++ b/libs/ui-components/src/components/form/ImageOrCatalogRefField.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { useField } from 'formik'; +import { TextInput, type TextInputProps } from '@patternfly/react-core'; + +import type { ImageOrCatalogItemRefSpec } from '@flightctl/types'; +import { useTranslation } from '../../hooks/useTranslation'; +import { formatCatalogItemRef } from '../../utils/catalog'; +import { useResolvedCatalogRef } from '../Catalog/useResolvedCatalogRef'; +import { DefaultHelperText } from './FieldHelperText'; + +export interface ImageOrCatalogRefFieldProps extends TextInputProps { + name: string; + helperText?: React.ReactNode; +} + +// Field for an OCI image or catalog item reference +// Currently the Form only allows editing the image field. +// If the value is set as a catalog item reference, the field is read-only. +const ImageOrCatalogRefField = ({ name, helperText, ...props }: ImageOrCatalogRefFieldProps) => { + const { t } = useTranslation(); + const [field, meta, { setValue }] = useField({ + name, + }); + + const val = field.value; + const catalogRef = val?.catalogItemRef; + const fieldId = `textfield-${name}`; + + // Resolve the catalog item reference to get the image URI and the correct display value + const hasCatalogRef = !!catalogRef; + const resolved = useResolvedCatalogRef(catalogRef); + + const value = hasCatalogRef ? resolved?.imageUri : val?.image; + const isReadOnly = props.isDisabled || hasCatalogRef; + return ( + <> + void setValue({ image: value })} + readOnlyVariant={isReadOnly ? 'default' : undefined} + id={fieldId} + data-testid={fieldId} + validated={meta.touched && !!meta.error ? 'error' : 'default'} + /> + + + ); +}; + +export default ImageOrCatalogRefField; diff --git a/libs/ui-components/src/components/form/LabelsField.tsx b/libs/ui-components/src/components/form/LabelsField.tsx index a6aee661ea..0d30290dea 100644 --- a/libs/ui-components/src/components/form/LabelsField.tsx +++ b/libs/ui-components/src/components/form/LabelsField.tsx @@ -7,7 +7,6 @@ import EditableLabelControl from '../common/EditableLabelControl'; import LabelsView from '../common/LabelsView'; import { toAPILabel } from '../../utils/labels'; import ErrorHelperText, { DefaultHelperText } from './FieldHelperText'; -import { CATALOG_LABEL } from '../Catalog/const'; type LabelsFieldProps = { name: string; @@ -88,7 +87,6 @@ const LabelsField = ({ > {labels .map((label, originalIndex) => ({ ...label, originalIndex })) - .filter((l) => !l.key.includes(CATALOG_LABEL)) .map(({ key, value, originalIndex }) => { const text = value ? `${key}=${value}` : key; const elKey = `${key}__${originalIndex}`; diff --git a/libs/ui-components/src/components/form/validations.ts b/libs/ui-components/src/components/form/validations.ts index f22290e498..d2e0cee7ad 100644 --- a/libs/ui-components/src/components/form/validations.ts +++ b/libs/ui-components/src/components/form/validations.ts @@ -3,7 +3,7 @@ import { TFunction } from 'i18next'; import countBy from 'lodash/countBy'; import yaml from 'js-yaml'; -import { AppType, ImagePullPolicy } from '@flightctl/types'; +import { AppType, ImageOrCatalogItemRefSpec, ImagePullPolicy } from '@flightctl/types'; import { FlightCtlLabel } from '../../types/extraTypes'; import { AppForm, @@ -309,6 +309,20 @@ export const validOsImage = (t: TFunction, { isFleet }: { isFleet: boolean }) => }, ); +export const validOsFormValue = (t: TFunction, { isFleet }: { isFleet: boolean }) => + Yup.mixed().test('os-image-or-catalog', t('System image is invalid'), function (value) { + const osSpec = value as ImageOrCatalogItemRefSpec; + if (!osSpec || !osSpec.image || osSpec.catalogItemRef) { + return true; + } + try { + validOsImage(t, { isFleet }).validateSync(osSpec.image); + return true; + } catch (e) { + return this.createError({ message: (e as Yup.ValidationError).message || t('System image is invalid') }); + } + }); + export const validHelmNamespace = (t: TFunction) => genericNameSchema(t).max( GENERIC_NAME_MAX_LENGTH, @@ -767,8 +781,21 @@ const ociImageSchema = (t: TFunction) => return true; }); -const requiredOciImageSchema = (t: TFunction, requiredMessage?: string) => - ociImageSchema(t).required(requiredMessage || t('Image is required.')); +const imageSpecSchema = (t: TFunction, imageRequired: boolean, requiredMessage: string) => + Yup.mixed().test('image-or-catalog-ref', function (val) { + const value = val as ImageOrCatalogItemRefSpec; + if ((!value && !imageRequired) || value.catalogItemRef) { + // For now, CatalogItems cannot be edited via the UI, so if they are set they must be valid + return true; + } + try { + const schema = imageRequired ? ociImageSchema(t).required(requiredMessage) : ociImageSchema(t); + schema.validateSync(value.image); + return true; + } catch (e) { + return this.createError({ message: (e as Yup.ValidationError).message || requiredMessage }); + } + }); const volumeNameSchema = (t: TFunction) => validApplicationAndVolumeName(t).required(t('Volume name is required')); @@ -789,7 +816,7 @@ export const singleContainerVolumesSchema = (t: TFunction) => Yup.array().of( Yup.object().shape({ name: volumeNameSchema(t), - imageRef: ociImageSchema(t), + imageSpec: imageSpecSchema(t, false, ''), imagePullPolicy: Yup.string(), mountPath: mountPathSchema(t, true), }), @@ -799,7 +826,7 @@ export const composeQuadletVolumesSchema = (t: TFunction) => Yup.array().of( Yup.object().shape({ name: volumeNameSchema(t), - imageRef: requiredOciImageSchema(t, t('Image reference is required for this volume type')), + imageSpec: imageSpecSchema(t, true, t('Image reference is required for this volume type')), imagePullPolicy: imagePullPolicySchema(t), }), ); @@ -852,7 +879,7 @@ export const validApplicationsSchema = (t: TFunction) => { .required(t('Definition source must be image for this type of applications')), appType: Yup.string().oneOf([AppType.AppTypeContainer]).required(t('Application type is required')), name: validApplicationAndVolumeName(t), - image: requiredOciImageSchema(t), + imageSpec: imageSpecSchema(t, true, t('Image is required.')), ports: containerAppPortMappingSchema(t), cpuLimit: Yup.string().test( 'valid-cpu-format', @@ -878,7 +905,7 @@ export const validApplicationsSchema = (t: TFunction) => { .required(t('Definition source must be image for this type of applications')), appType: Yup.string().oneOf([AppType.AppTypeHelm]).required(t('Application type is required')), name: validApplicationAndVolumeName(t), - image: requiredOciImageSchema(t), + imageSpec: imageSpecSchema(t, true, t('Image is required.')), namespace: validHelmNamespace(t), valuesYaml: Yup.string().test('valid-yaml', t('YAML content is invalid.'), (value) => { if (!value || value.trim() === '') { @@ -952,7 +979,7 @@ export const validApplicationsSchema = (t: TFunction) => { }), diskImage: Yup.string().when('configMode', { is: 'form', - then: () => requiredOciImageSchema(t, t('Disk image is required for VM applications.')), + then: () => ociImageSchema(t).required(t('Disk image is required for VM applications.')), }), cpuCores: Yup.number().when('configMode', { is: 'form', @@ -1008,7 +1035,7 @@ export const validApplicationsSchema = (t: TFunction) => { }); } - // Image applications (Quadlet or Compose) + // Image applications (Quadlet or Compose) — OCI string or catalog ref if (value.specType === AppSpecType.OCI_IMAGE) { return Yup.object().shape({ specType: Yup.string() @@ -1018,7 +1045,7 @@ export const validApplicationsSchema = (t: TFunction) => { .oneOf([AppType.AppTypeCompose, AppType.AppTypeQuadlet]) .required(t('Application type is required')), name: validApplicationAndVolumeName(t), - image: requiredOciImageSchema(t), + imageSpec: imageSpecSchema(t, true, t('Image is required.')), volumes: composeQuadletVolumesSchema(t), variables: appVariablesSchema(t), }); diff --git a/libs/ui-components/src/hooks/useAppLinks.ts b/libs/ui-components/src/hooks/useAppLinks.ts index 1875dcb36e..22476ba2c9 100644 --- a/libs/ui-components/src/hooks/useAppLinks.ts +++ b/libs/ui-components/src/hooks/useAppLinks.ts @@ -3,10 +3,9 @@ import { useAppContext } from './useAppContext'; // Links to other flightctl upstream resources export const DEMO_REPOSITORY_URL = 'https://github.com/flightctl/flightctl-demos'; -export const RHEM_VERSION = '1.1'; +export const RHEM_VERSION = '1.2'; const baseUpstreamDocs = 'https://github.com/flightctl/flightctl/blob/main/docs'; -const baseDownstreamDocs = `https://docs.redhat.com/en/documentation/red_hat_edge_manager/${RHEM_VERSION}/html`; const upstreamLinks = { createApp: `${baseUpstreamDocs}/user/using/managing-devices.md#creating-applications`, @@ -14,16 +13,17 @@ const upstreamLinks = { addNewDevice: `${baseUpstreamDocs}/user/building/building-images.md#choosing-an-enrollment-method`, createAcmRepo: `${baseUpstreamDocs}/user/using/registering-microshift-devices-acm.md#auto-registering-devices-with-microshift-into-acm`, provisionDevice: `${baseUpstreamDocs}/user/using/provisioning-devices.md#provisioning-physical-devices`, - catalog: `https://github.com/flightctl/flightctl/blob/main/docs/user/using/managing-catalogs.md`, + catalog: `${baseUpstreamDocs}/user/using/managing-catalogs.md`, }; +const baseDownstreamDocs = `https://docs.redhat.com/en/documentation/red_hat_edge_manager/${RHEM_VERSION}/html`; const downstreamLinks = { - createApp: `${baseDownstreamDocs}/managing_applications_on_an_edge_device/build-app-packages`, - useTemplateVars: `${baseDownstreamDocs}/managing_device_fleets/device-fleets#device-templates`, - addNewDevice: `${baseDownstreamDocs}/operating_system_images_for_the_red_hat_edge_manager/edge-mgr-images#build-images-consider`, - createAcmRepo: `${baseDownstreamDocs}/managing_devices/manage-devices-intro#manage-git-repository`, - provisionDevice: `${baseDownstreamDocs}/provisioning_devices/provision-devices-intro`, - catalog: `${baseDownstreamDocs}/managing_devices/manage-devices-intro#software-catalog`, + createApp: `${baseDownstreamDocs}/managing_applications_on_an_edge_device/build-app-packages_managing-apps-edge-device`, + useTemplateVars: `${baseDownstreamDocs}/managing_device_fleets/device-fleets_managing-device-fleets`, + addNewDevice: `${baseDownstreamDocs}/operating_system_images_for_the_red_hat_edge_manager/edge-mgr-images_os-images-edge-manager#build-images-consider_os-images-edge-manager`, + createAcmRepo: `${baseDownstreamDocs}/managing_devices/devices-air-gapped#manage-git-repository_managing-devices`, + provisionDevice: `${baseDownstreamDocs}/provisioning_devices/provision-devices-intro_provisioning-devices`, + catalog: `${baseDownstreamDocs}/managing_devices/manage-devices-intro_managing-devices#software-catalog_managing-devices`, }; type AppLink = 'createApp' | 'useTemplateVars' | 'addNewDevice' | 'createAcmRepo' | 'provisionDevice' | 'catalog'; diff --git a/libs/ui-components/src/types/deviceSpec.ts b/libs/ui-components/src/types/deviceSpec.ts index 8cc829961d..1dbfa0c87c 100644 --- a/libs/ui-components/src/types/deviceSpec.ts +++ b/libs/ui-components/src/types/deviceSpec.ts @@ -1,6 +1,7 @@ import { AppType, ApplicationProviderSpec, + CatalogItemRefSpec, ComposeApplication, ConfigProviderSpec, ContainerApplication, @@ -9,7 +10,7 @@ import { HelmApplication, HttpConfigProviderSpec, ImageApplicationProviderSpec, - ImagePullPolicy, + ImageOrCatalogItemRefSpec, InlineApplicationProviderSpec, InlineConfigProviderSpec, KubernetesSecretProviderSpec, @@ -18,6 +19,8 @@ import { } from '@flightctl/types'; import { FlightCtlLabel } from './extraTypes'; import { UpdateScheduleMode } from '../utils/time'; +import { formatCatalogItemRef } from '../utils/catalog'; +import { ApplicationVolumeForm } from '../utils/volumes'; // At the moment the "root" user is the default user when no user is specified. export const RUN_AS_ROOT_USER = 'root'; @@ -68,6 +71,12 @@ export const isRepoConfig = (config: ConfigSourceProvider): config is RepoConfig export const isImageVariantApp = ( app: ApplicationProviderSpec, ): app is ApplicationProviderSpec & ImageApplicationProviderSpec => 'image' in app; + +export const isCatalogItemRefVariantApp = ( + app: ApplicationProviderSpec, +): app is ApplicationProviderSpec & { catalogItemRef: CatalogItemRefSpec } => + 'catalogItemRef' in app && !!(app as { catalogItemRef?: CatalogItemRefSpec }).catalogItemRef; + export const isInlineVariantApp = ( app: ApplicationProviderSpec, ): app is ApplicationProviderSpec & InlineApplicationProviderSpec => 'inline' in app; @@ -86,13 +95,6 @@ export const isComposeAppSpec = (app: ApplicationProviderSpec): app is ComposeAp export const isContainerAppSpec = (app: ApplicationProviderSpec): app is ContainerApplication => app.appType === AppType.AppTypeContainer; -export type ApplicationVolumeForm = { - name: string; - imageRef: string; - imagePullPolicy: ImagePullPolicy; - mountPath: string; -}; - export type PortMapping = { hostPort: string; targetPort: string; @@ -105,12 +107,13 @@ export type InlineFileForm = { path: string; content?: string; base64?: boolean type InlineOrImageVariantForm = { specType: AppSpecType; - image: string; + imageSpec: ImageOrCatalogItemRefSpec; files: InlineFileForm[]; }; export type SingleContainerAppForm = Omit & { specType: AppSpecType.OCI_IMAGE; + imageSpec: ImageOrCatalogItemRefSpec; ports: PortMapping[]; cpuLimit: string; memoryLimit: string; @@ -120,17 +123,18 @@ export type SingleContainerAppForm = Omit & { specType: AppSpecType.OCI_IMAGE; + imageSpec: ImageOrCatalogItemRefSpec; valuesYaml?: string; valuesFiles: string[]; }; -export type QuadletAppForm = Omit & +export type QuadletAppForm = Omit & InlineOrImageVariantForm & { variables: VariablesForm; volumes: ApplicationVolumeForm[]; }; -export type ComposeAppForm = Omit & +export type ComposeAppForm = Omit & InlineOrImageVariantForm & { variables: VariablesForm; volumes: ApplicationVolumeForm[]; @@ -164,9 +168,16 @@ export type AppForm = SingleContainerAppForm | HelmAppForm | QuadletAppForm | Co const hasTemplateVariables = (str: string) => /{{.+?}}/.test(str); -export const getAppIdentifier = (app: AppForm | ApplicationProviderSpec): string => { +export const isCatalogAppForm = (app: AppForm): boolean => + app.specType === AppSpecType.OCI_IMAGE && Boolean(app.imageSpec?.catalogItemRef); + +export const getAppIdentifier = (app: AppForm): string => { if (app.name) return app.name; - if ('image' in app && app.image) return app.image; + // Name is mandatory for all apps, except when the apps have an image which then becomes the ID. + if ('imageSpec' in app) { + const catalogItemRef = app.imageSpec.catalogItemRef; + return catalogItemRef ? formatCatalogItemRef(catalogItemRef) : app.imageSpec.image || ''; + } return ''; }; @@ -245,7 +256,7 @@ export type SystemdUnitFormValue = { }; export type DeviceSpecConfigFormValues = { - osImage?: string; + osSpec?: ImageOrCatalogItemRefSpec; configTemplates: SpecConfigTemplate[]; applications: AppForm[]; systemdUnits: SystemdUnitFormValue[]; diff --git a/libs/ui-components/src/utils/catalog.ts b/libs/ui-components/src/utils/catalog.ts new file mode 100644 index 0000000000..6c1e3b8f41 --- /dev/null +++ b/libs/ui-components/src/utils/catalog.ts @@ -0,0 +1,413 @@ +import { + AppType, + type ApplicationProviderSpec, + type ApplicationVolume, + type CatalogItemRefSpec, + type DeviceSpec, + type ImageMountVolumeProviderSpec, + type PatchRequest, +} from '@flightctl/types'; +import { + CatalogItem, + type CatalogItemArtifact, + CatalogItemArtifactType, + CatalogItemCategory, + CatalogItemType, + type CatalogItemVersion, +} from '@flightctl/types/alpha'; + +import { TFunction } from 'i18next'; +import semver from 'semver'; + +import { type FullAppVolume, buildApiVolume } from './volumes'; +import type { ArtifactFormValue } from '../components/Catalog/AddCatalogItemWizard/types'; +import { RUN_AS_ROOT_USER, isComposeAppSpec, isContainerAppSpec, isQuadletAppSpec } from '../types/deviceSpec'; + +import appIcon from '../../assets/application.svg'; +import osIcon from '../../assets/os.svg'; + +export type CatalogItemId = { catalog: string; item: string }; + +/** Tracks which volume slots use a catalog Data item. */ +export type VolumeCatalogSelection = { + volumeIndex: number; + catalogItemRef: CatalogItemRefSpec; +}; + +export type ResolvedCatalogRef = { + item: CatalogItem; + displayName: string; + version: CatalogItemVersion | undefined; + channel: string; + imageUri?: string; +}; + +export const getAppCatalogItemRef = (app: ApplicationProviderSpec): CatalogItemRefSpec | undefined => + 'catalogItemRef' in app ? app.catalogItemRef : undefined; + +export const catalogItemCacheKey = (id: CatalogItemId): string => `${id.catalog}\0${id.item}`; + +export const formatCatalogItemRef = (ref: CatalogItemRefSpec): string => `${ref.catalog}/${ref.item}:${ref.version}`; + +export const toCatalogItemId = (ref: Pick): CatalogItemId => ({ + catalog: ref.catalog, + item: ref.item, +}); + +export const extractCatalogItemIdsFromSpec = (spec: DeviceSpec | undefined): CatalogItemId[] => { + const byKey = new Map(); + const addRef = (ref: CatalogItemRefSpec | undefined) => { + if (!ref) { + return; + } + const id = toCatalogItemId(ref); + byKey.set(catalogItemCacheKey(id), id); + }; + + if (spec?.os?.catalogItemRef) { + addRef(spec.os.catalogItemRef); + } + (spec?.applications || []).forEach((app) => { + addRef(getAppCatalogItemRef(app)); + const volumes = 'volumes' in app ? (app.volumes as ImageMountVolumeProviderSpec[]) : undefined; + if (volumes) { + volumes.forEach((vol) => { + addRef(vol.image?.catalogItemRef); + }); + } + }); + return [...byKey.values()]; +}; + +export const getCurrentVersion = ( + catalogItem: CatalogItem, + version: string | undefined, + catalogRef: CatalogItemRefSpec | undefined, +) => { + const matchingVersion = version || catalogRef?.version; + return catalogItem.spec.versions.find((v) => v.version === matchingVersion); +}; + +export const buildCatalogItemRef = ({ + catalogItem, + catalogItemVersion, + channel, +}: { + catalogItem: CatalogItem; + catalogItemVersion: CatalogItemVersion; + channel: string; +}): CatalogItemRefSpec => { + const ref: CatalogItemRefSpec = { + catalog: catalogItem.metadata.catalog, + item: catalogItem.metadata.name || '', + version: catalogItemVersion.version, + }; + if (channel) { + ref.channel = channel; + } + return ref; +}; + +const tagRegex = /^[\w][\w.-]{0,127}$/; + +export const getFullArtifactURI = (artifact: CatalogItemArtifact, version: CatalogItemVersion) => { + const versionRef = version.references[artifact.type]; + if (!versionRef) { + return undefined; + } + + // tag, nor digest can contain '/' + if (versionRef.includes('/')) { + return versionRef; + } + + if (tagRegex.test(versionRef)) { + return `${artifact.uri}:${versionRef}`; + } + + return `${artifact.uri}@${versionRef}`; +}; + +export const getFullContainerURI = (artifacts: CatalogItemArtifact[], version: CatalogItemVersion) => { + const containerArtifact = artifacts.find((a) => a.type === CatalogItemArtifactType.CatalogItemArtifactTypeContainer); + if (!containerArtifact) { + return undefined; + } + + return getFullArtifactURI(containerArtifact, version); +}; + +export const resolveCatalogRef = (item: CatalogItem, ref: CatalogItemRefSpec): ResolvedCatalogRef => { + const version = getCurrentVersion(item, ref.version, ref); + const displayName = item.spec.displayName || item.metadata.name || ref.item; + const imageUri = version ? getFullContainerURI(item.spec.artifacts, version) : undefined; + return { + item, + displayName, + version, + channel: ref.channel || '', + imageUri, + }; +}; + +export const getCatalogItemBadge = (itemType: CatalogItemType | undefined, t: TFunction) => { + switch (itemType) { + case CatalogItemType.CatalogItemTypeCompose: { + return t('Compose'); + } + case CatalogItemType.CatalogItemTypeContainer: { + return t('Container'); + } + case CatalogItemType.CatalogItemTypeData: { + return t('Data'); + } + case CatalogItemType.CatalogItemTypeHelm: { + return t('Helm'); + } + case CatalogItemType.CatalogItemTypeQuadlet: { + return t('Quadlet'); + } + case CatalogItemType.CatalogItemTypeOS: { + return t('OS image'); + } + default: { + return t('Unknown'); + } + } +}; + +export const getRemoveOsPatches = ({ specPath }: { specPath: string }) => { + const allPatches: PatchRequest = []; + allPatches.push({ + path: `${specPath}spec/os`, + op: 'remove', + }); + return allPatches; +}; + +export const getRemoveAppPatches = ({ + appName, + specPath, + currentApps, +}: { + appName: string; + specPath: string; + currentApps: ApplicationProviderSpec[] | undefined; +}) => { + const allPatches: PatchRequest = []; + const appIndex = currentApps?.findIndex((a) => a.name === appName); + + if (currentApps?.length && appIndex !== -1) { + allPatches.push({ + path: `${specPath}spec/applications/${appIndex}`, + op: 'remove', + }); + } + + return allPatches; +}; + +const getAppType = (catalogItem: CatalogItem): AppType | undefined => { + switch (catalogItem.spec.type) { + case CatalogItemType.CatalogItemTypeCompose: + return AppType.AppTypeCompose; + case CatalogItemType.CatalogItemTypeQuadlet: + return AppType.AppTypeQuadlet; + case CatalogItemType.CatalogItemTypeHelm: + return AppType.AppTypeHelm; + case CatalogItemType.CatalogItemTypeContainer: + return AppType.AppTypeContainer; + default: + return undefined; + } +}; + +// Combines the form volumes with their selected Data catalog assets. +const getCatalogApiVolumes = ( + volumes: ApplicationVolume[] | undefined, + volumeSelection: VolumeCatalogSelection[], +): ApplicationVolume[] => { + if (!volumes?.length) { + return []; + } + + return volumes.map((v, idx) => { + const vol = v as FullAppVolume; + + // Ensure only one of catalogItemRef or image is set. + const selectedVolume = volumeSelection.find((a) => a.volumeIndex === idx); + const volumeImageSpec = selectedVolume + ? { catalogItemRef: selectedVolume.catalogItemRef } + : { image: vol.image?.reference || '' }; + return buildApiVolume(vol.name, volumeImageSpec, vol.image?.pullPolicy, vol.mount?.path || ''); + }); +}; + +/** + * RJSF leaves cleared optional string fields as "". + * The API rejects envVar with empty values, therefore these must be dropped. + */ +const sanitizeEnvVars = (envVars: unknown): Record | undefined => { + if (!envVars || typeof envVars !== 'object' || Array.isArray(envVars)) { + return undefined; + } + const cleaned = Object.fromEntries( + Object.entries(envVars as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0, + ), + ); + return Object.keys(cleaned).length > 0 ? cleaned : undefined; +}; + +/** + * RJSF leaves cleared optional string fields as "". + * The API rejects port with empty values, therefore these must be dropped. + */ +const sanitizePorts = (ports: unknown): string[] | undefined => { + if (!Array.isArray(ports)) { + return undefined; + } + const cleaned = ports.filter((p): p is string => typeof p === 'string' && p.length > 0); + return cleaned.length > 0 ? cleaned : undefined; +}; + +export const getAppPatches = ({ + appName, + currentApps, + catalogItem, + catalogItemVersion, + channel, + formValues, + specPath, + volumeSelection, +}: { + appName: string; + currentApps: ApplicationProviderSpec[] | undefined; + catalogItem: CatalogItem; + catalogItemVersion: CatalogItemVersion; + channel: string; + formValues: Record | undefined; + specPath: string; + volumeSelection: VolumeCatalogSelection[]; +}) => { + const appType = getAppType(catalogItem); + if (!appType) { + throw new Error('Unknown application type'); + } + + // Extract the fields that need to be processed before they can be submitted to the API. + const { volumes, envVars, ports, ...appFormValues } = formValues || {}; + + const userVolumes = Array.isArray(volumes) ? [...(volumes as ApplicationVolume[])] : undefined; + const sanitizedEnvVars = sanitizeEnvVars(envVars); + const sanitizedPorts = sanitizePorts(ports); + + const appSpec: ApplicationProviderSpec = { + ...appFormValues, + ...(sanitizedEnvVars ? { envVars: sanitizedEnvVars } : {}), + ...(sanitizedPorts ? { ports: sanitizedPorts } : {}), + name: appName, + appType, + catalogItemRef: buildCatalogItemRef({ catalogItem, catalogItemVersion, channel }), + // Explicitly clear image since the catalog item ref will be used instead + image: undefined, + }; + + // Force Container/quadlet apps to have a "runAs" field when the schema / user form did not include it. + const isContainerOrQuadletApp = isContainerAppSpec(appSpec) || isQuadletAppSpec(appSpec); + if (isContainerOrQuadletApp && !appSpec.runAs) { + appSpec.runAs = RUN_AS_ROOT_USER; + } + + // Only set volumes to the application types that support them. + if (isContainerOrQuadletApp || isComposeAppSpec(appSpec)) { + appSpec.volumes = getCatalogApiVolumes(userVolumes, volumeSelection); + } + + const existingAppIndex = currentApps?.findIndex((app) => app.name === appSpec.name); + + const allPatches: PatchRequest = []; + if (!currentApps) { + allPatches.push({ + path: `${specPath}spec/applications`, + op: 'add', + value: [appSpec], + }); + } else if (existingAppIndex === -1) { + allPatches.push({ + path: `${specPath}spec/applications/-`, + op: 'add', + value: appSpec, + }); + } else { + allPatches.push({ + path: `${specPath}spec/applications/${existingAppIndex}`, + op: 'replace', + value: appSpec, + }); + } + + return allPatches; +}; + +export const getUpdates = (catalogItem: CatalogItem, currentChannel: string, currentVersion: string) => { + const updateVersions = catalogItem.spec.versions.filter((version) => { + if (!version.channels.includes(currentChannel)) return false; + + // Check if current version can upgrade to this version via: + // 1. replaces - direct replacement (now a single string) + if (version.replaces === currentVersion) return true; + + // 2. skips - array of specific versions that can be skipped + if (version.skips?.includes(currentVersion)) return true; + + // 3. skipRange - semver range check + if (version.skipRange && semver.satisfies(currentVersion, version.skipRange, { includePrerelease: true })) { + return true; + } + + return false; + }); + + // only versions which have container + return updateVersions.filter((v) => !!getFullContainerURI(catalogItem.spec.artifacts, v)); +}; + +export const getCatalogItemIcon = (catalogItem: CatalogItem): string => + catalogItem.spec.icon || + ((catalogItem.spec.category === CatalogItemCategory.CatalogItemCategorySystem ? osIcon : appIcon) as string); + +export const getArtifactLabel = (t: TFunction, artifact: ArtifactFormValue | CatalogItemArtifact) => { + const { type, name } = artifact; + if (type === '') { + return name; + } + if (name) { + return `${name} (${type})`; + } + switch (type) { + case CatalogItemArtifactType.CatalogItemArtifactTypeQcow2: + return t('QCOW2 (qcow2)'); + case CatalogItemArtifactType.CatalogItemArtifactTypeIso: + return t('Bare Metal (iso)'); + case CatalogItemArtifactType.CatalogItemArtifactTypeAmi: + return t('Amazon Web Services (ami)'); + case CatalogItemArtifactType.CatalogItemArtifactTypeAnacondaIso: + return t('Anaconda Installer (anaconda-iso)'); + case CatalogItemArtifactType.CatalogItemArtifactTypeGce: + return t('Google Cloud (gce)'); + case CatalogItemArtifactType.CatalogItemArtifactTypeRaw: + return t('KVM/custom cloud import (raw)'); + case CatalogItemArtifactType.CatalogItemArtifactTypeVhd: + return t('Microsoft Hyper-V (vhd)'); + case CatalogItemArtifactType.CatalogItemArtifactTypeVmdk: + return t('VMware vSphere (vmdk)'); + case CatalogItemArtifactType.CatalogItemArtifactTypeContainer: + return t('Cloud native (container)'); + case CatalogItemArtifactType.CatalogItemArtifactTypeQcow2DiskContainer: + return t('OpenShift Virtualization (qcow2-disk-container)'); + default: { + return t('Unknown ({{ type }})', { type }); + } + } +}; diff --git a/libs/ui-components/src/utils/volumes.ts b/libs/ui-components/src/utils/volumes.ts new file mode 100644 index 0000000000..757d7eb8f3 --- /dev/null +++ b/libs/ui-components/src/utils/volumes.ts @@ -0,0 +1,64 @@ +import { + type ApplicationVolume, + type ImageMountVolumeProviderSpec, + type ImageOrCatalogItemRefSpec, + ImagePullPolicy, + type ImageVolumeSource, +} from '@flightctl/types'; + +// A full application volume includes the image mount volume provider spec +export type FullAppVolume = ApplicationVolume & ImageMountVolumeProviderSpec; + +export type ApplicationVolumeForm = { + name: string; + imageSpec: ImageOrCatalogItemRefSpec; + imagePullPolicy: ImagePullPolicy; + mountPath: string; +}; + +const toFormVolumeImageSpec = (image?: ImageVolumeSource): ImageOrCatalogItemRefSpec => { + if (image?.catalogItemRef) { + return { catalogItemRef: image.catalogItemRef }; + } + return { image: image?.reference || '' }; +}; + +export const buildApiVolume = ( + name: string, + imageSpec: ImageOrCatalogItemRefSpec, + imagePolicy: ImagePullPolicy | undefined, + mountPath: string | undefined, +): ApplicationVolume => { + const vol: Partial = { + name, + }; + + const catalogRef = imageSpec?.catalogItemRef; + const imageRef = imageSpec?.image; + if (catalogRef || imageRef) { + const pullPolicy = imagePolicy || ImagePullPolicy.PullIfNotPresent; + vol.image = catalogRef ? { catalogItemRef: catalogRef, pullPolicy } : { reference: imageRef, pullPolicy }; + } + if (mountPath) { + vol.mount = { path: mountPath }; + } + return vol as ApplicationVolume; +}; + +export const formVolumesToApi = (volumes: ApplicationVolumeForm[] | undefined): ApplicationVolume[] => { + if (!volumes) return []; + return volumes.map((v) => buildApiVolume(v.name || '', v.imageSpec, v.imagePullPolicy, v.mountPath)); +}; + +export const toFormVolumes = (volumes?: ApplicationVolume[]): ApplicationVolumeForm[] => { + if (!volumes) return []; + return volumes.map((vol) => { + const fullVolume = vol as FullAppVolume; + return { + name: fullVolume.name, + imageSpec: toFormVolumeImageSpec(fullVolume.image), + imagePullPolicy: fullVolume.image?.pullPolicy || ImagePullPolicy.PullIfNotPresent, + mountPath: fullVolume.mount?.path || '', + }; + }); +}; From 872f1f44efc6dc249dad24c387ac836dde530704 Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Tue, 4 Aug 2026 14:36:44 +0200 Subject: [PATCH 2/5] Review comments Made-with: Cursor --- .../components/Catalog/CatalogItemTitle.tsx | 4 ++-- .../Catalog/EditWizard/EditAppWizard.tsx | 6 ++--- .../Catalog/EditWizard/EditWizard.tsx | 2 +- .../Catalog/InstallWizard/InstallOsWizard.tsx | 21 +++++++++++++---- .../InstallWizard/steps/ReviewStep.tsx | 23 +++++++++++-------- .../InstallWizard/steps/SelectTargetStep.tsx | 9 ++++---- .../components/Catalog/InstallWizard/utils.ts | 6 ++--- .../Catalog/InstalledSoftwareItem.tsx | 2 +- .../EditDeviceWizard/deviceSpecUtils.ts | 2 +- .../components/DynamicForm/DynamicForm.tsx | 2 +- .../NewVersionImageBuildWizard/utils.ts | 12 ++++------ .../src/components/form/validations.ts | 5 +++- libs/ui-components/src/types/deviceSpec.ts | 2 +- 13 files changed, 56 insertions(+), 40 deletions(-) diff --git a/libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx b/libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx index b171a5c674..2f9ac52006 100644 --- a/libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx +++ b/libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { Button, Content, @@ -49,7 +49,7 @@ const CatalogTitleLayout = ({ icon, title, description, version, channel }: Cata
{description && ( - {description} + {description} )} {versionLine && ( diff --git a/libs/ui-components/src/components/Catalog/EditWizard/EditAppWizard.tsx b/libs/ui-components/src/components/Catalog/EditWizard/EditAppWizard.tsx index fac389bccf..c516964ca4 100644 --- a/libs/ui-components/src/components/Catalog/EditWizard/EditAppWizard.tsx +++ b/libs/ui-components/src/components/Catalog/EditWizard/EditAppWizard.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { Formik, FormikErrors, useFormikContext } from 'formik'; -import { Wizard, WizardStep, WizardStepType } from '@patternfly/react-core'; +import { Formik, type FormikErrors, useFormikContext } from 'formik'; +import { Wizard, WizardStep, type WizardStepType } from '@patternfly/react-core'; import * as Yup from 'yup'; -import { RJSFValidationError } from '@rjsf/utils'; +import type { RJSFValidationError } from '@rjsf/utils'; import semver from 'semver'; import type { ApplicationProviderSpec } from '@flightctl/types'; diff --git a/libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx b/libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx index 83bbc30d81..c6aa415bd8 100644 --- a/libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx +++ b/libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx @@ -131,7 +131,7 @@ const EditWizard = ({ await patch(`${isDevice ? 'devices' : 'fleets'}/${resourceId}`, allPatches); setIsSpecUnchanged(false); } else { - setIsSpecUnchanged(false); + setIsSpecUnchanged(true); } setIsSuccess(true); }} diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/InstallOsWizard.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/InstallOsWizard.tsx index df12bd7c12..2e26193289 100644 --- a/libs/ui-components/src/components/Catalog/InstallWizard/InstallOsWizard.tsx +++ b/libs/ui-components/src/components/Catalog/InstallWizard/InstallOsWizard.tsx @@ -1,10 +1,10 @@ -import { CatalogItem } from '@flightctl/types/alpha'; -import { Wizard, WizardStep, WizardStepType } from '@patternfly/react-core'; -import { Formik, FormikErrors, useFormikContext } from 'formik'; import * as React from 'react'; +import { Wizard, WizardStep, type WizardStepType } from '@patternfly/react-core'; +import { Formik, type FormikErrors, useFormikContext } from 'formik'; import * as Yup from 'yup'; -import { Device, Fleet, ImageOrCatalogItemRefSpec, PatchRequest } from '@flightctl/types'; +import type { Device, Fleet, ImageOrCatalogItemRefSpec, PatchRequest } from '@flightctl/types'; +import type { CatalogItem } from '@flightctl/types/alpha'; import { useTranslation } from '../../../hooks/useTranslation'; import { useFetch } from '../../../hooks/useFetch'; import { buildCatalogItemRef } from '../../../utils/catalog'; @@ -178,7 +178,8 @@ const InstallOsWizard = ({ catalogItem }: InstallOsWizardProps) => { return; } const installToDevice = values.target === 'device'; - const selectedResource = installToDevice ? (values.device as Device) : (values.fleet as Fleet); + const selectedResource = + values.target === 'device' ? values.device : values.target === 'fleet' ? values.fleet : undefined; if (!selectedResource) { setError(t('Deployment target not found for {{ target }}', { target: values.target })); return; @@ -189,6 +190,16 @@ const InstallOsWizard = ({ catalogItem }: InstallOsWizardProps) => { setError(t('Failed to find requested version {{ version }}', { version: values.version })); return; } + const isValidChannel = catalogItemVersion.channels.some((chan) => chan === values.channel); + if (!isValidChannel) { + setError( + t('Invalid channel {{ channel }} for version {{ version }}', { + channel: values.channel, + version: values.version, + }), + ); + return; + } const getResourceOs = async ( endpoint: string, diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/steps/ReviewStep.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/steps/ReviewStep.tsx index 0e037e6d58..e922ed8619 100644 --- a/libs/ui-components/src/components/Catalog/InstallWizard/steps/ReviewStep.tsx +++ b/libs/ui-components/src/components/Catalog/InstallWizard/steps/ReviewStep.tsx @@ -15,9 +15,9 @@ import { } from '@patternfly/react-core'; import { useFormikContext } from 'formik'; -import { type ImageOrCatalogItemRefSpec } from '@flightctl/types'; +import type { ImageOrCatalogItemRefSpec } from '@flightctl/types'; import { type CatalogItem, CatalogItemType } from '@flightctl/types/alpha'; -import { InstallAppFormik, InstallOsFormik } from '../types'; +import type { InstallAppFormik, InstallOsFormik } from '../types'; import { useTranslation } from '../../../../hooks/useTranslation'; import FlightCtlForm from '../../../form/FlightCtlForm'; @@ -36,7 +36,12 @@ const isOsUpdate = (catalogItem: CatalogItem, version: string, osSpec: ImageOrCa ); }; -const isOsUnchanged = (osSpec: ImageOrCatalogItemRefSpec | undefined, catalogItem: CatalogItem, version: string) => { +const isOsUnchanged = ( + osSpec: ImageOrCatalogItemRefSpec | undefined, + catalogItem: CatalogItem, + version: string, + channel: string, +) => { const osRef = osSpec?.catalogItemRef; if (!osRef) { return false; @@ -44,7 +49,8 @@ const isOsUnchanged = (osSpec: ImageOrCatalogItemRefSpec | undefined, catalogIte return ( osRef.item === catalogItem.metadata.name && osRef.catalog === catalogItem.metadata.catalog && - osRef.version === version + osRef.version === version && + osRef.channel === channel ); }; @@ -69,7 +75,7 @@ const UpdateOsUpdateAlerts = ({ catalogItem }: { catalogItem: CatalogItem }) => ); } - if (isOsUnchanged(resourceOs, catalogItem, values.version)) { + if (isOsUnchanged(resourceOs, catalogItem, values.version, values.channel)) { return ( @@ -114,7 +120,7 @@ const UpdateOsUpdateAlerts = ({ catalogItem }: { catalogItem: CatalogItem }) => ); } - if (isOsUnchanged(resourceOs, catalogItem, values.version)) { + if (isOsUnchanged(resourceOs, catalogItem, values.version, values.channel)) { return ( @@ -157,6 +163,7 @@ const ReviewStep = ({ error, catalogItem }: ReviewStepProps) => { const { values } = useFormikContext(); const isOsCatalogItem = catalogItem.spec.type === CatalogItemType.CatalogItemTypeOS; + const resourceMeta = values.target === 'fleet' ? values.fleet?.metadata : values.device?.metadata; return ( @@ -196,9 +203,7 @@ const ReviewStep = ({ error, catalogItem }: ReviewStepProps) => { {t('Target')} - {values.target === 'fleet' - ? values.fleet?.metadata.name - : values.device?.metadata.labels?.alias || values.device?.metadata.name} + {resourceMeta?.labels?.alias || resourceMeta?.name} diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/steps/SelectTargetStep.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/steps/SelectTargetStep.tsx index 47a1cd1f70..82ff9e03f2 100644 --- a/libs/ui-components/src/components/Catalog/InstallWizard/steps/SelectTargetStep.tsx +++ b/libs/ui-components/src/components/Catalog/InstallWizard/steps/SelectTargetStep.tsx @@ -1,4 +1,4 @@ -import { Device, Fleet, ImageOrCatalogItemRefSpec } from '@flightctl/types'; +import * as React from 'react'; import { Alert, Button, @@ -13,10 +13,11 @@ import { ToolbarItem, } from '@patternfly/react-core'; import { Tbody } from '@patternfly/react-table'; -import { FormikErrors, useFormikContext } from 'formik'; -import * as React from 'react'; +import { type FormikErrors, useFormikContext } from 'formik'; import ExternalLinkAltIcon from '@patternfly/react-icons/dist/js/icons/external-link-alt-icon'; -import { CatalogItem, CatalogItemType } from '@flightctl/types/alpha'; + +import type { Device, Fleet, ImageOrCatalogItemRefSpec } from '@flightctl/types'; +import { type CatalogItem, CatalogItemType } from '@flightctl/types/alpha'; import { useTranslation } from '../../../../hooks/useTranslation'; import Table from '../../../Table/Table'; diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/utils.ts b/libs/ui-components/src/components/Catalog/InstallWizard/utils.ts index 24636291af..6a6a58b29e 100644 --- a/libs/ui-components/src/components/Catalog/InstallWizard/utils.ts +++ b/libs/ui-components/src/components/Catalog/InstallWizard/utils.ts @@ -1,12 +1,12 @@ -import { CatalogItem } from '@flightctl/types/alpha'; import validator from '@rjsf/validator-ajv8'; import { createSchemaUtils } from '@rjsf/utils'; import merge from 'lodash/merge'; -import { FormikHelpers } from 'formik'; +import type { FormikHelpers } from 'formik'; import type { ApplicationProviderSpec, ImageMountVolumeProviderSpec } from '@flightctl/types'; +import type { CatalogItem } from '@flightctl/types/alpha'; import type { VolumeCatalogSelection } from '../../../utils/catalog'; -import { DynamicFormConfigFormik } from './types'; +import type { DynamicFormConfigFormik } from './types'; import { convertObjToYAMLString } from '../../common/CodeEditor/YamlEditor'; const appSpecFilteredKeys = ['name', 'appType', 'catalogItemRef']; diff --git a/libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx b/libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx index a4f116f025..44cfc54cc6 100644 --- a/libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx +++ b/libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx @@ -6,7 +6,7 @@ import { Button, Flex, FlexItem, Label, Popover, StackItem } from '@patternfly/r import { getUpdates } from '../../utils/catalog'; import { useTranslation } from '../../hooks/useTranslation'; import { buildAllDropdownActions } from '../common/ActionsDropdownList'; -import { ResolvedCatalogItemData, type SpecAppCatalogItem, type SpecOsCatalogItem } from './useSpecCatalogItems'; +import type { ResolvedCatalogItemData, SpecAppCatalogItem, SpecOsCatalogItem } from './useSpecCatalogItems'; import CatalogItemTitle, { BrokenCatalogItemTitle } from './CatalogItemTitle'; const isAppCatalogItem = (entry: SpecOsCatalogItem | SpecAppCatalogItem): entry is SpecAppCatalogItem => { diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts index f27580bce6..355c64d127 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts @@ -62,7 +62,7 @@ import { parseVmYamlForForm, vmYamlHasAdvancedSettings, } from '../../../utils/vmApplications'; -import { FullAppVolume, formVolumesToApi, toFormVolumes } from '../../../utils/volumes'; +import { type FullAppVolume, formVolumesToApi, toFormVolumes } from '../../../utils/volumes'; const DEFAULT_INLINE_FILE_MODE = 420; // In Octal: 0644 const DEFAULT_INLINE_FILE_USER = 'root'; diff --git a/libs/ui-components/src/components/DynamicForm/DynamicForm.tsx b/libs/ui-components/src/components/DynamicForm/DynamicForm.tsx index 71597e1bf2..6ce2f84356 100644 --- a/libs/ui-components/src/components/DynamicForm/DynamicForm.tsx +++ b/libs/ui-components/src/components/DynamicForm/DynamicForm.tsx @@ -21,7 +21,7 @@ import { PFObjectFieldTemplate, pfFields, } from './FieldTemplate'; -import { VolumeCatalogSelection } from '../../utils/catalog'; +import type { VolumeCatalogSelection } from '../../utils/catalog'; export type DynamicFormContext = { volumeSelection: VolumeCatalogSelection[]; diff --git a/libs/ui-components/src/components/ImageBuilds/NewVersionImageBuildWizard/utils.ts b/libs/ui-components/src/components/ImageBuilds/NewVersionImageBuildWizard/utils.ts index 371530db29..7443db1d60 100644 --- a/libs/ui-components/src/components/ImageBuilds/NewVersionImageBuildWizard/utils.ts +++ b/libs/ui-components/src/components/ImageBuilds/NewVersionImageBuildWizard/utils.ts @@ -35,14 +35,10 @@ export const getImagePromotion = (values: ImagePromotionFormValues, buildName: s displayName: optionalTrimmed(values.newItem.displayName), } as NewCatalogItemTarget; } else { - const skips = - values.existingItem.skips || [] - ? values.existingItem.skips - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - : []; - + const skips = (values.existingItem.skips || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); promotionTarget = { type: ExistingCatalogItemTarget.type.EXISTING_CATALOG_ITEM, catalogItemName: values.existingItem.name, diff --git a/libs/ui-components/src/components/form/validations.ts b/libs/ui-components/src/components/form/validations.ts index d2e0cee7ad..f3438d3b4f 100644 --- a/libs/ui-components/src/components/form/validations.ts +++ b/libs/ui-components/src/components/form/validations.ts @@ -784,7 +784,10 @@ const ociImageSchema = (t: TFunction) => const imageSpecSchema = (t: TFunction, imageRequired: boolean, requiredMessage: string) => Yup.mixed().test('image-or-catalog-ref', function (val) { const value = val as ImageOrCatalogItemRefSpec; - if ((!value && !imageRequired) || value.catalogItemRef) { + if (!value) { + return imageRequired ? this.createError({ message: requiredMessage }) : true; + } + if (value.catalogItemRef) { // For now, CatalogItems cannot be edited via the UI, so if they are set they must be valid return true; } diff --git a/libs/ui-components/src/types/deviceSpec.ts b/libs/ui-components/src/types/deviceSpec.ts index 1dbfa0c87c..a6a48ead43 100644 --- a/libs/ui-components/src/types/deviceSpec.ts +++ b/libs/ui-components/src/types/deviceSpec.ts @@ -20,7 +20,7 @@ import { import { FlightCtlLabel } from './extraTypes'; import { UpdateScheduleMode } from '../utils/time'; import { formatCatalogItemRef } from '../utils/catalog'; -import { ApplicationVolumeForm } from '../utils/volumes'; +import type { ApplicationVolumeForm } from '../utils/volumes'; // At the moment the "root" user is the default user when no user is specified. export const RUN_AS_ROOT_USER = 'root'; From 52fb32d871411068d0a23f0d80f161d27ec74b8b Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Tue, 4 Aug 2026 15:26:13 +0200 Subject: [PATCH 3/5] Prevent XSS attack for catalog item links Made-with: Cursor --- libs/i18n/locales/en/translation.json | 1 + .../src/components/Catalog/CatalogItemDetails.tsx | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 4ddfdbef60..4dfa43c208 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -455,6 +455,7 @@ "Failed to find requested version {{version}}": "Failed to find requested version {{version}}", "Deployment target not found for {{ target }}": "Deployment target not found for {{ target }}", "Failed to find requested version {{ version }}": "Failed to find requested version {{ version }}", + "Invalid channel {{ channel }} for version {{ version }}": "Invalid channel {{ channel }} for version {{ version }}", "Loading catalog item": "Loading catalog item", "Deploy {{name}}": "Deploy {{name}}", "Application name": "Application name", diff --git a/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx b/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx index 982a4ed2bf..0d8128c06d 100644 --- a/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx +++ b/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx @@ -161,11 +161,23 @@ const CatalogItemDetailsModal = ({ return null; }; +const isSafeHttpUrl = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + const CatalogItemPanelLink = ({ link }: { link: string | undefined }) => { const { t } = useTranslation(); if (!link) { return t('N/A'); } + if (!isSafeHttpUrl(link)) { + return link; + } return (