-
-
- A fully managed Kubernetes cluster will be deployed
-
- on your{' '}
- {match(currentProvider.short_name as Exclude)
- .with('AWS', () => 'AWS account (EKS)')
- .with('SCW', () => 'Scaleway account (Kapsule)')
- .with('AZURE', () => 'Azure account (AKS)')
- .with('GCP', () => 'GCP account (Autopilot GKE)')
- .exhaustive()}
-
-
- High-availability infrastructure deployed across multiple zones
- Customizable resources (in the next steps)
- No Kubernetes expertise required
-
+
+
+
+
+ A fully managed Kubernetes cluster will be deployed
+
+ on your{' '}
+ {match(currentProvider.short_name as Exclude)
+ .with('AWS', () => 'AWS account (EKS)')
+ .with('SCW', () => 'Scaleway account (Kapsule)')
+ .with('AZURE', () => 'Azure account (AKS)')
+ .with('GCP', () => 'GCP account (Autopilot GKE)')
+ .exhaustive()}
+
+
+ High-availability infrastructure deployed across multiple zones
+ Customizable resources (in the next steps)
+ No Kubernetes expertise required
+
+
-
-
+
-
-
+
+
-
-
Qovery manages this cluster for you
-
- Secure, stable, and optimized environment with continuous security patches and proactive
- health monitoring. Qovery performs weekly maintenance following a staged rollout to
- minimize risk.
-
+
+
Qovery manages this cluster for you
+
+ Secure, stable, and optimized environment with continuous security patches and proactive
+ health monitoring. Qovery performs weekly maintenance following a staged rollout to
+ minimize risk.
+
+
-
-
+
+ ) : null}
>
)}
>
diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-platform/step-platform.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-platform/step-platform.spec.tsx
new file mode 100644
index 00000000000..6eabb34a248
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-platform/step-platform.spec.tsx
@@ -0,0 +1,263 @@
+import {
+ CloudProviderEnum,
+ type PlatformCloudVendor,
+ type PlatformComponentConfigurationPreviewRequest,
+ type PlatformTemplateSummaryResponse,
+} from 'qovery-typescript-axios'
+import { type PropsWithChildren } from 'react'
+import selectEvent from 'react-select-event'
+import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests'
+import {
+ ClusterContainerCreateContext,
+ type ClusterContainerCreateContextInterface,
+ defaultResourcesData,
+} from '../cluster-creation-flow'
+import { StepPlatform } from './step-platform'
+
+const mockSetCurrentStep = jest.fn()
+const mockSetPlatformConfigurationData = jest.fn()
+const mockOnPrevious = jest.fn()
+const mockOnSubmit = jest.fn()
+const mockUsePlatformTemplates = jest.fn()
+const mockUsePlatformTemplateComponentConfiguration = jest.fn()
+
+interface ResolverHookProps {
+ componentKey?: string
+ cloudProvider?: PlatformCloudVendor
+ request: PlatformComponentConfigurationPreviewRequest
+}
+
+const mockTemplate = {
+ key: 'qovery-cluster-v0',
+ version: '0.1.0',
+ status: 'PUBLISHED',
+ layers: [
+ {
+ key: 'log-infrastructure',
+ mandatory: false,
+ enabledByDefault: true,
+ modes: ['CUSTOMER_MANAGED'],
+ providers: ['AWS'],
+ componentKeys: ['loki'],
+ components: [
+ {
+ key: 'loki',
+ kind: 'HELM',
+ fields: [
+ {
+ key: 'retention',
+ type: 'number',
+ required: true,
+ defaultValue: '4',
+ label: 'Retention period',
+ sensitive: false,
+ constraints: { min: 1, max: 52 },
+ },
+ {
+ key: 'highAvailability',
+ type: 'bool',
+ required: true,
+ defaultValue: 'false',
+ label: 'High availability',
+ sensitive: false,
+ constraints: {},
+ },
+ {
+ key: 'storage',
+ type: 'string',
+ required: true,
+ defaultValue: 'pvc',
+ label: 'Storage',
+ sensitive: false,
+ constraints: { allowedValues: ['pvc', 's3'] },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+} satisfies PlatformTemplateSummaryResponse
+
+const mockGcpTemplate = {
+ ...mockTemplate,
+ layers: mockTemplate.layers.map((layer) => ({
+ ...layer,
+ providers: ['GCP'],
+ components: layer.components.map((component) => ({
+ ...component,
+ fields: component.fields.map((field) =>
+ field.key === 'storage' ? { ...field, constraints: { allowedValues: ['pvc', 'gcs'] } } : field
+ ),
+ })),
+ })),
+} satisfies PlatformTemplateSummaryResponse
+
+function createMockContextValue(cloudProvider = CloudProviderEnum.AWS): ClusterContainerCreateContextInterface {
+ return {
+ currentStep: 2,
+ setCurrentStep: mockSetCurrentStep,
+ generalData: {
+ name: 'test-cluster',
+ cloud_provider: cloudProvider,
+ region: 'eu-west-3',
+ installation_type: 'SELF_MANAGED',
+ },
+ setGeneralData: jest.fn(),
+ resourcesData: defaultResourcesData,
+ setResourcesData: jest.fn(),
+ featuresData: { vpc_mode: undefined, features: {} },
+ setFeaturesData: jest.fn(),
+ kubeconfigData: undefined,
+ setKubeconfigData: jest.fn(),
+ addonsData: { kedaActivated: false, secretManagers: [] },
+ setAddonsData: jest.fn(),
+ platformConfigurationData: undefined,
+ setPlatformConfigurationData: mockSetPlatformConfigurationData,
+ isEngineV2SelfManaged: true,
+ creationFlowUrl: '/organization/org-123/cluster/create/aws-self-managed',
+ }
+}
+
+let mockContextValue = createMockContextValue()
+
+function Wrapper({ children }: PropsWithChildren) {
+ return (
+
{children}
+ )
+}
+
+jest.mock('../../platform-configuration/hooks/use-platform-template-component-configuration', () => ({
+ usePlatformTemplateComponentConfiguration: (props: unknown) => mockUsePlatformTemplateComponentConfiguration(props),
+}))
+
+jest.mock('../../platform-configuration/hooks/use-platform-templates', () => ({
+ usePlatformTemplates: (props: unknown) => mockUsePlatformTemplates(props),
+}))
+
+jest.mock('@qovery/shared/util-hooks', () => ({
+ ...jest.requireActual('@qovery/shared/util-hooks'),
+ useDebounce: (value: unknown) => value,
+}))
+
+describe('StepPlatform', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ jest.clearAllMocks()
+ mockContextValue = createMockContextValue()
+ mockUsePlatformTemplates.mockReturnValue({ data: [mockTemplate], isLoading: false, isError: false })
+ mockUsePlatformTemplateComponentConfiguration.mockImplementation(
+ ({ componentKey, cloudProvider, request }: ResolverHookProps) => {
+ const fields =
+ cloudProvider === 'GCP'
+ ? mockGcpTemplate.layers[0].components[0].fields
+ : mockTemplate.layers[0].components[0].fields
+ const requirements =
+ request.profileConfig?.storage === 'gcs'
+ ? [
+ {
+ key: 'infra.gcsBucketName',
+ type: 'string',
+ scope: 'CLUSTER',
+ label: 'GCS bucket name',
+ required: true,
+ sensitive: false,
+ constraints: {},
+ status: request.clusterInputs?.['infra.gcsBucketName'] ? 'READY' : 'MISSING',
+ },
+ {
+ key: 'infra.gcpServiceAccountEmail',
+ type: 'string',
+ scope: 'CLUSTER',
+ label: 'GCP service account email',
+ required: true,
+ sensitive: false,
+ constraints: {},
+ status: request.clusterInputs?.['infra.gcpServiceAccountEmail'] ? 'READY' : 'MISSING',
+ },
+ ]
+ : []
+
+ return {
+ data: componentKey
+ ? { componentKey, fields, requirements, componentBindings: [], violations: [] }
+ : undefined,
+ isError: false,
+ isFetching: false,
+ }
+ }
+ )
+ })
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers()
+ jest.useRealTimers()
+ jest.restoreAllMocks()
+ })
+
+ it('opens Loki configuration and stores edited values in the binding draft', async () => {
+ const { userEvent } = renderWithProviders(
+
,
+ { wrapper: Wrapper }
+ )
+
+ expect(mockUsePlatformTemplates).toHaveBeenCalledWith({
+ organizationId: 'org-123',
+ clusterMode: 'CUSTOMER_MANAGED',
+ cloudProvider: 'AWS',
+ })
+
+ await userEvent.click(screen.getByRole('button', { name: 'Loki HELM' }))
+
+ expect(screen.getByLabelText('High availability')).not.toBeChecked()
+ expect(screen.getByText('pvc')).toBeInTheDocument()
+
+ const retentionInput = screen.getByLabelText('Retention period')
+ await userEvent.clear(retentionInput)
+ await userEvent.type(retentionInput, '12')
+ const saveButton = screen.getByRole('button', { name: 'Save configuration' })
+ await waitFor(() => expect(saveButton).toBeEnabled())
+ await userEvent.click(saveButton)
+
+ expect(mockSetPlatformConfigurationData).toHaveBeenLastCalledWith({
+ templateKey: mockTemplate.key,
+ templateVersion: mockTemplate.version,
+ layerSelections: { 'log-infrastructure': true },
+ managedConfig: { loki: { retention: 12 } },
+ customerProvidedInputs: {},
+ })
+ })
+
+ it('displays and stores GCS runtime inputs selected during cluster creation', async () => {
+ mockContextValue = createMockContextValue(CloudProviderEnum.GCP)
+ mockUsePlatformTemplates.mockReturnValue({ data: [mockGcpTemplate], isLoading: false, isError: false })
+ const { userEvent } = renderWithProviders(
+
,
+ { wrapper: Wrapper }
+ )
+
+ await userEvent.click(screen.getByRole('button', { name: 'Loki HELM' }))
+ await selectEvent.select(screen.getByLabelText('Storage'), 'gcs')
+
+ const bucketInput = screen.getByLabelText('GCS bucket name')
+ const serviceAccountInput = screen.getByLabelText('GCP service account email')
+ await userEvent.type(bucketInput, 'qovery-loki-logs')
+ await userEvent.type(serviceAccountInput, 'loki@qovery.iam.gserviceaccount.com')
+
+ const saveButton = screen.getByRole('button', { name: 'Save configuration' })
+ await waitFor(() => expect(saveButton).toBeEnabled())
+ await userEvent.click(saveButton)
+
+ expect(mockSetPlatformConfigurationData).toHaveBeenLastCalledWith({
+ templateKey: mockGcpTemplate.key,
+ templateVersion: mockGcpTemplate.version,
+ layerSelections: { 'log-infrastructure': true },
+ managedConfig: { loki: { storage: 'gcs' } },
+ customerProvidedInputs: {
+ loki: {
+ 'infra.gcsBucketName': 'qovery-loki-logs',
+ 'infra.gcpServiceAccountEmail': 'loki@qovery.iam.gserviceaccount.com',
+ },
+ },
+ })
+ })
+})
diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-platform/step-platform.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-platform/step-platform.tsx
new file mode 100644
index 00000000000..2e1961cfc6f
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-platform/step-platform.tsx
@@ -0,0 +1,282 @@
+import { type ClusterPlatformBindingRequest } from 'qovery-typescript-axios'
+import { useEffect, useMemo, useState } from 'react'
+import { Button, Callout, FunnelFlowBody, Icon, LoaderSpinner, Section } from '@qovery/shared/ui'
+import { useDebounce } from '@qovery/shared/util-hooks'
+import { type CatalogVariableValue } from '@qovery/shared/util-js'
+import { usePlatformTemplateComponentConfiguration } from '../../platform-configuration/hooks/use-platform-template-component-configuration'
+import { usePlatformTemplates } from '../../platform-configuration/hooks/use-platform-templates'
+import { PlatformComponentConfiguration } from '../../platform-configuration/platform-component-configuration'
+import { PlatformConfigurationCatalog } from '../../platform-configuration/platform-configuration-catalog'
+import {
+ applyPlatformConfigurationDefaults,
+ createPlatformConfigurationDraft,
+ findPlatformComponent,
+ getCurrentPlatformConfigurationPreview,
+ omitEmptyValues,
+ toPlatformCloudVendor,
+ toPlatformConfigurationValue,
+} from '../../platform-configuration/platform-configuration-utils'
+import { steps, useClusterContainerCreateContext } from '../cluster-creation-flow'
+
+interface StepPlatformProps {
+ organizationId: string
+ onPrevious: () => void
+ onSubmit: () => void
+}
+
+interface ComponentDraft {
+ componentKey: string
+ managedConfig: Record
+ clusterInputs: Record
+}
+
+export function StepPlatform({ organizationId, onPrevious, onSubmit }: StepPlatformProps) {
+ const [componentDraft, setComponentDraft] = useState()
+ const {
+ generalData,
+ platformConfigurationData,
+ setCurrentStep,
+ setPlatformConfigurationData,
+ isEngineV2SelfManaged,
+ } = useClusterContainerCreateContext()
+ const cloudProvider = toPlatformCloudVendor(generalData?.cloud_provider)
+ const {
+ data: templates = [],
+ isLoading,
+ isError,
+ } = usePlatformTemplates({
+ organizationId,
+ clusterMode: cloudProvider ? 'CUSTOMER_MANAGED' : undefined,
+ cloudProvider,
+ })
+ const template =
+ templates.find(
+ (candidate) =>
+ candidate.key === platformConfigurationData?.templateKey &&
+ candidate.version === platformConfigurationData.templateVersion
+ ) ?? templates[0]
+ const draft = template
+ ? platformConfigurationData?.templateKey === template.key &&
+ platformConfigurationData.templateVersion === template.version
+ ? platformConfigurationData
+ : createPlatformConfigurationDraft(template, null)
+ : undefined
+ const selectedComponent = template ? findPlatformComponent(template, componentDraft?.componentKey) : undefined
+ // profileConfig drives the form display and may contain '' for fields the user
+ // explicitly cleared; the resolver request must omit those so a cleared required
+ // field surfaces as a violation instead of silently resurrecting its default.
+ const { profileConfig, clusterInputs } = useMemo(
+ () => ({
+ profileConfig: selectedComponent
+ ? applyPlatformConfigurationDefaults(selectedComponent.fields, componentDraft?.managedConfig ?? {})
+ : {},
+ clusterInputs: componentDraft?.clusterInputs ?? {},
+ }),
+ [componentDraft?.clusterInputs, componentDraft?.managedConfig, selectedComponent]
+ )
+ const previewRequest = useMemo(
+ () => ({
+ profileConfig: omitEmptyValues(profileConfig),
+ clusterInputs,
+ componentOutputs: {},
+ }),
+ [profileConfig, clusterInputs]
+ )
+ const previewQuery = useMemo(
+ () => ({ componentKey: selectedComponent?.key, request: previewRequest }),
+ [previewRequest, selectedComponent?.key]
+ )
+ const debouncedPreviewQuery = useDebounce(previewQuery, 300)
+ const isPreviewPending = debouncedPreviewQuery !== previewQuery
+ const {
+ data: previewData,
+ isError: hasPreviewError,
+ isFetching,
+ } = usePlatformTemplateComponentConfiguration({
+ organizationId,
+ templateKey: template?.key,
+ templateVersion: template?.version,
+ componentKey: debouncedPreviewQuery.componentKey,
+ clusterMode: 'CUSTOMER_MANAGED',
+ cloudProvider,
+ request: debouncedPreviewQuery.request,
+ enabled: Boolean(selectedComponent) && debouncedPreviewQuery.componentKey === selectedComponent?.key,
+ })
+ const preview = getCurrentPlatformConfigurationPreview(previewData, selectedComponent?.key, isPreviewPending)
+
+ useEffect(() => {
+ const stepIndex = steps(generalData, isEngineV2SelfManaged).findIndex((step) => step.key === 'platform') + 1
+ if (stepIndex > 0) setCurrentStep(stepIndex)
+ }, [generalData, isEngineV2SelfManaged, setCurrentStep])
+
+ const updateDraft = (update: (current: ClusterPlatformBindingRequest) => ClusterPlatformBindingRequest) => {
+ if (!draft || !setPlatformConfigurationData) return
+ setPlatformConfigurationData(update(draft))
+ }
+
+ const handleSubmit = () => {
+ if (!draft || !setPlatformConfigurationData) return
+ setPlatformConfigurationData(draft)
+ onSubmit()
+ }
+
+ const updateProfileConfig = (fieldKey: string, value: CatalogVariableValue) => {
+ if (!selectedComponent || !componentDraft) return
+
+ const field = (preview?.fields ?? selectedComponent.fields).find((candidate) => candidate.key === fieldKey)
+ if (!field) return
+
+ const fieldValue = toPlatformConfigurationValue(field, value)
+ setComponentDraft((current) => {
+ if (!current) return current
+
+ const managedConfig = { ...current.managedConfig }
+ if (fieldValue === undefined) {
+ delete managedConfig[fieldKey]
+ } else {
+ managedConfig[fieldKey] = fieldValue
+ }
+ return { ...current, managedConfig }
+ })
+ }
+
+ const updateClusterInput = (fieldKey: string, value: CatalogVariableValue) => {
+ if (!componentDraft) return
+
+ setComponentDraft((current) => {
+ if (!current) return current
+
+ const nextClusterInputs = { ...current.clusterInputs }
+ if (value === undefined) {
+ delete nextClusterInputs[fieldKey]
+ } else {
+ nextClusterInputs[fieldKey] = String(value)
+ }
+ return { ...current, clusterInputs: nextClusterInputs }
+ })
+ }
+
+ const saveComponentConfiguration = () => {
+ if (!componentDraft || !preview) return
+
+ const activeClusterInputs: Record = Object.fromEntries(
+ preview.requirements.flatMap((requirement) => {
+ const value = componentDraft.clusterInputs[requirement.key]
+ return value === undefined ? [] : [[requirement.key, value] as const]
+ })
+ )
+
+ updateDraft((current) => {
+ const customerProvidedInputs = { ...current.customerProvidedInputs }
+ if (Object.keys(activeClusterInputs).length > 0) {
+ customerProvidedInputs[componentDraft.componentKey] = activeClusterInputs
+ } else {
+ delete customerProvidedInputs[componentDraft.componentKey]
+ }
+
+ return {
+ ...current,
+ managedConfig: {
+ ...current.managedConfig,
+ // '' entries are only display markers for cleared fields — never persist them.
+ [componentDraft.componentKey]: omitEmptyValues(componentDraft.managedConfig),
+ },
+ customerProvidedInputs,
+ }
+ })
+ setComponentDraft(undefined)
+ }
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+
+
+
+
+ Platform layers could not be loaded. Please try again.
+
+
+
+ Back
+
+
+
+ ) : template && draft && selectedComponent ? (
+
+
setComponentDraft(undefined)}
+ >
+
+ Platform layers
+
+
+
+ ) : template && draft ? (
+
+ setComponentDraft({
+ componentKey,
+ managedConfig: { ...draft.managedConfig?.[componentKey] },
+ clusterInputs: { ...draft.customerProvidedInputs?.[componentKey] },
+ })
+ }
+ onLayerSelectionChange={(layerKey, enabled) =>
+ updateDraft((current) => ({
+ ...current,
+ layerSelections: { ...current.layerSelections, [layerKey]: enabled },
+ }))
+ }
+ onPrevious={onPrevious}
+ onSave={handleSubmit}
+ saveLabel="Continue"
+ />
+ ) : (
+
+
+
+
+
+ No platform template is available for this organization.
+
+
+
+ Back
+
+
+
+ )}
+
+
+ )
+}
+
+export default StepPlatform
diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx
index feacf105a8b..a0e6943cf95 100644
--- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx
+++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx
@@ -1,4 +1,8 @@
-import { type ClusterInstanceTypeResponseListResultsInner, type SecretManagerAccess } from 'qovery-typescript-axios'
+import {
+ type ClusterInstanceTypeResponseListResultsInner,
+ type ClusterPlatformBindingRequest,
+ type SecretManagerAccess,
+} from 'qovery-typescript-axios'
import { match } from 'ts-pattern'
import {
CONTROL_PLANE_LABELS,
@@ -29,9 +33,12 @@ export interface StepSummaryPresentationProps {
resourcesData: ClusterResourcesData
featuresData?: ClusterFeaturesData
addonsData: ClusterAddonsData
+ isEngineV2SelfManaged?: boolean
+ platformConfigurationData?: ClusterPlatformBindingRequest
goToFeatures: () => void
goToResources: () => void
goToKubeconfig: () => void
+ goToPlatform?: () => void
goToEksConfig: () => void
goToGeneral: () => void
goToAddons: () => void
@@ -119,9 +126,13 @@ export function StepSummaryPresentation(props: StepSummaryPresentationProps) {
return (
-
Ready to install your cluster
+
+ {props.isEngineV2SelfManaged ? 'Ready to connect your cluster' : 'Ready to install your cluster'}
+
- Here is what we will deploy, this action can take up to 30 minutes.
+ {props.isEngineV2SelfManaged
+ ? 'Review your configuration before creating the cluster and installing the Qovery operator.'
+ : 'Here is what we will deploy, this action can take up to 30 minutes.'}
@@ -292,25 +303,48 @@ export function StepSummaryPresentation(props: StepSummaryPresentationProps) {
)
)
- .with({ installation_type: 'SELF_MANAGED' }, () => (
-
-
-
Kubeconfig
-
-
- Kubeconfig:
- {props.kubeconfigData?.file_name}
-
-
-
-
-
-
-
- ))
+ .with({ installation_type: 'SELF_MANAGED' }, () =>
+ props.isEngineV2SelfManaged ? (
+
+
+
Platform layers
+
+
+ Optional layers enabled:
+ {Object.entries(props.platformConfigurationData?.layerSelections ?? {})
+ .filter(([, enabled]) => enabled)
+ .map(([key]) => key)
+ .join(', ') || 'None'}
+
+
+
+
+
+
+
+ ) : (
+
+
+
Kubeconfig
+
+
+ Kubeconfig:
+ {props.kubeconfigData?.file_name}
+
+
+
+
+
+
+
+ )
+ )
.with({ installation_type: 'PARTIALLY_MANAGED' }, () => (
<>
- props.onSubmit(false)}
- size="lg"
- color="neutral"
- variant="surface"
- >
- Create
-
+ {!props.isEngineV2SelfManaged ? (
+ props.onSubmit(false)}
+ size="lg"
+ color="neutral"
+ variant="surface"
+ >
+ Create
+
+ ) : null}
props.onSubmit(true)}
size="lg"
>
- Create and install
+ {props.isEngineV2SelfManaged ? 'Create and continue' : 'Create and install'}
diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.spec.tsx
index 1487051c630..b60e578b015 100644
--- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.spec.tsx
+++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.spec.tsx
@@ -1,6 +1,7 @@
import { CloudProviderEnum } from 'qovery-typescript-axios'
import { type PropsWithChildren } from 'react'
import * as cloudProvidersDomain from '@qovery/domains/cloud-providers/feature'
+import { type ClusterGeneralData } from '@qovery/shared/interfaces'
import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests'
import {
ClusterContainerCreateContext,
@@ -15,6 +16,8 @@ const mockCreateCluster = jest.fn()
const mockEditCloudProviderInfo = jest.fn()
const mockEditClusterKubeconfig = jest.fn()
const mockDeployCluster = jest.fn()
+const mockUpdatePlatformBinding = jest.fn()
+const mockAttachClusterOperator = jest.fn()
jest.mock('posthog-js/react', () => ({
useFeatureFlagEnabled: jest.fn(() => true),
@@ -74,6 +77,20 @@ jest.mock('../../hooks/use-deploy-cluster/use-deploy-cluster', () => ({
}),
}))
+jest.mock('../../platform-configuration/hooks/use-update-platform-binding', () => ({
+ useUpdatePlatformBinding: () => ({
+ mutateAsync: mockUpdatePlatformBinding,
+ isLoading: false,
+ }),
+}))
+
+jest.mock('../../platform-configuration/hooks/use-cluster-operator', () => ({
+ useAttachClusterOperator: () => ({
+ mutateAsync: mockAttachClusterOperator,
+ isLoading: false,
+ }),
+}))
+
jest.mock('@tanstack/react-router', () => ({
...jest.requireActual('@tanstack/react-router'),
useNavigate: () => mockNavigate,
@@ -99,6 +116,8 @@ describe('StepSummary', () => {
production: false,
}
mockContextValue.kubeconfigData = undefined
+ mockContextValue.platformConfigurationData = undefined
+ mockContextValue.isEngineV2SelfManaged = false
useCloudProviderInstanceTypesMockSpy.mockReturnValue({
data: [],
})
@@ -150,6 +169,70 @@ describe('StepSummary', () => {
})
})
+ it('should create the binding and attach the operator for an Engine v2 self-managed cluster', async () => {
+ mockContextValue.generalData = {
+ name: 'self-managed-cluster',
+ description: 'description',
+ cloud_provider: CloudProviderEnum.AWS,
+ region: 'eu-west-3',
+ installation_type: 'SELF_MANAGED',
+ production: false,
+ credentials: 'cred-id',
+ credentials_name: 'cred-name',
+ } as ClusterGeneralData
+ mockContextValue.isEngineV2SelfManaged = true
+ mockContextValue.platformConfigurationData = {
+ templateKey: 'qovery-cluster-v0',
+ templateVersion: '0.1.0',
+ layerSelections: { logs: true },
+ managedConfig: {},
+ customerProvidedInputs: {},
+ }
+ mockCreateCluster.mockResolvedValue({ id: 'cluster-123' })
+
+ const { userEvent } = renderWithProviders(
, { wrapper: Wrapper })
+ await userEvent.click(screen.getByTestId('button-create-deploy'))
+
+ await waitFor(() => {
+ expect(mockCreateCluster).toHaveBeenCalledWith({
+ organizationId: 'org-123',
+ clusterRequest: expect.objectContaining({
+ cloud_provider_credentials: {
+ cloud_provider: CloudProviderEnum.AWS,
+ credentials: { id: 'cred-id', name: 'cred-name' },
+ region: 'eu-west-3',
+ },
+ }),
+ })
+ expect(mockEditCloudProviderInfo).toHaveBeenCalledWith({
+ organizationId: 'org-123',
+ clusterId: 'cluster-123',
+ cloudProviderInfoRequest: {
+ cloud_provider: CloudProviderEnum.AWS,
+ credentials: { id: 'cred-id', name: 'cred-name' },
+ region: 'eu-west-3',
+ },
+ })
+ expect(mockUpdatePlatformBinding).toHaveBeenCalledWith({
+ organizationId: 'org-123',
+ clusterId: 'cluster-123',
+ request: mockContextValue.platformConfigurationData,
+ })
+ expect(mockAttachClusterOperator).toHaveBeenCalledWith({
+ organizationId: 'org-123',
+ clusterId: 'cluster-123',
+ })
+ expect(mockNavigate).toHaveBeenCalledWith({
+ to: '/organization/$organizationId/cluster/$clusterId/overview',
+ params: { organizationId: 'org-123', clusterId: 'cluster-123' },
+ search: { 'show-self-managed-guide': true },
+ })
+ })
+ expect(mockUpdatePlatformBinding.mock.invocationCallOrder[0]).toBeLessThan(
+ mockAttachClusterOperator.mock.invocationCallOrder[0]
+ )
+ })
+
it('should send GCP NAT_GATEWAY using nat_gateway_type format on create', async () => {
mockContextValue.generalData = {
name: 'test-gcp-cluster',
diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.tsx
index c26813b3514..0fdf2496174 100644
--- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.tsx
+++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.tsx
@@ -1,5 +1,6 @@
import { useNavigate } from '@tanstack/react-router'
import {
+ type Cluster,
type ClusterCloudProviderInfoRequest,
type ClusterFeatureNatGatewayParameters,
type ClusterFeatureNatGatewayTypeGcp,
@@ -8,7 +9,7 @@ import {
type ClusterRequestFeaturesInner,
type KubernetesEnum,
} from 'qovery-typescript-axios'
-import { useCallback, useEffect } from 'react'
+import { useCallback, useEffect, useRef } from 'react'
import { match } from 'ts-pattern'
import { SCW_CONTROL_PLANE_FEATURE_ID, useCloudProviderInstanceTypes } from '@qovery/domains/cloud-providers/feature'
import { FunnelFlowBody } from '@qovery/shared/ui'
@@ -16,6 +17,8 @@ import { useCreateCluster } from '../../hooks/use-create-cluster/use-create-clus
import { useDeployCluster } from '../../hooks/use-deploy-cluster/use-deploy-cluster'
import { useEditCloudProviderInfo } from '../../hooks/use-edit-cloud-provider-info/use-edit-cloud-provider-info'
import { useEditClusterKubeconfig } from '../../hooks/use-edit-cluster-kubeconfig/use-edit-cluster-kubeconfig'
+import { useAttachClusterOperator } from '../../platform-configuration/hooks/use-cluster-operator'
+import { useUpdatePlatformBinding } from '../../platform-configuration/hooks/use-update-platform-binding'
import { steps, useClusterContainerCreateContext } from '../cluster-creation-flow'
import { getValueByKey } from './get-value-by-key'
import { StepSummaryPresentation } from './step-summary-presentation'
@@ -45,12 +48,25 @@ const buildGcpNatGatewayFeature = (
export function StepSummary({ organizationId }: StepSummaryProps) {
const navigate = useNavigate()
- const { generalData, kubeconfigData, resourcesData, featuresData, addonsData, setCurrentStep, creationFlowUrl } =
- useClusterContainerCreateContext()
+ const {
+ generalData,
+ kubeconfigData,
+ resourcesData,
+ featuresData,
+ addonsData,
+ platformConfigurationData,
+ isEngineV2SelfManaged,
+ setCurrentStep,
+ creationFlowUrl,
+ } = useClusterContainerCreateContext()
const { mutateAsync: createCluster, isLoading: isCreateClusterLoading } = useCreateCluster()
const { mutateAsync: editCloudProviderInfo } = useEditCloudProviderInfo({ silently: true })
const { mutateAsync: editClusterKubeconfig } = useEditClusterKubeconfig()
const { mutateAsync: deployCluster, isLoading: isDeployClusterLoading } = useDeployCluster()
+ const { mutateAsync: updatePlatformBinding, isLoading: isPlatformBindingLoading } = useUpdatePlatformBinding()
+ const { mutateAsync: attachClusterOperator, isLoading: isOperatorAttachLoading } = useAttachClusterOperator()
+ // Survives failed submit attempts so retries reuse the already-created cluster.
+ const createdClusterRef = useRef
()
const { data: cloudProviderInstanceTypes } = useCloudProviderInstanceTypes(
match(generalData)
@@ -81,6 +97,7 @@ export function StepSummary({ organizationId }: StepSummaryProps) {
() => navigate({ to: `${creationFlowUrl}/kubeconfig` }),
[navigate, creationFlowUrl]
)
+ const goToPlatform = useCallback(() => navigate({ to: `${creationFlowUrl}/platform` }), [navigate, creationFlowUrl])
const goToFeatures = useCallback(() => navigate({ to: `${creationFlowUrl}/features` }), [navigate, creationFlowUrl])
const goToAddons = useCallback(() => navigate({ to: `${creationFlowUrl}/addons` }), [navigate, creationFlowUrl])
const goToGeneral = () => navigate({ to: `${creationFlowUrl}/general` })
@@ -89,7 +106,11 @@ export function StepSummary({ organizationId }: StepSummaryProps) {
const onBack = () => {
if (generalData?.installation_type === 'SELF_MANAGED') {
- goToKubeconfig()
+ if (isEngineV2SelfManaged) {
+ goToPlatform()
+ } else {
+ goToKubeconfig()
+ }
return
}
if (generalData?.installation_type === 'PARTIALLY_MANAGED') {
@@ -118,11 +139,13 @@ export function StepSummary({ organizationId }: StepSummaryProps) {
const onSubmit = async (withDeploy: boolean) => {
if (!generalData?.name) throw new Error('Invalid generalData')
- const cloudProviderCredentials: ClusterCloudProviderInfoRequest = {
- cloud_provider: generalData.cloud_provider,
- credentials: { id: generalData.credentials, name: generalData.credentials_name },
- region: generalData.region,
- }
+ const cloudProviderCredentials: ClusterCloudProviderInfoRequest | undefined = generalData.credentials
+ ? {
+ cloud_provider: generalData.cloud_provider,
+ credentials: { id: generalData.credentials, name: generalData.credentials_name },
+ region: generalData.region,
+ }
+ : undefined
const addonsPayload = {
keda: {
@@ -143,28 +166,49 @@ export function StepSummary({ organizationId }: StepSummaryProps) {
}
: {}
- if (generalData.installation_type === 'SELF_MANAGED' && kubeconfigData) {
+ if (generalData.installation_type === 'SELF_MANAGED' && (isEngineV2SelfManaged || kubeconfigData)) {
+ if (isEngineV2SelfManaged && !platformConfigurationData) {
+ // The platform step was skipped (deep link / legacy kubeconfig route): send the
+ // user there instead of failing silently.
+ navigate({ to: `${creationFlowUrl}/platform` })
+ return
+ }
try {
- const cluster = await createCluster({
- organizationId,
- clusterRequest: {
- name: generalData.name,
- description: generalData.description,
- region: generalData.region,
- cloud_provider: generalData.cloud_provider,
- kubernetes: 'SELF_MANAGED',
- production: generalData.production,
- features: [],
- cloud_provider_credentials: cloudProviderCredentials,
- ...addonsPayload,
- ...awsLabelsGroups,
- },
- })
- await editCloudProviderInfo({
- organizationId,
- clusterId: cluster.id,
- cloudProviderInfoRequest: cloudProviderCredentials,
- })
+ // Keep the created cluster across attempts: if a follow-up call fails
+ // (binding, operator attach), retrying must not create a second cluster.
+ const cluster =
+ createdClusterRef.current ??
+ (await createCluster({
+ organizationId,
+ clusterRequest: {
+ name: generalData.name,
+ description: generalData.description,
+ region: generalData.region,
+ cloud_provider: generalData.cloud_provider,
+ kubernetes: 'SELF_MANAGED',
+ production: generalData.production,
+ features: [],
+ cloud_provider_credentials: cloudProviderCredentials,
+ ...addonsPayload,
+ ...awsLabelsGroups,
+ },
+ }))
+ createdClusterRef.current = cluster
+ if (cloudProviderCredentials) {
+ await editCloudProviderInfo({
+ organizationId,
+ clusterId: cluster.id,
+ cloudProviderInfoRequest: cloudProviderCredentials,
+ })
+ }
+ if (isEngineV2SelfManaged && platformConfigurationData) {
+ await updatePlatformBinding({
+ organizationId,
+ clusterId: cluster.id,
+ request: platformConfigurationData,
+ })
+ await attachClusterOperator({ organizationId, clusterId: cluster.id })
+ }
navigate({
to: '/organization/$organizationId/cluster/$clusterId/overview',
params: { organizationId, clusterId: cluster.id },
@@ -421,6 +465,7 @@ export function StepSummary({ organizationId }: StepSummaryProps) {
})
try {
+ if (!cloudProviderCredentials) throw new Error('Cloud provider credentials are required for a managed cluster')
const cluster = await createCluster({ organizationId, clusterRequest })
await editCloudProviderInfo({
organizationId,
@@ -437,16 +482,18 @@ export function StepSummary({ organizationId }: StepSummaryProps) {
}
useEffect(() => {
- const stepIndex = steps(generalData).findIndex((step) => step.key === 'summary') + 1
+ const stepIndex = steps(generalData, isEngineV2SelfManaged).findIndex((step) => step.key === 'summary') + 1
setCurrentStep(stepIndex)
- }, [setCurrentStep, generalData])
+ }, [setCurrentStep, generalData, isEngineV2SelfManaged])
return (
{generalData && resourcesData && (
)}
diff --git a/libs/domains/clusters/feature/src/lib/cluster-installation-guide-modal/cluster-installation-guide-modal.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-installation-guide-modal/cluster-installation-guide-modal.spec.tsx
new file mode 100644
index 00000000000..66c751895c3
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/cluster-installation-guide-modal/cluster-installation-guide-modal.spec.tsx
@@ -0,0 +1,65 @@
+import * as posthog from 'posthog-js/react'
+import { type Cluster } from 'qovery-typescript-axios'
+import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import * as installationValuesHooks from '../hooks/use-installation-helm-values/use-installation-helm-values'
+import * as operatorHooks from '../platform-configuration/hooks/use-cluster-operator'
+import { ClusterInstallationGuideModal } from './cluster-installation-guide-modal'
+
+jest.mock('posthog-js/react', () => ({
+ ...jest.requireActual('posthog-js/react'),
+ useFeatureFlagEnabled: jest.fn(),
+}))
+
+describe('ClusterInstallationGuideModal', () => {
+ const refetchOperatorBootstrap = jest.fn()
+
+ beforeEach(() => {
+ jest.useFakeTimers()
+ jest.clearAllMocks()
+ jest.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true)
+ jest.spyOn(installationValuesHooks, 'useInstallationHelmValues').mockReturnValue({
+ mutateAsync: jest.fn(),
+ isLoading: false,
+ } as unknown as ReturnType)
+ jest.spyOn(operatorHooks, 'useClusterOperatorStatus').mockReturnValue({
+ data: {
+ organizationId: 'org-123',
+ clusterId: 'cluster-123',
+ operatorConnected: false,
+ },
+ isLoading: false,
+ isError: false,
+ refetch: jest.fn(),
+ } as unknown as ReturnType)
+ jest.spyOn(operatorHooks, 'useClusterOperatorBootstrap').mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ refetch: refetchOperatorBootstrap,
+ } as unknown as ReturnType)
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ jest.restoreAllMocks()
+ })
+
+ it('shows an actionable error when the operator bootstrap cannot be loaded', async () => {
+ const cluster = {
+ id: 'cluster-123',
+ organization: { id: 'org-123' },
+ kubernetes: 'SELF_MANAGED',
+ is_demo: false,
+ } as Cluster
+
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ expect(screen.getByText('Unable to load the operator installation guide.')).toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
+
+ expect(refetchOperatorBootstrap).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/libs/domains/clusters/feature/src/lib/cluster-installation-guide-modal/cluster-installation-guide-modal.tsx b/libs/domains/clusters/feature/src/lib/cluster-installation-guide-modal/cluster-installation-guide-modal.tsx
index 2a2f6e64864..894bfa1ca59 100644
--- a/libs/domains/clusters/feature/src/lib/cluster-installation-guide-modal/cluster-installation-guide-modal.tsx
+++ b/libs/domains/clusters/feature/src/lib/cluster-installation-guide-modal/cluster-installation-guide-modal.tsx
@@ -1,8 +1,14 @@
import download from 'downloadjs'
+import { useFeatureFlagEnabled } from 'posthog-js/react'
import { type Cluster } from 'qovery-typescript-axios'
-import { Button, Callout, ExternalLink, Icon } from '@qovery/shared/ui'
+import { Button, Callout, CopyButton, ExternalLink, Icon, LoaderSpinner } from '@qovery/shared/ui'
import { ClusterSetup } from '../cluster-setup/cluster-setup'
import { useInstallationHelmValues } from '../hooks/use-installation-helm-values/use-installation-helm-values'
+import {
+ useClusterOperatorBootstrap,
+ useClusterOperatorStatus,
+} from '../platform-configuration/hooks/use-cluster-operator'
+import { PLATFORM_CONFIGURATION_FEATURE_FLAG } from '../platform-configuration/platform-configuration-feature-flag'
export type ClusterInstallationGuideModalProps = {
type: 'MANAGED' | 'ON_PREMISE'
@@ -19,6 +25,32 @@ export type ClusterInstallationGuideModalProps = {
)
export function ClusterInstallationGuideModal({ type, onClose, ...props }: ClusterInstallationGuideModalProps) {
+ const isEngineV2Enabled = useFeatureFlagEnabled(PLATFORM_CONFIGURATION_FEATURE_FLAG)
+ const cluster = props.mode === 'EDIT' ? props.cluster : undefined
+ // Boolean() matters: the flag is undefined while PostHog loads, and passing
+ // `enabled: undefined` to the operator hooks would fall back to their `enabled = true` default.
+ const canUseOperator = Boolean(isEngineV2Enabled) && cluster?.kubernetes === 'SELF_MANAGED'
+ const isFeatureFlagLoading = isEngineV2Enabled === undefined && cluster?.kubernetes === 'SELF_MANAGED'
+ const {
+ data: operatorStatus,
+ isLoading: isOperatorStatusLoading,
+ isError: isOperatorStatusError,
+ refetch: refetchOperatorStatus,
+ } = useClusterOperatorStatus({
+ organizationId: cluster?.organization.id ?? '',
+ clusterId: cluster?.id ?? '',
+ enabled: canUseOperator,
+ })
+ const {
+ data: operatorBootstrap,
+ isLoading: isOperatorBootstrapLoading,
+ isError: isOperatorBootstrapError,
+ refetch: refetchOperatorBootstrap,
+ } = useClusterOperatorBootstrap({
+ organizationId: cluster?.organization.id ?? '',
+ clusterId: cluster?.id ?? '',
+ enabled: canUseOperator && Boolean(operatorStatus),
+ })
const { mutateAsync: getInstallationHelmValues, isLoading } = useInstallationHelmValues()
const downloadInstallationValues = async () => {
if (props.mode === 'CREATE') {
@@ -32,6 +64,23 @@ export function ClusterInstallationGuideModal({ type, onClose, ...props }: Clust
}
const isDemo = props.mode === 'CREATE' ? props.isDemo : props.cluster.is_demo
+ const isOperatorManaged = canUseOperator && Boolean(operatorStatus)
+ const isOperatorGuideLoading =
+ isFeatureFlagLoading ||
+ (canUseOperator && (isOperatorStatusLoading || (isOperatorManaged && isOperatorBootstrapLoading)))
+ // Only a bootstrap failure of a confirmed operator-managed cluster blocks the guide;
+ // a status failure falls back to the legacy instructions, which don't depend on the operator.
+ const hasOperatorGuideError = isOperatorManaged && isOperatorBootstrapError
+
+ const retryOperatorGuide = () => {
+ if (isOperatorStatusError) void refetchOperatorStatus()
+ if (isOperatorBootstrapError) void refetchOperatorBootstrap()
+ }
+
+ const downloadOperatorValues = () => {
+ if (!cluster || !operatorBootstrap) return
+ download(operatorBootstrap.valuesYaml, 'values.yaml', 'text/yaml')
+ }
return (
@@ -40,7 +89,7 @@ export function ClusterInstallationGuideModal({ type, onClose, ...props }: Clust
- {props.mode === 'EDIT' && type === 'ON_PREMISE' && (
+ {props.mode === 'EDIT' && type === 'ON_PREMISE' && !isOperatorManaged && !hasOperatorGuideError && (
@@ -53,7 +102,70 @@ export function ClusterInstallationGuideModal({ type, onClose, ...props }: Clust
)}
- {type === 'MANAGED' && (
+ {isOperatorGuideLoading ? (
+
+
+
+ ) : hasOperatorGuideError ? (
+
+
+
+
+
+ Unable to load the operator installation guide.
+
+ Retry the request. If the problem persists, contact Qovery support.
+
+
+ Retry
+
+
+
+ ) : isOperatorManaged && operatorBootstrap ? (
+ <>
+
+
+
+
+
+ Install the Qovery operator on your existing Kubernetes cluster. It will apply the platform layers you
+ selected and handle future Engine v2 deployments.
+
+
+
+
+ 1. Download the operator values
+
+ Save the generated values file. It contains the credentials assigned to this cluster.
+
+
+ Download values
+
+
+
+
+ 2. Install the operator
+
+ Run this command from the directory containing the downloaded values file.
+
+
+
+ $
+ {operatorBootstrap.helmCommand}
+
+
+
+
+
+ 3. Deploy your first environment
+
+ Once the operator is connected, return to the Qovery console and deploy an environment on this
+ cluster.
+
+
+
+ >
+ ) : type === 'MANAGED' ? (
Save the following yaml, it contains the Qovery configuration assigned to your cluster.
@@ -79,16 +191,16 @@ export function ClusterInstallationGuideModal({ type, onClose, ...props }: Clust
- )}
+ ) : null}
- {type === 'ON_PREMISE' && (
+ {type === 'ON_PREMISE' && !isOperatorGuideLoading && !isOperatorManaged && !hasOperatorGuideError && (
)}
- {type === 'MANAGED' && (
+ {type === 'MANAGED' && !isOperatorManaged && !hasOperatorGuideError && (
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-cluster-operator.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-cluster-operator.ts
new file mode 100644
index 00000000000..f250c44793e
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-cluster-operator.ts
@@ -0,0 +1,38 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { clusterOperatorMutations } from '@qovery/domains/clusters/data-access'
+import { queries } from '@qovery/state/util-queries'
+
+interface ClusterOperatorQueryProps {
+ organizationId: string
+ clusterId: string
+ enabled?: boolean
+}
+
+export function useClusterOperatorStatus({ organizationId, clusterId, enabled = true }: ClusterOperatorQueryProps) {
+ return useQuery({
+ ...queries.clusterOperator.status({ organizationId, clusterId }),
+ enabled: enabled && Boolean(organizationId) && Boolean(clusterId),
+ })
+}
+
+export function useClusterOperatorBootstrap({ organizationId, clusterId, enabled = true }: ClusterOperatorQueryProps) {
+ return useQuery({
+ ...queries.clusterOperator.bootstrap({ organizationId, clusterId }),
+ enabled: enabled && Boolean(organizationId) && Boolean(clusterId),
+ })
+}
+
+export function useAttachClusterOperator() {
+ const queryClient = useQueryClient()
+
+ return useMutation(clusterOperatorMutations.attach, {
+ onSuccess(_, { organizationId, clusterId }) {
+ queryClient.invalidateQueries({
+ queryKey: queries.clusterOperator.status({ organizationId, clusterId }).queryKey,
+ })
+ },
+ meta: {
+ notifyOnError: true,
+ },
+ })
+}
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-binding.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-binding.ts
new file mode 100644
index 00000000000..4faed77643a
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-binding.ts
@@ -0,0 +1,22 @@
+import { useQuery } from '@tanstack/react-query'
+import { queries } from '@qovery/state/util-queries'
+
+interface UsePlatformBindingProps {
+ organizationId: string
+ clusterId: string
+ enabled?: boolean
+ suspense?: boolean
+}
+
+export function usePlatformBinding({
+ organizationId,
+ clusterId,
+ enabled = true,
+ suspense = false,
+}: UsePlatformBindingProps) {
+ return useQuery({
+ ...queries.platformConfiguration.binding({ organizationId, clusterId }),
+ enabled: enabled && Boolean(organizationId) && Boolean(clusterId),
+ suspense,
+ })
+}
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-component-configuration.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-component-configuration.ts
new file mode 100644
index 00000000000..00641a8fcc8
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-component-configuration.ts
@@ -0,0 +1,30 @@
+import { useQuery } from '@tanstack/react-query'
+import { type PlatformComponentConfigurationPreviewRequest } from 'qovery-typescript-axios'
+import { queries } from '@qovery/state/util-queries'
+
+interface UsePlatformComponentConfigurationProps {
+ organizationId: string
+ clusterId: string
+ componentKey?: string
+ request: PlatformComponentConfigurationPreviewRequest
+ enabled?: boolean
+}
+
+export function usePlatformComponentConfiguration({
+ organizationId,
+ clusterId,
+ componentKey,
+ request,
+ enabled = true,
+}: UsePlatformComponentConfigurationProps) {
+ return useQuery({
+ ...queries.platformConfiguration.componentConfiguration({
+ organizationId,
+ clusterId,
+ componentKey: componentKey ?? '',
+ request,
+ }),
+ enabled: enabled && Boolean(organizationId) && Boolean(clusterId) && Boolean(componentKey),
+ keepPreviousData: true,
+ })
+}
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-template-component-configuration.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-template-component-configuration.ts
new file mode 100644
index 00000000000..cb2ac12042d
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-template-component-configuration.ts
@@ -0,0 +1,49 @@
+import { useQuery } from '@tanstack/react-query'
+import {
+ type PlatformCloudVendor,
+ type PlatformClusterMode,
+ type PlatformComponentConfigurationPreviewRequest,
+} from 'qovery-typescript-axios'
+import { queries } from '@qovery/state/util-queries'
+
+interface UsePlatformTemplateComponentConfigurationProps {
+ organizationId: string
+ templateKey?: string
+ templateVersion?: string
+ componentKey?: string
+ clusterMode: PlatformClusterMode
+ cloudProvider?: PlatformCloudVendor
+ request: PlatformComponentConfigurationPreviewRequest
+ enabled?: boolean
+}
+
+export function usePlatformTemplateComponentConfiguration({
+ organizationId,
+ templateKey,
+ templateVersion,
+ componentKey,
+ clusterMode,
+ cloudProvider,
+ request,
+ enabled = true,
+}: UsePlatformTemplateComponentConfigurationProps) {
+ return useQuery({
+ ...queries.platformConfiguration.templateComponentConfiguration({
+ organizationId,
+ templateKey: templateKey ?? '',
+ templateVersion: templateVersion ?? '',
+ componentKey: componentKey ?? '',
+ clusterMode,
+ cloudProvider: cloudProvider ?? 'UNKNOWN',
+ request,
+ }),
+ enabled:
+ enabled &&
+ Boolean(organizationId) &&
+ Boolean(templateKey) &&
+ Boolean(templateVersion) &&
+ Boolean(componentKey) &&
+ Boolean(cloudProvider),
+ keepPreviousData: true,
+ })
+}
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-templates.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-templates.ts
new file mode 100644
index 00000000000..44606748ab5
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-templates.ts
@@ -0,0 +1,25 @@
+import { useQuery } from '@tanstack/react-query'
+import { type PlatformCloudVendor, type PlatformClusterMode } from 'qovery-typescript-axios'
+import { queries } from '@qovery/state/util-queries'
+
+interface UsePlatformTemplatesProps {
+ organizationId: string
+ clusterMode?: PlatformClusterMode
+ cloudProvider?: PlatformCloudVendor
+ enabled?: boolean
+ suspense?: boolean
+}
+
+export function usePlatformTemplates({
+ organizationId,
+ clusterMode,
+ cloudProvider,
+ enabled = true,
+ suspense = false,
+}: UsePlatformTemplatesProps) {
+ return useQuery({
+ ...queries.platformConfiguration.templates({ organizationId, clusterMode, cloudProvider }),
+ enabled: enabled && Boolean(organizationId),
+ suspense,
+ })
+}
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-update-platform-binding.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-update-platform-binding.ts
new file mode 100644
index 00000000000..ce8f152d51f
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-update-platform-binding.ts
@@ -0,0 +1,20 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { platformConfigurationMutations } from '@qovery/domains/clusters/data-access'
+import { queries } from '@qovery/state/util-queries'
+
+export function useUpdatePlatformBinding() {
+ const queryClient = useQueryClient()
+
+ return useMutation(platformConfigurationMutations.updateBinding, {
+ onSuccess(binding, { organizationId, clusterId }) {
+ queryClient.setQueryData(queries.platformConfiguration.binding({ organizationId, clusterId }).queryKey, binding)
+ queryClient.invalidateQueries({ queryKey: queries.platformConfiguration.componentConfiguration._def })
+ },
+ meta: {
+ notifyOnSuccess: {
+ title: 'Platform configuration saved',
+ },
+ notifyOnError: true,
+ },
+ })
+}
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/platform-component-configuration.spec.tsx b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-component-configuration.spec.tsx
new file mode 100644
index 00000000000..8c374c4a92e
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-component-configuration.spec.tsx
@@ -0,0 +1,205 @@
+import {
+ type PlatformComponentConfigurationPreviewResponse,
+ type PlatformTemplateComponentResponse,
+} from 'qovery-typescript-axios'
+import selectEvent from 'react-select-event'
+import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import { PlatformComponentConfiguration } from './platform-component-configuration'
+
+const component: PlatformTemplateComponentResponse = {
+ key: 'log-storage',
+ kind: 'HELM',
+ description: 'Stores platform logs.',
+ fields: [
+ {
+ key: 'storage',
+ type: 'string',
+ required: true,
+ defaultValue: 'persistent-volume',
+ label: 'Storage',
+ sensitive: false,
+ constraints: { allowedValues: ['persistent-volume', 'object-storage'] },
+ },
+ ],
+}
+
+const preview: PlatformComponentConfigurationPreviewResponse = {
+ clusterId: 'cluster-id',
+ componentKey: component.key,
+ fields: component.fields,
+ requirements: [],
+ componentBindings: [],
+ violations: [],
+}
+
+const defaultProps = {
+ component,
+ preview,
+ profileConfig: {},
+ clusterInputs: {},
+ hasPreviewError: false,
+ isFetching: false,
+ isSaving: false,
+ onProfileConfigChange: jest.fn(),
+ onClusterInputChange: jest.fn(),
+ onSave: jest.fn(),
+}
+
+describe('PlatformComponentConfiguration', () => {
+ it('allows saving a valid configuration without showing a ready status', () => {
+ renderWithProviders( )
+
+ expect(screen.queryByText('Ready')).not.toBeInTheDocument()
+ expect(screen.getByText('persistent-volume')).toBeInTheDocument()
+ expect(screen.queryByText('Cluster inputs')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Save configuration' })).toBeEnabled()
+ })
+
+ it('uses contextual field choices returned by the preview', () => {
+ renderWithProviders(
+
+ )
+
+ selectEvent.openMenu(screen.getByLabelText('Storage'))
+
+ expect(screen.getByRole('option', { name: 'gcs' })).toBeInTheDocument()
+ expect(screen.queryByRole('option', { name: 'object-storage' })).not.toBeInTheDocument()
+ })
+
+ it('keeps saving disabled until a resolver preview is available', () => {
+ renderWithProviders( )
+
+ expect(screen.getByText('persistent-volume')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Save configuration' })).toBeDisabled()
+ })
+
+ it('does not show a ready status on resolved cluster inputs', () => {
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByText('Cluster inputs')).toBeInTheDocument()
+ expect(screen.queryByText('Ready')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Save configuration' })).toBeEnabled()
+ })
+
+ it('renders resolver requirements for a component without catalog fields', () => {
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByText('Cluster inputs')).toBeInTheDocument()
+ expect(screen.getByLabelText('Bucket')).toBeInTheDocument()
+ expect(screen.queryByText('This component does not require any configuration.')).not.toBeInTheDocument()
+ })
+
+ it('surfaces a preview failure and prevents saving', () => {
+ renderWithProviders( )
+
+ expect(screen.getByText('Configuration could not be checked')).toBeInTheDocument()
+ expect(screen.getByText('Refresh the page and try again.')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Save configuration' })).toBeDisabled()
+ })
+
+ it('renders missing cluster requirements and prevents saving', () => {
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByText('Cluster inputs')).toBeInTheDocument()
+ expect(screen.getAllByText('Action required')).toHaveLength(3)
+ expect(screen.getByText('Endpoint is required.')).toBeInTheDocument()
+ expect(screen.getByLabelText('Access key')).toHaveAttribute('type', 'password')
+ expect(screen.getByRole('button', { name: 'Save configuration' })).toBeDisabled()
+ })
+
+ it('displays component bindings supplied by the resolver', () => {
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByText('Managed component bindings')).toBeInTheDocument()
+ expect(screen.getByText('Bucket from Bucket provisioner (Name)')).toBeInTheDocument()
+ })
+})
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/platform-component-configuration.tsx b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-component-configuration.tsx
new file mode 100644
index 00000000000..cbb9a80357c
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-component-configuration.tsx
@@ -0,0 +1,201 @@
+import {
+ type PlatformComponentConfigurationResolutionResponse,
+ type PlatformComponentInputRequirementResponse,
+ type PlatformTemplateComponentResponse,
+} from 'qovery-typescript-axios'
+import { useRef } from 'react'
+import { match } from 'ts-pattern'
+import { Badge, Button, Callout, CatalogVariableInput, Heading, Icon } from '@qovery/shared/ui'
+import { type CatalogVariableValue, formatCatalogKey, getCatalogVariableValue } from '@qovery/shared/util-js'
+import {
+ getFieldViolation,
+ getUnmappedViolations,
+ isPlatformConfigurationReady,
+ toCatalogVariableField,
+} from './platform-configuration-utils'
+
+interface PlatformComponentConfigurationProps {
+ clusterInputs: Record
+ component: PlatformTemplateComponentResponse
+ isFetching: boolean
+ isSaving: boolean
+ hasPreviewError: boolean
+ onClusterInputChange: (key: string, value: CatalogVariableValue) => void
+ onProfileConfigChange: (key: string, value: CatalogVariableValue) => void
+ onSave: () => void
+ preview?: PlatformComponentConfigurationResolutionResponse
+ profileConfig: Record
+}
+
+function RequirementStatus({ status }: { status: 'MISSING' | 'READY' }) {
+ return match(status)
+ .with('READY', () => null)
+ .with('MISSING', () => (
+
+ Action required
+
+ ))
+ .exhaustive()
+}
+
+export function PlatformComponentConfiguration({
+ clusterInputs,
+ component,
+ hasPreviewError,
+ isFetching,
+ isSaving,
+ onClusterInputChange,
+ onProfileConfigChange,
+ onSave,
+ preview,
+ profileConfig,
+}: PlatformComponentConfigurationProps) {
+ const fields = preview?.fields ?? component.fields
+ // The preview is undefined while a new resolution is debounced/fetched. Keep the
+ // last known requirements for the same component so their inputs (including the
+ // one being typed into) don't unmount and lose focus on every keystroke.
+ const lastRequirementsRef = useRef<{
+ componentKey: string
+ requirements: PlatformComponentInputRequirementResponse[]
+ }>()
+ if (preview) {
+ lastRequirementsRef.current = { componentKey: component.key, requirements: preview.requirements }
+ }
+ const requirements =
+ preview?.requirements ??
+ (lastRequirementsRef.current?.componentKey === component.key ? lastRequirementsRef.current.requirements : [])
+ const violations = preview?.violations ?? []
+ const unmappedViolations = getUnmappedViolations(violations, fields, requirements)
+ const ready = preview ? isPlatformConfigurationReady(violations, requirements) : false
+
+ return (
+
+
+
+
{formatCatalogKey(component.key)}
+ {component.description ?
{component.description}
: null}
+
+ {isFetching ? (
+
+ Checking…
+
+ ) : preview && !ready ? (
+
+ Action required
+
+ ) : null}
+
+
+ {!isFetching && !hasPreviewError && fields.length === 0 && requirements.length === 0 ? (
+
+
+
+
+ This component does not require any configuration.
+
+ ) : (
+
+ {hasPreviewError ? (
+
+
+
+
+
+ Configuration could not be checked
+ Refresh the page and try again.
+
+
+ ) : null}
+
+ {fields.length > 0 ? (
+
+ Configuration
+ {fields.map((field) => (
+ onProfileConfigChange(field.key, value)}
+ />
+ ))}
+
+ ) : null}
+
+ {requirements.length > 0 ? (
+
+
+
+
Cluster inputs
+
+ Values required from this cluster for the selected configuration.
+
+
+
+ {requirements.map((requirement) => (
+
+
+
+
+
onClusterInputChange(requirement.key, value)}
+ />
+
+ ))}
+
+ ) : null}
+
+ {preview?.componentBindings.length ? (
+
+
+
+
+
+ Managed component bindings
+
+
+ {preview.componentBindings.map((binding) => (
+
+ {formatCatalogKey(binding.input)} from {formatCatalogKey(binding.fromComponent)} (
+ {formatCatalogKey(binding.output)})
+
+ ))}
+
+
+
+
+ ) : null}
+
+ {unmappedViolations.map((violation) => (
+
+
+
+
+
+ {formatCatalogKey(violation.code)}
+ {violation.message}
+
+
+ ))}
+
+ )}
+
+
+
+ Save configuration
+
+
+
+ )
+}
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-catalog.spec.tsx b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-catalog.spec.tsx
new file mode 100644
index 00000000000..9a1f4b04c9b
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-catalog.spec.tsx
@@ -0,0 +1,201 @@
+import { type ClusterPlatformBindingResponse, type PlatformTemplateSummaryResponse } from 'qovery-typescript-axios'
+import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import { PlatformConfigurationCatalog } from './platform-configuration-catalog'
+
+const template = {
+ key: 'qovery-cluster-v0',
+ version: '0.1.0',
+ status: 'PUBLISHED',
+ layers: [
+ {
+ key: 'log-infrastructure',
+ mandatory: false,
+ enabledByDefault: false,
+ modes: ['CUSTOMER_MANAGED'],
+ providers: ['AWS'],
+ componentKeys: ['loki'],
+ components: [
+ {
+ key: 'loki',
+ kind: 'HELM',
+ fields: [
+ {
+ key: 'storage',
+ type: 'string',
+ required: false,
+ defaultValue: 'pvc',
+ label: 'Storage',
+ sensitive: false,
+ constraints: { allowedValues: ['pvc', 's3'] },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+} satisfies PlatformTemplateSummaryResponse
+
+const binding = {
+ clusterId: 'cluster-id',
+ organizationId: 'organization-id',
+ templateKey: template.key,
+ templateVersion: template.version,
+ layerSelections: { 'log-infrastructure': true },
+ managedConfig: {},
+ customerProvidedInputs: {},
+ layers: [
+ {
+ key: 'log-infrastructure',
+ status: 'ENABLED',
+ reason: 'optional layer enabled',
+ componentKeys: ['loki'],
+ },
+ ],
+} satisfies ClusterPlatformBindingResponse
+
+const defaultProps = {
+ binding,
+ layerSelections: binding.layerSelections,
+ isSaving: false,
+ onComponentSelect: jest.fn(),
+ onLayerSelectionChange: jest.fn(),
+ onSave: jest.fn(),
+ template,
+}
+
+describe('PlatformConfigurationCatalog', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ jest.clearAllMocks()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it('shows complete layer names and opens a component on a second depth', async () => {
+ const { userEvent } = renderWithProviders( )
+
+ expect(screen.getByText('Log infrastructure')).not.toHaveClass('truncate')
+ await userEvent.click(screen.getByRole('button', { name: 'Loki HELM' }))
+
+ expect(defaultProps.onComponentSelect).toHaveBeenCalledWith('loki')
+ })
+
+ it('keeps the displayed status and checkbox aligned with the editable draft', () => {
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByText('DISABLED')).toBeInTheDocument()
+ expect(screen.getByRole('checkbox', { name: 'Enable this layer' })).not.toBeChecked()
+ expect(screen.getByRole('button', { name: 'Loki HELM' })).toBeDisabled()
+ })
+
+ it('uses the resolved binding status when no explicit selection exists', () => {
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByText('ENABLED')).toBeInTheDocument()
+ expect(screen.getByRole('checkbox', { name: 'Enable this layer' })).toBeChecked()
+ expect(screen.getByRole('button', { name: 'Loki HELM' })).toBeEnabled()
+ })
+
+ it('keeps non-applicable mandatory layers skipped', () => {
+ const infrastructureTemplate = {
+ ...template,
+ layers: [{ ...template.layers[0], key: 'infrastructure', mandatory: true }],
+ } satisfies PlatformTemplateSummaryResponse
+ const infrastructureBinding = {
+ ...binding,
+ layers: [
+ {
+ key: 'infrastructure',
+ status: 'SKIPPED',
+ reason: 'not applicable to CUSTOMER_MANAGED/AWS cluster',
+ componentKeys: [],
+ },
+ ],
+ } satisfies ClusterPlatformBindingResponse
+
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByText('SKIPPED')).toBeInTheDocument()
+ expect(screen.getByText('not applicable to CUSTOMER_MANAGED/AWS cluster')).toBeInTheDocument()
+ })
+
+ it('uses catalog applicability before a cluster binding exists', () => {
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByText('SKIPPED')).toBeInTheDocument()
+ expect(screen.getByText('Not applicable to this cluster.')).toBeInTheDocument()
+ expect(screen.getByRole('checkbox', { name: 'Enable this layer' })).toBeDisabled()
+ })
+
+ it('saves layer changes independently from component configuration', async () => {
+ const { userEvent } = renderWithProviders( )
+
+ await userEvent.click(screen.getByRole('button', { name: 'Save layers' }))
+
+ expect(defaultProps.onSave).toHaveBeenCalledTimes(1)
+ })
+
+ it('reuses the catalog for creation with its own labels and back navigation', async () => {
+ const onPrevious = jest.fn()
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ await userEvent.click(screen.getByRole('button', { name: 'Continue' }))
+ expect(defaultProps.onSave).toHaveBeenCalledTimes(1)
+
+ await userEvent.click(screen.getByRole('button', { name: 'Back' }))
+ expect(onPrevious).toHaveBeenCalledTimes(1)
+ })
+
+ it('hides the template and management modes and keeps components without catalog fields selectable', async () => {
+ const agentTemplate = {
+ ...template,
+ layers: [
+ {
+ ...template.layers[0],
+ key: 'qovery-stack',
+ mandatory: true,
+ modes: ['CUSTOMER_MANAGED', 'QOVERY_MANAGED'],
+ providers: undefined,
+ componentKeys: ['cluster-agent', 'shell-agent'],
+ components: [
+ { key: 'cluster-agent', kind: 'HELM', fields: [] },
+ { key: 'shell-agent', kind: 'HELM', fields: [] },
+ ],
+ },
+ ],
+ } satisfies PlatformTemplateSummaryResponse
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ expect(screen.queryByText('Platform template')).not.toBeInTheDocument()
+ expect(screen.queryByText('CUSTOMER MANAGED')).not.toBeInTheDocument()
+ expect(screen.queryByText('QOVERY MANAGED')).not.toBeInTheDocument()
+
+ // Components without catalog fields stay selectable: their configuration may
+ // consist solely of resolver-provided cluster-input requirements.
+ await userEvent.click(screen.getByRole('button', { name: 'Cluster agent HELM' }))
+ expect(defaultProps.onComponentSelect).toHaveBeenCalledWith('cluster-agent')
+ })
+})
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-catalog.tsx b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-catalog.tsx
new file mode 100644
index 00000000000..30cf864bd43
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-catalog.tsx
@@ -0,0 +1,182 @@
+import {
+ type ClusterPlatformBindingResponse,
+ type PlatformCloudVendor,
+ type PlatformClusterMode,
+ type PlatformTemplateSummaryResponse,
+} from 'qovery-typescript-axios'
+import { match } from 'ts-pattern'
+import { Accordion, Badge, Button, Checkbox, Heading, Icon } from '@qovery/shared/ui'
+import { formatCatalogKey } from '@qovery/shared/util-js'
+
+interface PlatformConfigurationCatalogProps {
+ binding: ClusterPlatformBindingResponse | null | undefined
+ cloudProvider?: PlatformCloudVendor
+ clusterMode?: PlatformClusterMode
+ description?: string
+ layerSelections: Record
+ isSaving: boolean
+ onComponentSelect: (componentKey: string) => void
+ onLayerSelectionChange: (layerKey: string, enabled: boolean) => void
+ onPrevious?: () => void
+ onSave: () => void
+ saveLabel?: string
+ template: PlatformTemplateSummaryResponse
+}
+
+function layerStatusColor(status: ClusterPlatformBindingResponse['layers'][number]['status']) {
+ return match(status)
+ .with('ENABLED', () => 'green' as const)
+ .with('DISABLED', () => 'neutral' as const)
+ .with('SKIPPED', () => 'yellow' as const)
+ .exhaustive()
+}
+
+export function PlatformConfigurationCatalog({
+ binding,
+ cloudProvider,
+ clusterMode,
+ description = 'Enable the layers to include in the next cluster deployment, then configure their components.',
+ layerSelections,
+ isSaving,
+ onComponentSelect,
+ onLayerSelectionChange,
+ onPrevious,
+ onSave,
+ saveLabel = 'Save layers',
+ template,
+}: PlatformConfigurationCatalogProps) {
+ return (
+
+
+
Platform layers
+
{description}
+
+
+
layer.key)}
+ >
+ {template.layers.map((layer) => {
+ const bindingLayer = binding?.layers.find((candidate) => candidate.key === layer.key)
+ const applicableToCluster =
+ (!clusterMode || layer.modes.includes(clusterMode)) &&
+ (!cloudProvider || !layer.providers?.length || layer.providers.includes(cloudProvider))
+ const skipped = bindingLayer?.status === 'SKIPPED' || !applicableToCluster
+ const resolvedEnabled = bindingLayer ? bindingLayer.status === 'ENABLED' : undefined
+ const enabled =
+ !skipped && (layer.mandatory || (layerSelections[layer.key] ?? resolvedEnabled ?? layer.enabledByDefault))
+ const status = skipped ? 'SKIPPED' : enabled ? 'ENABLED' : 'DISABLED'
+
+ return (
+
+
+
+ {formatCatalogKey(layer.key)}
+
+ {layer.mandatory ? 'Mandatory' : 'Optional'}
+
+
+
+ {formatCatalogKey(status)}
+
+
+
+ {layer.description ? {layer.description}
: null}
+
+ {layer.providers?.length ? (
+
+ {layer.providers?.map((provider) => (
+
+ {formatCatalogKey(provider)}
+
+ ))}
+
+ ) : null}
+
+ {!layer.mandatory ? (
+
+ {
+ if (checked === 'indeterminate') return
+ onLayerSelectionChange(layer.key, checked)
+ }}
+ />
+
+ Enable this layer
+
+
+ ) : null}
+
+
+ {layer.components.map((component) => {
+ const content = (
+ <>
+
+ {formatCatalogKey(component.key)}
+ {component.description ? (
+
+ {component.description}
+
+ ) : null}
+
+
+
+ {formatCatalogKey(component.kind)}
+
+
+
+ >
+ )
+
+ // Components without catalog fields stay openable: their configuration
+ // may consist solely of resolver-provided cluster-input requirements.
+ return (
+ onComponentSelect(component.key)}
+ >
+ {content}
+
+ )
+ })}
+
+
+ {skipped ? (
+
+ {bindingLayer?.reason ?? 'Not applicable to this cluster.'}
+
+ ) : null}
+
+
+ )
+ })}
+
+
+
+ {onPrevious ? (
+
+ Back
+
+ ) : (
+
+ )}
+
+ {saveLabel}
+
+
+
+ )
+}
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-feature-flag.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-feature-flag.ts
new file mode 100644
index 00000000000..b3c5e4c382a
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-feature-flag.ts
@@ -0,0 +1 @@
+export const PLATFORM_CONFIGURATION_FEATURE_FLAG = 'engine-v2-platform-configuration'
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.spec.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.spec.ts
new file mode 100644
index 00000000000..e9cb13f6e93
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.spec.ts
@@ -0,0 +1,240 @@
+import {
+ type ClusterPlatformBindingResponse,
+ type FieldSchemaResponse,
+ type PlatformTemplateSummaryResponse,
+} from 'qovery-typescript-axios'
+import {
+ applyPlatformConfigurationDefaults,
+ createPlatformConfigurationDraft,
+ getCurrentPlatformConfigurationPreview,
+ isPlatformConfigurationReady,
+ omitEmptyValues,
+ toCatalogVariableField,
+ toPlatformCloudVendor,
+ toPlatformClusterMode,
+ toPlatformConfigurationValue,
+ updateComponentValue,
+} from './platform-configuration-utils'
+
+const field: FieldSchemaResponse = {
+ key: 'retention',
+ type: 'number',
+ required: true,
+ defaultValue: '12',
+ label: 'Retention',
+ sensitive: false,
+ constraints: {},
+}
+
+describe('platform configuration utils', () => {
+ it('maps cluster API context to the platform catalog context', () => {
+ expect(toPlatformClusterMode('MANAGED')).toBe('QOVERY_MANAGED')
+ expect(toPlatformClusterMode('SELF_MANAGED')).toBe('CUSTOMER_MANAGED')
+ expect(toPlatformClusterMode('PARTIALLY_MANAGED')).toBeUndefined()
+ expect(toPlatformCloudVendor('GCP')).toBe('GCP')
+ expect(toPlatformCloudVendor('ON_PREMISE')).toBe('UNKNOWN')
+ })
+
+ it('converts number inputs at the API boundary without losing their type', () => {
+ expect(toPlatformConfigurationValue(field, '24')).toBe(24)
+ // '' marks a field the user explicitly cleared so defaults are not resurrected.
+ expect(toPlatformConfigurationValue(field, '')).toBe('')
+ })
+
+ it('omits cleared markers from API payloads', () => {
+ expect(omitEmptyValues({ retention: 24, bucket: '', region: undefined, enabled: false })).toEqual({
+ retention: 24,
+ enabled: false,
+ })
+ })
+
+ it('maps the full field vocabulary to the catalog variable contract', () => {
+ expect(
+ toCatalogVariableField({
+ ...field,
+ description: 'Retention period in weeks.',
+ constraints: { allowedValues: ['12', '24'], pattern: '^\\d+$', minLength: 1, maxLength: 3, min: 1, max: 104 },
+ })
+ ).toEqual({
+ key: 'retention',
+ label: 'Retention',
+ type: 'number',
+ description: 'Retention period in weeks.',
+ required: true,
+ sensitive: false,
+ defaultValue: '12',
+ allowedValues: ['12', '24'],
+ pattern: '^\\d+$',
+ minLength: 1,
+ maxLength: 3,
+ min: 1,
+ max: 104,
+ })
+ })
+
+ it('applies catalog defaults before resolving a component configuration', () => {
+ expect(
+ applyPlatformConfigurationDefaults(
+ [
+ field,
+ { ...field, key: 'highAvailability', type: 'bool', defaultValue: 'false' },
+ { ...field, key: 'storage', type: 'string', defaultValue: 'pvc' },
+ ],
+ { retention: 24 }
+ )
+ ).toEqual({
+ retention: 24,
+ highAvailability: false,
+ storage: 'pvc',
+ })
+ })
+
+ it('keeps values from inactive fields and other components when one value changes', () => {
+ const values = {
+ logs: { storage: 'object', inactiveEndpoint: 'https://example.com' },
+ metrics: { retention: '7d' },
+ }
+
+ expect(updateComponentValue(values, 'logs', 'storage', 'persistent-volume')).toEqual({
+ logs: { storage: 'persistent-volume', inactiveEndpoint: 'https://example.com' },
+ metrics: { retention: '7d' },
+ })
+ })
+
+ it('copies an existing binding into a new draft', () => {
+ const template = {
+ key: 'default',
+ version: '1.0.0',
+ status: 'PUBLISHED',
+ layers: [],
+ } satisfies PlatformTemplateSummaryResponse
+ const binding = {
+ clusterId: 'cluster-id',
+ organizationId: 'organization-id',
+ templateKey: 'default',
+ templateVersion: '1.0.0',
+ layerSelections: { observability: true },
+ managedConfig: { logs: { storage: 'object' } },
+ customerProvidedInputs: { logs: { endpoint: 'https://example.com' } },
+ layers: [],
+ } satisfies ClusterPlatformBindingResponse
+
+ expect(createPlatformConfigurationDraft(template, binding)).toEqual({
+ templateKey: 'default',
+ templateVersion: '1.0.0',
+ layerSelections: { observability: true },
+ managedConfig: { logs: { storage: 'object' } },
+ customerProvidedInputs: { logs: { endpoint: 'https://example.com' } },
+ })
+ })
+
+ it('hydrates optional selections from the resolved binding status', () => {
+ const template = {
+ key: 'default',
+ version: '1.0.0',
+ status: 'PUBLISHED',
+ layers: [
+ {
+ key: 'logs',
+ mandatory: false,
+ enabledByDefault: false,
+ modes: ['CUSTOMER_MANAGED'],
+ componentKeys: [],
+ components: [],
+ },
+ ],
+ } satisfies PlatformTemplateSummaryResponse
+ const binding = {
+ clusterId: 'cluster-id',
+ organizationId: 'organization-id',
+ templateKey: 'previous-template',
+ templateVersion: '0.1.0',
+ layerSelections: {},
+ managedConfig: {},
+ customerProvidedInputs: {},
+ layers: [
+ {
+ key: 'logs',
+ status: 'ENABLED',
+ reason: 'optional layer enabled',
+ componentKeys: [],
+ },
+ ],
+ } satisfies ClusterPlatformBindingResponse
+
+ expect(createPlatformConfigurationDraft(template, binding).layerSelections).toEqual({ logs: true })
+ })
+
+ it('seeds optional layer defaults when creating a binding draft', () => {
+ const template = {
+ key: 'default',
+ version: '1.0.0',
+ status: 'PUBLISHED',
+ layers: [
+ {
+ key: 'logs',
+ mandatory: false,
+ enabledByDefault: true,
+ modes: ['CUSTOMER_MANAGED'],
+ componentKeys: [],
+ components: [],
+ },
+ ],
+ } satisfies PlatformTemplateSummaryResponse
+
+ expect(createPlatformConfigurationDraft(template, null).layerSelections).toEqual({ logs: true })
+ })
+
+ it('only accepts a preview for the current settled component request', () => {
+ const preview = {
+ clusterId: 'cluster-id',
+ componentKey: 'loki',
+ fields: [],
+ requirements: [],
+ componentBindings: [],
+ violations: [],
+ }
+
+ expect(getCurrentPlatformConfigurationPreview(preview, 'loki', false)).toBe(preview)
+ expect(getCurrentPlatformConfigurationPreview(preview, 'loki', true)).toBeUndefined()
+ expect(getCurrentPlatformConfigurationPreview(preview, 'prometheus', false)).toBeUndefined()
+ })
+
+ it('is ready only when requirements are ready and there are no violations', () => {
+ expect(
+ isPlatformConfigurationReady(
+ [],
+ [
+ {
+ key: 'endpoint',
+ type: 'string',
+ scope: 'CLUSTER',
+ label: 'Endpoint',
+ required: true,
+ sensitive: false,
+ constraints: {},
+ status: 'READY',
+ },
+ ]
+ )
+ ).toBe(true)
+
+ expect(
+ isPlatformConfigurationReady(
+ [],
+ [
+ {
+ key: 'endpoint',
+ type: 'string',
+ scope: 'CLUSTER',
+ label: 'Endpoint',
+ required: true,
+ sensitive: false,
+ constraints: {},
+ status: 'MISSING',
+ },
+ ]
+ )
+ ).toBe(false)
+ })
+})
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.ts
new file mode 100644
index 00000000000..7640ab35f65
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.ts
@@ -0,0 +1,196 @@
+import {
+ type CloudProviderEnum,
+ type CloudVendorEnum,
+ type ClusterPlatformBindingResponse,
+ type FieldSchemaResponse,
+ type KubernetesEnum,
+ type PlatformCloudVendor,
+ type PlatformClusterMode,
+ type PlatformComponentConfigurationResolutionResponse,
+ type PlatformComponentConfigurationViolationResponse,
+ type PlatformComponentInputRequirementResponse,
+ type PlatformTemplateSummaryResponse,
+} from 'qovery-typescript-axios'
+import { match } from 'ts-pattern'
+import { type CatalogVariableField, type CatalogVariableValue, getCatalogVariableValue } from '@qovery/shared/util-js'
+
+export interface PlatformConfigurationDraft {
+ templateKey: string
+ templateVersion: string
+ layerSelections: Record
+ managedConfig: Record>
+ customerProvidedInputs: Record>
+}
+
+export function toPlatformCloudVendor(
+ cloudProvider: CloudProviderEnum | CloudVendorEnum | undefined
+): PlatformCloudVendor | undefined {
+ return match(cloudProvider)
+ .with(undefined, () => undefined)
+ .with('ON_PREMISE', () => 'UNKNOWN' as const)
+ .otherwise((provider) => provider)
+}
+
+export function toPlatformClusterMode(kubernetes: KubernetesEnum | undefined): PlatformClusterMode | undefined {
+ return match(kubernetes)
+ .with('MANAGED', () => 'QOVERY_MANAGED' as const)
+ .with('SELF_MANAGED', () => 'CUSTOMER_MANAGED' as const)
+ .otherwise(() => undefined)
+}
+
+type PlatformFieldDescriptor = Pick<
+ FieldSchemaResponse,
+ 'key' | 'label' | 'type' | 'description' | 'sensitive' | 'required' | 'constraints'
+> &
+ Partial>
+
+export function getTemplateId(template: Pick) {
+ return `${template.key}@${template.version}`
+}
+
+export function createPlatformConfigurationDraft(
+ template: PlatformTemplateSummaryResponse,
+ binding: ClusterPlatformBindingResponse | null | undefined
+): PlatformConfigurationDraft {
+ const defaultLayerSelections = Object.fromEntries(
+ template.layers.flatMap((layer) => (layer.mandatory ? [] : [[layer.key, layer.enabledByDefault]]))
+ )
+ const resolvedLayerSelections = Object.fromEntries(
+ template.layers.flatMap((layer) => {
+ if (layer.mandatory) return []
+
+ const status = binding?.layers.find((candidate) => candidate.key === layer.key)?.status
+ if (status === 'ENABLED') return [[layer.key, true]]
+ if (status === 'DISABLED') return [[layer.key, false]]
+ return []
+ })
+ )
+
+ if (binding && binding.templateKey === template.key && binding.templateVersion === template.version) {
+ return {
+ templateKey: binding.templateKey,
+ templateVersion: binding.templateVersion,
+ layerSelections: { ...defaultLayerSelections, ...binding.layerSelections, ...resolvedLayerSelections },
+ managedConfig: binding.managedConfig,
+ customerProvidedInputs: binding.customerProvidedInputs,
+ }
+ }
+
+ return {
+ templateKey: template.key,
+ templateVersion: template.version,
+ layerSelections: { ...defaultLayerSelections, ...resolvedLayerSelections },
+ managedConfig: {},
+ customerProvidedInputs: {},
+ }
+}
+
+export function findPlatformComponent(template: PlatformTemplateSummaryResponse, componentKey?: string) {
+ return template.layers.flatMap((layer) => layer.components).find((component) => component.key === componentKey)
+}
+
+export function getCurrentPlatformConfigurationPreview(
+ preview: PlatformComponentConfigurationResolutionResponse | undefined,
+ componentKey: string | undefined,
+ isPreviewPending: boolean
+) {
+ if (isPreviewPending || preview?.componentKey !== componentKey) return undefined
+ return preview
+}
+
+export function toCatalogVariableField(field: PlatformFieldDescriptor): CatalogVariableField {
+ return {
+ key: field.key,
+ label: field.label,
+ type: field.type,
+ description: field.description ?? undefined,
+ required: field.required,
+ sensitive: field.sensitive,
+ defaultValue: field.defaultValue ?? undefined,
+ allowedValues: field.constraints.allowedValues ?? undefined,
+ pattern: field.constraints.pattern ?? undefined,
+ minLength: field.constraints.minLength ?? undefined,
+ maxLength: field.constraints.maxLength ?? undefined,
+ min: field.constraints.min ?? undefined,
+ max: field.constraints.max ?? undefined,
+ }
+}
+
+export function applyPlatformConfigurationDefaults(
+ fields: FieldSchemaResponse[],
+ values: Record
+): Record {
+ const defaultValues = Object.fromEntries(
+ fields.flatMap((field) => {
+ const defaultValue = getCatalogVariableValue(field, undefined)
+ if (defaultValue === undefined) return []
+
+ return [[field.key, toPlatformConfigurationValue(field, defaultValue)]]
+ })
+ )
+
+ return { ...defaultValues, ...values }
+}
+
+export function toPlatformConfigurationValue(field: Pick, value: CatalogVariableValue) {
+ if (field.type !== 'number') return value
+ if (typeof value !== 'string') return undefined
+ // Keep the empty string: it marks a field the user explicitly cleared, so
+ // applyPlatformConfigurationDefaults must not resurrect the schema default.
+ if (value.trim() === '') return ''
+
+ const numberValue = Number(value)
+ return Number.isFinite(numberValue) ? numberValue : value
+}
+
+export function omitEmptyValues(values: Record): Record {
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== '' && value !== undefined))
+}
+
+export function updateComponentValue(
+ valuesByComponent: Record>,
+ componentKey: string,
+ fieldKey: string,
+ value: T | undefined
+) {
+ const componentValues = { ...valuesByComponent[componentKey] }
+
+ if (value === undefined) {
+ delete componentValues[fieldKey]
+ } else {
+ componentValues[fieldKey] = value
+ }
+
+ return {
+ ...valuesByComponent,
+ [componentKey]: componentValues,
+ }
+}
+
+export function getFieldViolation(
+ violations: PlatformComponentConfigurationViolationResponse[],
+ fieldKey: string,
+ source?: 'clusterInputs'
+) {
+ const expectedPath = source ? `${source}.${fieldKey}` : fieldKey
+ return violations.find((violation) => violation.fieldPath === expectedPath)?.message
+}
+
+export function getUnmappedViolations(
+ violations: PlatformComponentConfigurationViolationResponse[],
+ fields: FieldSchemaResponse[],
+ requirements: PlatformComponentInputRequirementResponse[]
+) {
+ const mappedPaths = new Set([
+ ...fields.map((field) => field.key),
+ ...requirements.map((requirement) => `clusterInputs.${requirement.key}`),
+ ])
+ return violations.filter((violation) => !mappedPaths.has(violation.fieldPath))
+}
+
+export function isPlatformConfigurationReady(
+ violations: PlatformComponentConfigurationViolationResponse[],
+ requirements: PlatformComponentInputRequirementResponse[]
+) {
+ return violations.length === 0 && requirements.every((requirement) => requirement.status === 'READY')
+}
diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration.tsx b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration.tsx
new file mode 100644
index 00000000000..91d7963691d
--- /dev/null
+++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration.tsx
@@ -0,0 +1,251 @@
+import { type PlatformCloudVendor, type PlatformClusterMode } from 'qovery-typescript-axios'
+import { useEffect, useMemo, useState } from 'react'
+import { Button, Callout, Icon } from '@qovery/shared/ui'
+import { useDebounce } from '@qovery/shared/util-hooks'
+import { type CatalogVariableValue } from '@qovery/shared/util-js'
+import { usePlatformBinding } from './hooks/use-platform-binding'
+import { usePlatformComponentConfiguration } from './hooks/use-platform-component-configuration'
+import { usePlatformTemplates } from './hooks/use-platform-templates'
+import { useUpdatePlatformBinding } from './hooks/use-update-platform-binding'
+import { PlatformComponentConfiguration } from './platform-component-configuration'
+import { PlatformConfigurationCatalog } from './platform-configuration-catalog'
+import {
+ type PlatformConfigurationDraft,
+ applyPlatformConfigurationDefaults,
+ createPlatformConfigurationDraft,
+ findPlatformComponent,
+ getCurrentPlatformConfigurationPreview,
+ getTemplateId,
+ omitEmptyValues,
+ toPlatformConfigurationValue,
+ updateComponentValue,
+} from './platform-configuration-utils'
+
+interface PlatformConfigurationProps {
+ clusterId: string
+ cloudProvider: PlatformCloudVendor
+ clusterMode: PlatformClusterMode
+ organizationId: string
+}
+
+interface PlatformConfigurationState {
+ componentKey?: string
+ draft: PlatformConfigurationDraft
+ templateId: string
+}
+
+export function PlatformConfiguration({
+ clusterId,
+ cloudProvider,
+ clusterMode,
+ organizationId,
+}: PlatformConfigurationProps) {
+ const { data: templates = [] } = usePlatformTemplates({
+ organizationId,
+ clusterMode,
+ cloudProvider,
+ suspense: true,
+ })
+ const { data: binding } = usePlatformBinding({ organizationId, clusterId, suspense: true })
+ const { mutate: updateBinding, isLoading: isSaving } = useUpdatePlatformBinding()
+
+ const [state, setState] = useState(() => {
+ const template =
+ templates.find(
+ (candidate) => candidate.key === binding?.templateKey && candidate.version === binding.templateVersion
+ ) ?? templates[0]
+ if (!template) return null
+
+ return {
+ templateId: getTemplateId(template),
+ draft: createPlatformConfigurationDraft(template, binding),
+ }
+ })
+
+ const selectedTemplate = templates.find((template) => getTemplateId(template) === state?.templateId)
+ const selectedComponent = selectedTemplate ? findPlatformComponent(selectedTemplate, state?.componentKey) : undefined
+
+ // Re-seed when the selected template disappears from the list (e.g. the template
+ // version was bumped between refetches, or templates arrived after an empty list).
+ useEffect(() => {
+ if (selectedTemplate || templates.length === 0) return
+
+ const template =
+ templates.find(
+ (candidate) => candidate.key === binding?.templateKey && candidate.version === binding.templateVersion
+ ) ?? templates[0]
+ setState({
+ templateId: getTemplateId(template),
+ draft: createPlatformConfigurationDraft(template, binding),
+ })
+ }, [selectedTemplate, templates, binding])
+
+ // profileConfig drives the form display and may contain '' for fields the user
+ // explicitly cleared; the resolver request must omit those so a cleared required
+ // field surfaces as a violation instead of silently resurrecting its default.
+ const { profileConfig, clusterInputs } = useMemo(() => {
+ const componentKey = selectedComponent?.key
+ return {
+ profileConfig: selectedComponent
+ ? applyPlatformConfigurationDefaults(
+ selectedComponent.fields,
+ state && componentKey ? state.draft.managedConfig[componentKey] ?? {} : {}
+ )
+ : {},
+ clusterInputs: state && componentKey ? state.draft.customerProvidedInputs[componentKey] ?? {} : {},
+ }
+ }, [selectedComponent, state])
+ const previewRequest = useMemo(
+ () => ({
+ profileConfig: omitEmptyValues(profileConfig),
+ clusterInputs,
+ componentOutputs: {},
+ }),
+ [profileConfig, clusterInputs]
+ )
+ const previewQuery = useMemo(
+ () => ({ componentKey: selectedComponent?.key, request: previewRequest }),
+ [previewRequest, selectedComponent?.key]
+ )
+ const debouncedPreviewQuery = useDebounce(previewQuery, 300)
+ const isPreviewPending = debouncedPreviewQuery !== previewQuery
+ const {
+ data: previewData,
+ isError: hasPreviewError,
+ isFetching,
+ } = usePlatformComponentConfiguration({
+ organizationId,
+ clusterId,
+ componentKey: debouncedPreviewQuery.componentKey,
+ request: debouncedPreviewQuery.request,
+ enabled: Boolean(selectedComponent) && debouncedPreviewQuery.componentKey === selectedComponent?.key,
+ })
+ const preview = getCurrentPlatformConfigurationPreview(previewData, selectedComponent?.key, isPreviewPending)
+
+ if (!state || !selectedTemplate) {
+ return (
+
+
+
+
+ No platform template is available for this organization.
+
+ )
+ }
+
+ const updateProfileConfig = (fieldKey: string, value: CatalogVariableValue) => {
+ if (!selectedComponent) return
+
+ const field = (preview?.fields ?? selectedComponent.fields).find((candidate) => candidate.key === fieldKey)
+ if (!field) return
+
+ setState((current) =>
+ current
+ ? {
+ ...current,
+ draft: {
+ ...current.draft,
+ managedConfig: updateComponentValue(
+ current.draft.managedConfig,
+ selectedComponent.key,
+ fieldKey,
+ toPlatformConfigurationValue(field, value)
+ ),
+ },
+ }
+ : current
+ )
+ }
+
+ const updateClusterInput = (fieldKey: string, value: CatalogVariableValue) => {
+ if (!selectedComponent) return
+
+ setState((current) =>
+ current
+ ? {
+ ...current,
+ draft: {
+ ...current.draft,
+ customerProvidedInputs: updateComponentValue(
+ current.draft.customerProvidedInputs,
+ selectedComponent.key,
+ fieldKey,
+ String(value)
+ ),
+ },
+ }
+ : current
+ )
+ }
+
+ const saveConfiguration = () =>
+ updateBinding({
+ organizationId,
+ clusterId,
+ request: {
+ ...state.draft,
+ // '' entries are only display markers for cleared fields — never persist them.
+ managedConfig: Object.fromEntries(
+ Object.entries(state.draft.managedConfig).map(([componentKey, values]) => [
+ componentKey,
+ omitEmptyValues(values),
+ ])
+ ),
+ },
+ })
+
+ if (!selectedComponent) {
+ return (
+ setState((current) => (current ? { ...current, componentKey } : current))}
+ onLayerSelectionChange={(layerKey, enabled) =>
+ setState((current) =>
+ current
+ ? {
+ ...current,
+ draft: {
+ ...current.draft,
+ layerSelections: { ...current.draft.layerSelections, [layerKey]: enabled },
+ },
+ }
+ : current
+ )
+ }
+ onSave={saveConfiguration}
+ />
+ )
+ }
+
+ return (
+
+
setState((current) => (current ? { ...current, componentKey: undefined } : current))}
+ >
+
+ Platform layers
+
+
+
+ )
+}
diff --git a/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.ts b/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.ts
index 761bda23046..00b8e1485a7 100644
--- a/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.ts
+++ b/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.ts
@@ -3,8 +3,22 @@ import {
type BlueprintManifestResponseResultsInner,
type BlueprintManifestVariableField,
} from 'qovery-typescript-axios'
-
-export type BlueprintFieldValue = string | boolean
+import {
+ type CatalogVariableField,
+ type CatalogVariableValue,
+ formatCatalogKey,
+ getCatalogBooleanValue,
+ getCatalogFieldLengthValidationError,
+ getCatalogFieldValidationError,
+ getCatalogStringValue,
+ getCatalogSummaryFieldValue,
+ getCatalogVariableValue,
+ isCatalogFieldValid,
+ isCatalogFieldValueFulfilled,
+ isCatalogFieldValueMatchingPattern,
+} from '@qovery/shared/util-js'
+
+export type BlueprintFieldValue = CatalogVariableValue
export type BlueprintFieldValues = Record
export type BlueprintFieldPath = `fields.${string}`
@@ -17,13 +31,30 @@ export function getBlueprintFieldPath(name: string): BlueprintFieldPath {
}
export function formatFieldLabel(name: string) {
- const label = name.replace(/_/g, ' ')
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}`
+ return formatCatalogKey(name)
+}
+
+export function toCatalogVariableField(field: BlueprintManifestVariableField, label?: string): CatalogVariableField {
+ return {
+ key: field.name,
+ label: label ?? formatFieldLabel(field.name),
+ type: field.type.type,
+ description: field.description ?? undefined,
+ required: field.required,
+ sensitive: field.is_secret,
+ defaultValue: field.default_value ?? undefined,
+ allowedValues: field.allowed_values ?? undefined,
+ pattern: field.type.pattern ?? undefined,
+ minLength: field.type.min_length ?? undefined,
+ maxLength: field.type.max_length ?? undefined,
+ min: field.type.min ?? undefined,
+ max: field.type.max ?? undefined,
+ }
}
export function getDefaultFieldValue(field: BlueprintManifestVariableField): BlueprintFieldValue {
- if (field.type.type === 'bool') return field.default_value === 'true'
- return field.default_value ?? ''
+ const defaultValue = getCatalogVariableValue({ type: field.type.type, defaultValue: field.default_value }, undefined)
+ return defaultValue ?? (field.type.type === 'bool' ? false : '')
}
export function getDefaultContextFieldValue(field: BlueprintManifestContextVariableField): BlueprintFieldValue {
@@ -45,62 +76,37 @@ export function getDefaultBlueprintFieldValues(blueprintManifestFields: Blueprin
}
export function getStringFieldValue(value: BlueprintFieldValue | undefined) {
- return typeof value === 'string' ? value : ''
+ return getCatalogStringValue(value)
}
export function getBooleanFieldValue(value: BlueprintFieldValue | undefined) {
- return typeof value === 'boolean' ? value : false
+ return getCatalogBooleanValue(value)
}
export function isFieldValueFulfilled(value: BlueprintFieldValue | undefined) {
- if (typeof value === 'boolean') return true
- return Boolean(value?.trim())
+ return isCatalogFieldValueFulfilled(value)
}
export function isFieldValueMatchingPattern(
field: BlueprintManifestVariableField,
value: BlueprintFieldValue | undefined
) {
- if (typeof value !== 'string' || !value || !field.type.pattern) return true
-
- try {
- return new RegExp(field.type.pattern).test(value)
- } catch {
- return true
- }
+ return isCatalogFieldValueMatchingPattern(toCatalogVariableField(field), value)
}
export function getFieldLengthValidationError(
field: BlueprintManifestVariableField,
value: BlueprintFieldValue | undefined
) {
- if (typeof value !== 'string' || !value) return undefined
-
- const { min_length: minLength, max_length: maxLength } = field.type
- const hasMinLength = typeof minLength === 'number'
- const hasMaxLength = typeof maxLength === 'number'
-
- if (hasMinLength && hasMaxLength && (value.length < minLength || value.length > maxLength)) {
- return `Value must be between ${minLength} and ${maxLength} characters.`
- }
-
- if (hasMinLength && value.length < minLength) return `Value must be at least ${minLength} characters.`
- if (hasMaxLength && value.length > maxLength) return `Value must be at most ${maxLength} characters.`
-
- return undefined
+ return getCatalogFieldLengthValidationError(toCatalogVariableField(field), value)
}
export function getFieldValidationError(field: BlueprintManifestVariableField, value: BlueprintFieldValue | undefined) {
- const lengthValidationError = getFieldLengthValidationError(field, value)
- if (lengthValidationError) return lengthValidationError
-
- if (!isFieldValueMatchingPattern(field, value)) return 'Value does not match the expected format.'
- return undefined
+ return getCatalogFieldValidationError(toCatalogVariableField(field), value)
}
export function isFieldValid(field: BlueprintManifestVariableField, value: BlueprintFieldValue | undefined) {
- if (field.required && !isFieldValueFulfilled(value)) return false
- return !getFieldValidationError(field, value)
+ return isCatalogFieldValid(toCatalogVariableField(field), value)
}
export function isVariableField(field: BlueprintManifestResponseResultsInner): field is BlueprintManifestVariableField {
@@ -129,7 +135,5 @@ export function getSummaryFieldValue(
field: BlueprintManifestVariableField | OverridableBlueprintManifestContextVariableField,
value: BlueprintFieldValue | undefined
) {
- if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled'
- if (field.kind === 'variable' && field.is_secret && value) return '••••••••'
- return value
+ return getCatalogSummaryFieldValue({ sensitive: field.kind === 'variable' && field.is_secret }, value)
}
diff --git a/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx b/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx
index 16af7cce676..fd98290d5bd 100644
--- a/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx
+++ b/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx
@@ -1,11 +1,6 @@
import { type BlueprintManifestVariableField } from 'qovery-typescript-axios'
-import { InputSelect, InputText, InputToggle } from '@qovery/shared/ui'
-import {
- type BlueprintFieldValue,
- formatFieldLabel,
- getBooleanFieldValue,
- getStringFieldValue,
-} from '../blueprint-field-utils/blueprint-field-utils'
+import { CatalogVariableInput } from '@qovery/shared/ui'
+import { type BlueprintFieldValue, toCatalogVariableField } from '../blueprint-field-utils/blueprint-field-utils'
export interface BlueprintManifestVariableInputProps {
autoFocus?: boolean
@@ -24,51 +19,13 @@ export function BlueprintManifestVariableInput({
onChange,
value,
}: BlueprintManifestVariableInputProps) {
- const inputLabel = label ?? formatFieldLabel(field.name)
-
- if (field.type.type === 'bool') {
- return (
-
-
-
- )
- }
-
- if (field.allowed_values?.length) {
- return (
- ({ label: allowedValue, value: allowedValue }))}
- autoFocus={autoFocus}
- onChange={(value) => {
- if (Array.isArray(value)) return
- onChange(value)
- }}
- />
- )
- }
-
return (
- onChange(event.currentTarget.value)}
+ onChange={onChange}
/>
)
}
diff --git a/libs/shared/ui/src/index.ts b/libs/shared/ui/src/index.ts
index 7490bb68541..36b6617acef 100644
--- a/libs/shared/ui/src/index.ts
+++ b/libs/shared/ui/src/index.ts
@@ -1,5 +1,6 @@
export * from './lib/components/action-toolbar/action-toolbar'
export * from './lib/components/callout/callout'
+export * from './lib/components/catalog-variable-input/catalog-variable-input'
export * from './lib/components/button/button'
export * from './lib/components/button-primitive/button-primitive'
export * from './lib/components/copy-button/copy-button'
diff --git a/libs/shared/ui/src/lib/components/catalog-variable-input/catalog-variable-input.spec.tsx b/libs/shared/ui/src/lib/components/catalog-variable-input/catalog-variable-input.spec.tsx
new file mode 100644
index 00000000000..47c68b789fb
--- /dev/null
+++ b/libs/shared/ui/src/lib/components/catalog-variable-input/catalog-variable-input.spec.tsx
@@ -0,0 +1,94 @@
+import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import { CatalogVariableInput, type CatalogVariableInputProps } from './catalog-variable-input'
+
+const defaultProps: CatalogVariableInputProps = {
+ field: {
+ key: 'retention',
+ label: 'Retention',
+ type: 'number',
+ description: 'Retention period in weeks.',
+ },
+ onChange: jest.fn(),
+ value: '12',
+}
+
+describe('CatalogVariableInput', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it('renders a number field with its description and validation error', () => {
+ renderWithProviders( )
+
+ expect(screen.getByRole('spinbutton', { name: 'Retention' })).toHaveValue(12)
+ expect(screen.getByText('Retention is invalid.')).toBeInTheDocument()
+ expect(screen.queryByText('Retention period in weeks.')).not.toBeInTheDocument()
+ })
+
+ it('renders sensitive values as passwords', () => {
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByLabelText('Token')).toHaveAttribute('type', 'password')
+ })
+
+ it('keeps the toggle presentation as the default for boolean fields', async () => {
+ const onChange = jest.fn()
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ await userEvent.click(screen.getByRole('switch', { name: 'Enabled' }))
+
+ expect(onChange).toHaveBeenCalledWith(true)
+ expect(screen.getByText('Enable this option.')).toBeInTheDocument()
+ })
+
+ it('renders a boolean control even when the field declares allowed values', async () => {
+ const onChange = jest.fn()
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ await userEvent.click(screen.getByRole('switch', { name: 'Enabled' }))
+
+ expect(onChange).toHaveBeenCalledWith(false)
+ })
+
+ it('supports a checkbox presentation for boolean fields', async () => {
+ const onChange = jest.fn()
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Enabled' }))
+
+ expect(onChange).toHaveBeenCalledWith(true)
+ expect(screen.getByText('Enable this option.')).toBeInTheDocument()
+ })
+})
diff --git a/libs/shared/ui/src/lib/components/catalog-variable-input/catalog-variable-input.tsx b/libs/shared/ui/src/lib/components/catalog-variable-input/catalog-variable-input.tsx
new file mode 100644
index 00000000000..b4786d65019
--- /dev/null
+++ b/libs/shared/ui/src/lib/components/catalog-variable-input/catalog-variable-input.tsx
@@ -0,0 +1,100 @@
+import { type CatalogVariableField, type CatalogVariableValue } from '@qovery/shared/util-js'
+import { Checkbox } from '../checkbox/checkbox'
+import { InputSelect } from '../inputs/input-select/input-select'
+import { InputText } from '../inputs/input-text/input-text'
+import { InputToggle } from '../inputs/input-toggle/input-toggle'
+
+export interface CatalogVariableInputProps {
+ autoFocus?: boolean
+ booleanControl?: 'checkbox' | 'toggle'
+ error?: string
+ field: CatalogVariableField
+ onChange: (value: CatalogVariableValue) => void
+ value: CatalogVariableValue | undefined
+}
+
+export function CatalogVariableInput({
+ autoFocus,
+ booleanControl = 'toggle',
+ error,
+ field,
+ onChange,
+ value,
+}: CatalogVariableInputProps) {
+ // Bool fields take precedence over allowedValues: a bool field carrying allowed
+ // values must keep emitting booleans, not the allowed-value strings.
+ if (field.type === 'bool') {
+ if (booleanControl === 'toggle') {
+ return (
+
+
+ {error ?
{error}
: null}
+
+ )
+ }
+
+ return (
+
+
+ {
+ if (checked === 'indeterminate') return
+ onChange(checked)
+ }}
+ />
+
+ {field.label}
+
+
+ {field.description ? (
+
{field.description}
+ ) : null}
+ {error ?
{error}
: null}
+
+ )
+ }
+
+ if (field.allowedValues?.length) {
+ return (
+ ({ label: allowedValue, value: allowedValue }))}
+ hint={field.description}
+ error={error}
+ autoFocus={autoFocus}
+ onChange={(value) => {
+ if (Array.isArray(value)) return
+ onChange(value)
+ }}
+ />
+ )
+ }
+
+ return (
+ onChange(event.currentTarget.value)}
+ />
+ )
+}
diff --git a/libs/shared/util-js/src/index.ts b/libs/shared/util-js/src/index.ts
index 0c48d3e8f10..b9a9973aadf 100644
--- a/libs/shared/util-js/src/index.ts
+++ b/libs/shared/util-js/src/index.ts
@@ -1,4 +1,5 @@
export * from './lib/build-git-repo-url'
+export * from './lib/catalog-variable-field'
export * from './lib/compute-available-environment-variable-scope'
export * from './lib/container-registry-kind-to-icon'
export * from './lib/convert-memory-size'
diff --git a/libs/shared/util-js/src/lib/catalog-variable-field.spec.ts b/libs/shared/util-js/src/lib/catalog-variable-field.spec.ts
new file mode 100644
index 00000000000..cc7a26ce932
--- /dev/null
+++ b/libs/shared/util-js/src/lib/catalog-variable-field.spec.ts
@@ -0,0 +1,95 @@
+import {
+ formatCatalogKey,
+ getCatalogBooleanValue,
+ getCatalogFieldLengthValidationError,
+ getCatalogFieldValidationError,
+ getCatalogStringValue,
+ getCatalogSummaryFieldValue,
+ getCatalogVariableValue,
+ isCatalogFieldValid,
+ isCatalogFieldValueFulfilled,
+ isCatalogFieldValueMatchingPattern,
+} from './catalog-variable-field'
+
+describe('formatCatalogKey', () => {
+ it('formats snake_case and kebab-case keys into a readable label', () => {
+ expect(formatCatalogKey('database_name')).toBe('Database name')
+ expect(formatCatalogKey('log-storage')).toBe('Log storage')
+ expect(formatCatalogKey('CUSTOMER_MANAGED')).toBe('CUSTOMER MANAGED')
+ })
+})
+
+describe('catalog value coercion', () => {
+ it('returns string and boolean values with safe fallbacks', () => {
+ expect(getCatalogStringValue('value')).toBe('value')
+ expect(getCatalogStringValue(true)).toBe('')
+ expect(getCatalogStringValue(undefined)).toBe('')
+ expect(getCatalogBooleanValue(true)).toBe(true)
+ expect(getCatalogBooleanValue('value')).toBe(false)
+ expect(getCatalogBooleanValue(undefined)).toBe(false)
+ })
+
+ it('resolves values against the field default without losing their type', () => {
+ expect(getCatalogVariableValue({ type: 'number', defaultValue: '12' }, undefined)).toBe('12')
+ expect(getCatalogVariableValue({ type: 'number', defaultValue: '12' }, 24)).toBe('24')
+ expect(getCatalogVariableValue({ type: 'bool', defaultValue: 'true' }, undefined)).toBe(true)
+ expect(getCatalogVariableValue({ type: 'bool' }, 'false')).toBe(false)
+ expect(getCatalogVariableValue({ type: 'bool' }, undefined)).toBeUndefined()
+ expect(getCatalogVariableValue({ type: 'string', defaultValue: null }, undefined)).toBeUndefined()
+ expect(getCatalogVariableValue({ type: 'string' }, { unexpected: true })).toBeUndefined()
+ })
+})
+
+describe('catalog field validation', () => {
+ it('treats booleans and non-blank strings as fulfilled', () => {
+ expect(isCatalogFieldValueFulfilled(false)).toBe(true)
+ expect(isCatalogFieldValueFulfilled('value')).toBe(true)
+ expect(isCatalogFieldValueFulfilled(' ')).toBe(false)
+ expect(isCatalogFieldValueFulfilled(undefined)).toBe(false)
+ })
+
+ it('matches values against the field pattern and ignores broken patterns', () => {
+ expect(isCatalogFieldValueMatchingPattern({ pattern: '^[a-z]+$' }, 'value')).toBe(true)
+ expect(isCatalogFieldValueMatchingPattern({ pattern: '^[a-z]+$' }, 'Value')).toBe(false)
+ expect(isCatalogFieldValueMatchingPattern({ pattern: '(' }, 'value')).toBe(true)
+ expect(isCatalogFieldValueMatchingPattern({}, 'value')).toBe(true)
+ expect(isCatalogFieldValueMatchingPattern({ pattern: '^[a-z]+$' }, undefined)).toBe(true)
+ })
+
+ it('reports length violations with a dedicated message', () => {
+ expect(getCatalogFieldLengthValidationError({ minLength: 2, maxLength: 4 }, 'value')).toBe(
+ 'Value must be between 2 and 4 characters.'
+ )
+ expect(getCatalogFieldLengthValidationError({ minLength: 8 }, 'value')).toBe('Value must be at least 8 characters.')
+ expect(getCatalogFieldLengthValidationError({ maxLength: 3 }, 'value')).toBe('Value must be at most 3 characters.')
+ expect(getCatalogFieldLengthValidationError({ minLength: 2, maxLength: 8 }, 'value')).toBeUndefined()
+ expect(getCatalogFieldLengthValidationError({ minLength: 2 }, undefined)).toBeUndefined()
+ })
+
+ it('combines length and pattern checks into one validation error', () => {
+ expect(getCatalogFieldValidationError({ maxLength: 3, pattern: '^[a-z]+$' }, 'value')).toBe(
+ 'Value must be at most 3 characters.'
+ )
+ expect(getCatalogFieldValidationError({ pattern: '^[a-z]+$' }, 'Value')).toBe(
+ 'Value does not match the expected format.'
+ )
+ expect(getCatalogFieldValidationError({ pattern: '^[a-z]+$' }, 'value')).toBeUndefined()
+ })
+
+ it('validates required state and constraints together', () => {
+ expect(isCatalogFieldValid({ required: true }, undefined)).toBe(false)
+ expect(isCatalogFieldValid({ required: true }, 'value')).toBe(true)
+ expect(isCatalogFieldValid({ required: false, pattern: '^[a-z]+$' }, 'Value')).toBe(false)
+ expect(isCatalogFieldValid({ required: false }, undefined)).toBe(true)
+ })
+})
+
+describe('getCatalogSummaryFieldValue', () => {
+ it('renders booleans as enabled state and masks sensitive values', () => {
+ expect(getCatalogSummaryFieldValue({}, true)).toBe('Enabled')
+ expect(getCatalogSummaryFieldValue({}, false)).toBe('Disabled')
+ expect(getCatalogSummaryFieldValue({ sensitive: true }, 'secret')).toBe('••••••••')
+ expect(getCatalogSummaryFieldValue({ sensitive: true }, '')).toBe('')
+ expect(getCatalogSummaryFieldValue({ sensitive: false }, 'value')).toBe('value')
+ })
+})
diff --git a/libs/shared/util-js/src/lib/catalog-variable-field.ts b/libs/shared/util-js/src/lib/catalog-variable-field.ts
new file mode 100644
index 00000000000..df37d2508af
--- /dev/null
+++ b/libs/shared/util-js/src/lib/catalog-variable-field.ts
@@ -0,0 +1,111 @@
+export type CatalogVariableValue = string | boolean
+
+export interface CatalogVariableField {
+ key: string
+ label: string
+ type: string
+ description?: string
+ required?: boolean
+ sensitive?: boolean
+ defaultValue?: string
+ allowedValues?: string[]
+ pattern?: string
+ minLength?: number
+ maxLength?: number
+ min?: number
+ max?: number
+}
+
+export function formatCatalogKey(key: string) {
+ const label = key.replace(/[-_]/g, ' ')
+ return `${label.charAt(0).toUpperCase()}${label.slice(1)}`
+}
+
+export function getCatalogStringValue(value: CatalogVariableValue | undefined) {
+ return typeof value === 'string' ? value : ''
+}
+
+export function getCatalogBooleanValue(value: CatalogVariableValue | undefined) {
+ return typeof value === 'boolean' ? value : false
+}
+
+export function getCatalogVariableValue(
+ field: Pick & { defaultValue?: string | null },
+ value: unknown
+): CatalogVariableValue | undefined {
+ const resolvedValue = value ?? field.defaultValue
+ if (field.type === 'bool') {
+ if (typeof resolvedValue === 'boolean') return resolvedValue
+ if (typeof resolvedValue === 'string') return resolvedValue === 'true'
+ return undefined
+ }
+
+ if (typeof resolvedValue === 'string' || typeof resolvedValue === 'number') return String(resolvedValue)
+ return undefined
+}
+
+export function isCatalogFieldValueFulfilled(value: CatalogVariableValue | undefined) {
+ if (typeof value === 'boolean') return true
+ return Boolean(value?.trim())
+}
+
+export function isCatalogFieldValueMatchingPattern(
+ field: Pick,
+ value: CatalogVariableValue | undefined
+) {
+ if (typeof value !== 'string' || !value || !field.pattern) return true
+
+ try {
+ return new RegExp(field.pattern).test(value)
+ } catch {
+ return true
+ }
+}
+
+export function getCatalogFieldLengthValidationError(
+ field: Pick,
+ value: CatalogVariableValue | undefined
+) {
+ if (typeof value !== 'string' || !value) return undefined
+
+ const { minLength, maxLength } = field
+ const hasMinLength = typeof minLength === 'number'
+ const hasMaxLength = typeof maxLength === 'number'
+
+ if (hasMinLength && hasMaxLength && (value.length < minLength || value.length > maxLength)) {
+ return `Value must be between ${minLength} and ${maxLength} characters.`
+ }
+
+ if (hasMinLength && value.length < minLength) return `Value must be at least ${minLength} characters.`
+ if (hasMaxLength && value.length > maxLength) return `Value must be at most ${maxLength} characters.`
+
+ return undefined
+}
+
+export function getCatalogFieldValidationError(
+ field: Pick,
+ value: CatalogVariableValue | undefined
+) {
+ const lengthValidationError = getCatalogFieldLengthValidationError(field, value)
+ if (lengthValidationError) return lengthValidationError
+
+ if (!isCatalogFieldValueMatchingPattern(field, value)) return 'Value does not match the expected format.'
+ return undefined
+}
+
+export function isCatalogFieldValid(
+ field: Pick,
+ value: CatalogVariableValue | undefined
+) {
+ if (field.required && !isCatalogFieldValueFulfilled(value)) return false
+ return !getCatalogFieldValidationError(field, value)
+}
+
+export function getCatalogSummaryFieldValue(
+ field: Pick,
+ value: CatalogVariableValue | undefined
+) {
+ if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled'
+ if (field.sensitive && value) return '••••••••'
+ return value
+}
diff --git a/libs/state/util-queries/src/lib/queries/queries.ts b/libs/state/util-queries/src/lib/queries/queries.ts
index bc4a4eced70..ca2e69819af 100644
--- a/libs/state/util-queries/src/lib/queries/queries.ts
+++ b/libs/state/util-queries/src/lib/queries/queries.ts
@@ -1,6 +1,6 @@
import { type inferQueryKeyStore, mergeQueryKeys } from '@lukemorales/query-key-factory'
import { cloudProviders } from '@qovery/domains/cloud-providers/data-access'
-import { clusters } from '@qovery/domains/clusters/data-access'
+import { clusterOperator, clusters, platformConfiguration } from '@qovery/domains/clusters/data-access'
import { customDomains } from '@qovery/domains/custom-domains/data-access'
import { environments } from '@qovery/domains/environments/data-access'
import { observability } from '@qovery/domains/observability/data-access'
@@ -18,7 +18,9 @@ import { webflow } from '@qovery/shared/webflow/data-access'
export const queries = mergeQueryKeys(
cloudProviders,
+ clusterOperator,
clusters,
+ platformConfiguration,
environments,
organizations,
projects,
diff --git a/package.json b/package.json
index de3d8048b56..615fa48e99b 100644
--- a/package.json
+++ b/package.json
@@ -77,7 +77,7 @@
"mermaid": "11.6.0",
"monaco-editor": "0.53.0",
"posthog-js": "1.345.1",
- "qovery-typescript-axios": "1.1.920",
+ "qovery-typescript-axios": "1.1.929",
"react": "18.3.1",
"react-country-flag": "3.0.2",
"react-datepicker": "4.12.0",
diff --git a/yarn.lock b/yarn.lock
index 1a6101f9168..ac6a361ae7a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6343,7 +6343,7 @@ __metadata:
prettier: 3.2.5
prettier-plugin-tailwindcss: 0.5.14
pretty-quick: 4.0.0
- qovery-typescript-axios: 1.1.920
+ qovery-typescript-axios: 1.1.929
qovery-ws-typescript-axios: 0.1.621
react: 18.3.1
react-country-flag: 3.0.2
@@ -25862,12 +25862,12 @@ __metadata:
languageName: node
linkType: hard
-"qovery-typescript-axios@npm:1.1.920":
- version: 1.1.920
- resolution: "qovery-typescript-axios@npm:1.1.920"
+"qovery-typescript-axios@npm:1.1.929":
+ version: 1.1.929
+ resolution: "qovery-typescript-axios@npm:1.1.929"
dependencies:
axios: 1.15.2
- checksum: ea8634a4c39cffb4127fd69ff1e9f59948db6ab35ddce30afd8a32312ae5a2394e88196b6ab25f47bf5a8d1801c839d5b63e8d7ca708da1da77eb9bdf6fe0e68
+ checksum: 6783baad1e9ab5a5bc21f7a526d8b57c6c993536305ddf08ee0d3c8add341940dbcde4c6dafb0633aabbea2264e8829b19bc714392001ef7def9e158712e6852
languageName: node
linkType: hard