From 1086f9184c5470dca37697b7474f44eaf32782aa Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 2 Oct 2026 16:10:10 +0200 Subject: [PATCH 01/14] fix(clusters): wait for the Operator configuration check before creating Co-Authored-By: Claude Opus 5.5 (1M context) --- ...elf-managed-cluster-creation-flow.spec.tsx | 33 ++++++++++++++++++- .../self-managed-cluster-creation-flow.tsx | 17 ++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.spec.tsx b/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.spec.tsx index 76bfcfdf58c..bd5b3630633 100644 --- a/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.spec.tsx @@ -116,7 +116,17 @@ describe('SelfManagedClusterCreationFlow', () => { isError: false, refetch: jest.fn(), }) - mockUsePlatformTemplateComponentConfiguration.mockReturnValue({ data: undefined }) + mockUsePlatformTemplateComponentConfiguration.mockReturnValue({ + data: { + componentKey: 'qovery-operator', + fields: operatorComponent.fields, + requirements: [], + componentBindings: [], + violations: [], + }, + isFetching: false, + isError: false, + }) mockCreateSelfManagedCluster.mockResolvedValue({ id: 'cluster-id' }) mockUseCreateSelfManagedCluster.mockReturnValue({ mutateAsync: mockCreateSelfManagedCluster, isLoading: false }) mockUseClusterOperatorStatus.mockReturnValue({ data: { operator_connected: false } }) @@ -197,6 +207,21 @@ describe('SelfManagedClusterCreationFlow', () => { ) }) + it.each([ + ['has not been checked yet', { data: undefined, isFetching: true, isError: false }], + ['is being checked again', { data: { componentKey: 'qovery-operator', violations: [] }, isFetching: true }], + ['could not be checked', { data: undefined, isFetching: false, isError: true }], + ])('cannot continue while the Operator configuration %s', async (_, preview) => { + mockUsePlatformTemplateComponentConfiguration.mockReturnValue(preview) + const { userEvent } = renderWithProviders( + + ) + + await goToOperatorStep(userEvent) + + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled() + }) + it('creates the cluster with the Operator configuration', async () => { const { userEvent } = renderWithProviders( @@ -246,6 +271,11 @@ describe('SelfManagedClusterCreationFlow', () => { isError: false, refetch: jest.fn(), }) + mockUsePlatformTemplateComponentConfiguration.mockReturnValue({ + data: undefined, + isFetching: false, + isError: false, + }) const { userEvent } = renderWithProviders( ) @@ -323,6 +353,7 @@ describe('SelfManagedClusterCreationFlow', () => { await goToOperatorStep(userEvent) await userEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(mockCreateSelfManagedCluster).toHaveBeenCalled() expect(screen.getByRole('heading', { name: 'Configure Qovery Operator' })).toBeInTheDocument() }) diff --git a/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.tsx b/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.tsx index 85f5a8046ca..9132ce8eb30 100644 --- a/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.tsx +++ b/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.tsx @@ -1,5 +1,6 @@ import { useNavigate } from '@tanstack/react-router' import download from 'downloadjs' +import equal from 'fast-deep-equal' import { type ClusterRegion, type PlatformComponentConfigurationResolutionResponse, @@ -472,7 +473,11 @@ export function SelfManagedClusterCreationFlow({ const operatorConfig = useMemo(() => omitEmptyValues(resolvedOperatorValues), [resolvedOperatorValues]) const { mutateAsync: createSelfManagedCluster, isLoading: isCreating } = useCreateSelfManagedCluster() const debouncedOperatorConfig = useDebounce(operatorConfig, 300) - const { data: operatorPreview } = usePlatformTemplateComponentConfiguration({ + const { + data: operatorPreview, + isFetching: isOperatorPreviewFetching, + isError: isOperatorPreviewError, + } = usePlatformTemplateComponentConfiguration({ organizationId, templateKey: template?.key, templateVersion: template?.version, @@ -482,7 +487,13 @@ export function SelfManagedClusterCreationFlow({ request: { profileConfig: debouncedOperatorConfig, clusterInputs: {}, componentOutputs: {} }, enabled: step === 'operator', }) - const hasOperatorViolations = Boolean(operatorPreview?.violations.length) + const isOperatorConfigurationValid = + !operatorComponent || + (equal(debouncedOperatorConfig, operatorConfig) && + !isOperatorPreviewFetching && + !isOperatorPreviewError && + operatorPreview?.componentKey === operatorComponent.key && + operatorPreview.violations.length === 0) const goToStep = (nextStep: SelfManagedClusterCreationStep) => { setStep(nextStep) @@ -541,7 +552,7 @@ export function SelfManagedClusterCreationFlow({ values={resolvedOperatorValues} isCreating={isCreating} // An empty template list lets q-core pick the default; a failed request must not create the cluster blindly. - canContinue={isTemplatesSuccess && !hasOperatorViolations} + canContinue={isTemplatesSuccess && isOperatorConfigurationValid} isTemplatesError={isTemplatesError} onRetryTemplates={() => refetchTemplates()} onChange={setOperatorValues} From 77526173faf482be4748b2801282cfe4d4b070bf Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 2 Oct 2026 16:10:10 +0200 Subject: [PATCH 02/14] fix(clusters): stop polling clusters without Operator and show status errors Co-Authored-By: Claude Opus 5.5 (1M context) --- .../profile-operator-footer.spec.tsx | 13 ++++++++- .../profile-operator-footer.tsx | 21 ++++++++++++-- .../use-cluster-operator-status.spec.ts | 29 +++++++++++++++++++ .../use-cluster-operator-status.ts | 2 +- 4 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.spec.ts diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx index a95a193aae0..08c7ef0f5e1 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx @@ -38,7 +38,7 @@ describe('ProfileOperatorFooter', () => { expect(container).toBeEmptyDOMElement() }) - it('refreshes the Operator status while displayed', () => { + it('configures polling for the Operator status', () => { mockUseClusterOperatorStatus.mockReturnValue({ data: operatorStatus, isLoading: false }) renderWithProviders() @@ -50,6 +50,17 @@ describe('ProfileOperatorFooter', () => { expect(screen.getByRole('button', { name: 'Qovery operator: Update available' })).toBeInTheDocument() }) + it('tells when the Operator status cannot be retrieved', async () => { + mockUseClusterOperatorStatus.mockReturnValue({ data: undefined, isLoading: false, isError: true }) + const { userEvent } = renderWithProviders( + + ) + + await userEvent.hover(screen.getByRole('button', { name: 'Qovery operator: status unavailable' })) + + expect((await screen.findAllByText('The Operator status could not be retrieved.'))[0]).toBeInTheDocument() + }) + it('shows the Operator versions in a tooltip and starts an update', async () => { mockUseClusterOperatorStatus.mockReturnValue({ data: operatorStatus, isLoading: false }) const { userEvent } = renderWithProviders( diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx index d041cf59940..3ede11658f8 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx @@ -8,32 +8,47 @@ import { useClusterOperatorStatus } from '../hooks/use-cluster-operator-status/u const OPERATOR_STATUS_REFRESH_INTERVAL = 30_000 export function ProfileOperatorFooter({ organizationId, clusterId }: { organizationId: string; clusterId: string }) { - const { data: operatorStatus, isLoading } = useClusterOperatorStatus({ + const { + data: operatorStatus, + isLoading, + isError, + } = useClusterOperatorStatus({ organizationId, clusterId, refetchInterval: OPERATOR_STATUS_REFRESH_INTERVAL, }) // Clusters without Operator state (q-core answers 404) have no Operator to describe. - if (!isLoading && !operatorStatus) return null + if (!isLoading && !isError && !operatorStatus) return null const display = operatorStatus ? getClusterOperatorStatusDisplay(operatorStatus.status) : undefined + const isUnavailable = !operatorStatus && isError const trigger = ( ) + if (isUnavailable) { + return ( + + {trigger} + + ) + } + return operatorStatus ? ( ({ + ...jest.requireActual('@tanstack/react-query'), + useQuery: jest.fn(), +})) + +const mockUseQuery = useQuery as jest.Mock + +describe('useClusterOperatorStatus', () => { + it('stops polling once the cluster is known to have no Operator', () => { + renderHook(() => + useClusterOperatorStatus({ organizationId: 'org-123', clusterId: 'cluster-123', refetchInterval: 30_000 }) + ) + + const { refetchInterval } = mockUseQuery.mock.calls[0][0] + expect(refetchInterval({ status: 'CURRENT' })).toBe(30_000) + expect(refetchInterval(undefined)).toBe(30_000) + expect(refetchInterval(null)).toBe(false) + }) + + it('does not poll without interval', () => { + renderHook(() => useClusterOperatorStatus({ organizationId: 'org-123', clusterId: 'cluster-123' })) + + expect(mockUseQuery.mock.calls.at(-1)[0].refetchInterval).toBeUndefined() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts index 49576d0a06a..3033e90b260 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts @@ -20,7 +20,7 @@ export function useClusterOperatorStatus({ return useQuery({ ...queries.clusters.operatorStatus({ organizationId, clusterId }), enabled: enabled && Boolean(organizationId) && Boolean(clusterId), - refetchInterval, + refetchInterval: refetchInterval ? (data) => (data === null ? false : refetchInterval) : undefined, staleTime, }) } From 466e21b544806c6174360e12a580ad431b70d990 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 2 Oct 2026 16:10:10 +0200 Subject: [PATCH 03/14] fix(clusters): block incomplete or invalid profile array items Co-Authored-By: Claude Opus 5.5 (1M context) --- .../profile-array-item-modal.spec.tsx | 37 ++++++++++ .../cluster-profile/profile-changes-bar.tsx | 4 +- .../profile-configuration-field.spec.tsx | 68 +++++++++++++++++++ .../profile-configuration-field.tsx | 9 ++- .../src/lib/catalog-variable-field.spec.ts | 9 +++ .../util-js/src/lib/catalog-variable-field.ts | 17 ++++- 6 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.spec.tsx create mode 100644 libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.spec.tsx diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.spec.tsx new file mode 100644 index 00000000000..c91d71d6cec --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.spec.tsx @@ -0,0 +1,37 @@ +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import { type PlatformFieldDescriptor } from '../platform-configuration/platform-configuration-utils' +import { ProfileArrayItemModal } from './profile-array-item-modal' + +const cidrField = { + key: 'cidr', + label: 'CIDR', + type: 'string', + required: false, + sensitive: false, + constraints: {}, +} as PlatformFieldDescriptor + +describe('ProfileArrayItemModal', () => { + it.each([ + ['optional fields', cidrField], + ['prefilled required fields', { ...cidrField, required: true, defaultValue: '10.0.0.0/8' }], + ])('lets an item made of %s be added without editing it', async (_, field) => { + const onSubmit = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + const addButton = screen.getByRole('button', { name: 'Add' }) + await waitFor(() => expect(addButton).toBeEnabled()) + await userEvent.click(addButton) + + expect(onSubmit).toHaveBeenCalled() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-changes-bar.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-changes-bar.tsx index e0f4b53b8a3..985e3645645 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-changes-bar.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-changes-bar.tsx @@ -58,7 +58,7 @@ export function ProfileChangesBar({ size="md" className="gap-1.5" loading={isSaving} - disabled={isDeploying || !canSubmit} + disabled={isPending || !canSubmit} onClick={onSave} > @@ -71,7 +71,7 @@ export function ProfileChangesBar({ size="md" className="gap-1.5" loading={isDeploying} - disabled={isSaving || !canSubmit} + disabled={isPending || !canSubmit} onClick={onSaveAndDeploy} > diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.spec.tsx new file mode 100644 index 00000000000..2340f196c7b --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.spec.tsx @@ -0,0 +1,68 @@ +import { type FieldSchemaResponse } from 'qovery-typescript-axios' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { ProfileConfigurationField } from './profile-configuration-field' + +const scalarItemField = { + key: 'name', + label: 'Name', + type: 'string', + required: true, + sensitive: false, + constraints: {}, +} + +function createArrayField(itemFields: unknown[]) { + return { + key: 'listeners', + label: 'Listeners', + type: 'array', + required: false, + sensitive: false, + constraints: {}, + items: { type: 'object', fields: itemFields }, + itemFields: [], + } as unknown as FieldSchemaResponse +} + +describe('ProfileConfigurationField', () => { + it('lets object items with scalar fields be added', () => { + renderWithProviders( + + ) + + expect(screen.getByRole('button', { name: 'Add item to Listeners' })).toBeEnabled() + }) + + it('does not add object items whose nested settings the modal cannot edit', () => { + renderWithProviders( + + ) + + expect(screen.getByRole('button', { name: 'Add item to Listeners' })).toBeDisabled() + expect(screen.getByText('Items with nested settings cannot be added here yet.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit https' })).toBeEnabled() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.tsx index 5d853943a46..14eaae7358d 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.tsx @@ -158,7 +158,10 @@ function ProfileArrayField({ const isItemModalOpenRef = useRef(false) const items: unknown[] = Array.isArray(value) ? value : [] const { minItems, maxItems } = field.constraints - const canAdd = typeof maxItems !== 'number' || items.length < maxItems + const isUnderLimit = typeof maxItems !== 'number' || items.length < maxItems + const hasNestedItemFields = + field.items.type === 'object' && field.items.fields.some((itemField) => !isPlatformScalarField(itemField)) + const canAdd = isUnderLimit && !hasNestedItemFields const canRemove = typeof minItems !== 'number' || items.length > minItems const error = getFieldViolation(violations, path) @@ -239,7 +242,9 @@ function ProfileArrayField({ Add item - {typeof maxItems === 'number' && !canAdd ? ( + {hasNestedItemFields ? ( +

Items with nested settings cannot be added here yet.

+ ) : typeof maxItems === 'number' && !isUnderLimit ? (

Limit of {maxItems} reached.

) : null} 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 index d56f730cfe9..0aa1b6afb68 100644 --- a/libs/shared/util-js/src/lib/catalog-variable-field.spec.ts +++ b/libs/shared/util-js/src/lib/catalog-variable-field.spec.ts @@ -90,6 +90,15 @@ describe('catalog field validation', () => { expect(getCatalogFieldValidationError({ type: 'string', pattern: '^[a-z]+$' }, 'value')).toBeUndefined() }) + it('rejects values outside the allowed values', () => { + const field = { type: 'string' as const, allowedValues: ['pvc', 's3'] } + + expect(getCatalogFieldValidationError(field, 'gcs')).toBe('Please select one of the allowed values.') + expect(getCatalogFieldValidationError(field, 's3')).toBeUndefined() + expect(getCatalogFieldValidationError(field, '')).toBeUndefined() + expect(getCatalogFieldValidationError({ type: 'bool', allowedValues: ['true'] }, false)).toBeUndefined() + }) + it('validates required state and constraints together', () => { expect(isCatalogFieldValid({ type: 'string', required: true }, undefined)).toBe(false) expect(isCatalogFieldValid({ type: 'string', required: true }, 'value')).toBe(true) diff --git a/libs/shared/util-js/src/lib/catalog-variable-field.ts b/libs/shared/util-js/src/lib/catalog-variable-field.ts index 1e5605071b1..4abb1c27735 100644 --- a/libs/shared/util-js/src/lib/catalog-variable-field.ts +++ b/libs/shared/util-js/src/lib/catalog-variable-field.ts @@ -108,9 +108,19 @@ export function getCatalogFieldNumberValidationError( } export function getCatalogFieldValidationError( - field: Pick, + field: Pick, value: CatalogVariableValue | undefined ) { + if ( + field.type !== 'bool' && + field.allowedValues?.length && + typeof value === 'string' && + value !== '' && + !field.allowedValues.includes(value) + ) { + return 'Please select one of the allowed values.' + } + const numberValidationError = getCatalogFieldNumberValidationError(field, value) if (numberValidationError) return numberValidationError @@ -122,7 +132,10 @@ export function getCatalogFieldValidationError( } export function isCatalogFieldValid( - field: Pick, + field: Pick< + CatalogVariableField, + 'required' | 'type' | 'allowedValues' | 'pattern' | 'minLength' | 'maxLength' | 'min' | 'max' + >, value: CatalogVariableValue | undefined ) { if (field.required && !isCatalogFieldValueFulfilled(value)) return false From ea697be3f94d779b3826165c201776c0800a418f Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 2 Oct 2026 16:10:11 +0200 Subject: [PATCH 04/14] fix(clusters): show the cluster provider and layers on every profile width Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/cluster-profile/cluster-profile-sidebar.tsx | 2 +- .../feature/src/lib/cluster-profile/cluster-profile.spec.tsx | 2 ++ .../feature/src/lib/cluster-profile/cluster-profile.tsx | 3 +-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx index 5ac6b0d25fd..72f10878696 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx @@ -164,7 +164,7 @@ export function ClusterProfileSidebar({ const showEmptyState = !isLoading && !isError && layers.length === 0 return ( -