From cb8005f918938381aace8190e6148a26248a40aa Mon Sep 17 00:00:00 2001 From: Pierre Gerbelot Date: Wed, 15 Jul 2026 11:57:19 +0200 Subject: [PATCH 1/2] feat(engine-v2): add platform configuration UI --- apps/console/src/routeTree.gen.ts | 52 ++++ .../cluster/$clusterId/settings/platform.tsx | 69 +++++ .../cluster/$clusterId/settings/route.tsx | 22 +- .../cluster/create/$slug/general.tsx | 7 +- .../cluster/create/$slug/platform.tsx | 33 +++ .../domains/clusters/data-access/src/index.ts | 2 + .../cluster-operator/cluster-operator.spec.ts | 64 +++++ .../lib/cluster-operator/cluster-operator.ts | 106 +++++++ .../src/lib/http/is-http-status.ts | 14 + .../platform-configuration.spec.ts | 85 ++++++ .../platform-configuration.ts | 109 ++++++++ libs/domains/clusters/feature/src/index.ts | 6 + .../cluster-actions/cluster-actions.spec.tsx | 88 ++++++ .../lib/cluster-actions/cluster-actions.tsx | 47 +++- .../cluster-creation-flow.spec.tsx | 10 + .../cluster-creation-flow.tsx | 59 +++- .../cluster-new/cluster-new.spec.tsx | 21 +- .../cluster-new/cluster-new.tsx | 82 ++++-- .../step-general/step-general.spec.tsx | 19 ++ .../step-general/step-general.tsx | 83 +++--- .../step-platform/step-platform.spec.tsx | 263 +++++++++++++++++ .../step-platform/step-platform.tsx | 264 ++++++++++++++++++ .../step-summary-presentation.tsx | 102 ++++--- .../step-summary/step-summary.spec.tsx | 83 ++++++ .../step-summary/step-summary.tsx | 73 +++-- .../cluster-installation-guide-modal.spec.tsx | 65 +++++ .../cluster-installation-guide-modal.tsx | 119 +++++++- .../hooks/use-cluster-operator.ts | 38 +++ .../hooks/use-platform-binding.ts | 22 ++ .../use-platform-component-configuration.ts | 30 ++ ...atform-template-component-configuration.ts | 49 ++++ .../hooks/use-platform-templates.ts | 25 ++ .../hooks/use-update-platform-binding.ts | 20 ++ .../platform-component-configuration.spec.tsx | 232 +++++++++++++++ .../platform-component-configuration.tsx | 243 ++++++++++++++++ .../platform-configuration-catalog.spec.tsx | 203 ++++++++++++++ .../platform-configuration-catalog.tsx | 192 +++++++++++++ .../platform-configuration-feature-flag.ts | 1 + .../platform-configuration-utils.spec.ts | 231 +++++++++++++++ .../platform-configuration-utils.ts | 191 +++++++++++++ .../platform-configuration.tsx | 214 ++++++++++++++ .../blueprint-field-utils.ts | 86 +++--- .../blueprint-manifest-variable-input.tsx | 55 +--- libs/shared/ui/src/index.ts | 1 + .../catalog-variable-input.spec.tsx | 78 ++++++ .../catalog-variable-input.tsx | 100 +++++++ libs/shared/util-js/src/index.ts | 1 + .../src/lib/catalog-variable-field.spec.ts | 95 +++++++ .../util-js/src/lib/catalog-variable-field.ts | 111 ++++++++ .../util-queries/src/lib/queries/queries.ts | 4 +- package.json | 2 +- yarn.lock | 10 +- 52 files changed, 3941 insertions(+), 240 deletions(-) create mode 100644 apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform.tsx create mode 100644 apps/console/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/platform.tsx create mode 100644 libs/domains/clusters/data-access/src/lib/cluster-operator/cluster-operator.spec.ts create mode 100644 libs/domains/clusters/data-access/src/lib/cluster-operator/cluster-operator.ts create mode 100644 libs/domains/clusters/data-access/src/lib/http/is-http-status.ts create mode 100644 libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.spec.ts create mode 100644 libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts create mode 100644 libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-platform/step-platform.spec.tsx create mode 100644 libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-platform/step-platform.tsx create mode 100644 libs/domains/clusters/feature/src/lib/cluster-installation-guide-modal/cluster-installation-guide-modal.spec.tsx create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-cluster-operator.ts create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-binding.ts create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-component-configuration.ts create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-template-component-configuration.ts create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-templates.ts create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-update-platform-binding.ts create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/platform-component-configuration.spec.tsx create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/platform-component-configuration.tsx create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-catalog.spec.tsx create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-catalog.tsx create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-feature-flag.ts create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.spec.ts create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.ts create mode 100644 libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration.tsx create mode 100644 libs/shared/ui/src/lib/components/catalog-variable-input/catalog-variable-input.spec.tsx create mode 100644 libs/shared/ui/src/lib/components/catalog-variable-input/catalog-variable-input.tsx create mode 100644 libs/shared/util-js/src/lib/catalog-variable-field.spec.ts create mode 100644 libs/shared/util-js/src/lib/catalog-variable-field.ts diff --git a/apps/console/src/routeTree.gen.ts b/apps/console/src/routeTree.gen.ts index 50660620478..6d250b01ec1 100644 --- a/apps/console/src/routeTree.gen.ts +++ b/apps/console/src/routeTree.gen.ts @@ -73,12 +73,14 @@ import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdSetting import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesCreateRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/deployment-rules/create' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugSummaryRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/summary' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/resources' +import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugPlatformRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/platform' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugKubeconfigRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/kubeconfig' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/general' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/features' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugEksRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/eks' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/addons' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/resources' +import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsPlatformRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/network' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsImageRegistryRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/image-registry' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsGeneralRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/general' @@ -662,6 +664,15 @@ const AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute = AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdClusterCreateSlugPlatformRoute = + AuthenticatedOrganizationOrganizationIdClusterCreateSlugPlatformRouteImport.update( + { + id: '/platform', + path: '/platform', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdClusterCreateSlugKubeconfigRoute = AuthenticatedOrganizationOrganizationIdClusterCreateSlugKubeconfigRouteImport.update( { @@ -716,6 +727,15 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRo AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsPlatformRoute = + AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsPlatformRouteImport.update( + { + id: '/platform', + path: '/platform', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRoute = AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRouteImport.update( { @@ -1787,12 +1807,14 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/cluster/$clusterId/settings/general': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsGeneralRoute '/organization/$organizationId/cluster/$clusterId/settings/image-registry': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsImageRegistryRoute '/organization/$organizationId/cluster/$clusterId/settings/network': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRoute + '/organization/$organizationId/cluster/$clusterId/settings/platform': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsPlatformRoute '/organization/$organizationId/cluster/$clusterId/settings/resources': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRoute '/organization/$organizationId/cluster/create/$slug/addons': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRoute '/organization/$organizationId/cluster/create/$slug/eks': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugEksRoute '/organization/$organizationId/cluster/create/$slug/features': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute '/organization/$organizationId/cluster/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute '/organization/$organizationId/cluster/create/$slug/kubeconfig': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugKubeconfigRoute + '/organization/$organizationId/cluster/create/$slug/platform': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugPlatformRoute '/organization/$organizationId/cluster/create/$slug/resources': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute '/organization/$organizationId/cluster/create/$slug/summary': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugSummaryRoute '/organization/$organizationId/project/$projectId/deployment-rules/create': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesCreateRoute @@ -1966,12 +1988,14 @@ export interface FileRoutesByTo { '/organization/$organizationId/cluster/$clusterId/settings/general': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsGeneralRoute '/organization/$organizationId/cluster/$clusterId/settings/image-registry': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsImageRegistryRoute '/organization/$organizationId/cluster/$clusterId/settings/network': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRoute + '/organization/$organizationId/cluster/$clusterId/settings/platform': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsPlatformRoute '/organization/$organizationId/cluster/$clusterId/settings/resources': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRoute '/organization/$organizationId/cluster/create/$slug/addons': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRoute '/organization/$organizationId/cluster/create/$slug/eks': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugEksRoute '/organization/$organizationId/cluster/create/$slug/features': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute '/organization/$organizationId/cluster/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute '/organization/$organizationId/cluster/create/$slug/kubeconfig': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugKubeconfigRoute + '/organization/$organizationId/cluster/create/$slug/platform': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugPlatformRoute '/organization/$organizationId/cluster/create/$slug/resources': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute '/organization/$organizationId/cluster/create/$slug/summary': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugSummaryRoute '/organization/$organizationId/project/$projectId/deployment-rules/create': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesCreateRoute @@ -2140,12 +2164,14 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/general': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsGeneralRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/image-registry': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsImageRegistryRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/network': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRoute + '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsPlatformRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/resources': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/addons': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/eks': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugEksRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/features': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/kubeconfig': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugKubeconfigRoute + '/_authenticated/organization/$organizationId/cluster/create/$slug/platform': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugPlatformRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/resources': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/summary': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugSummaryRoute '/_authenticated/organization/$organizationId/project/$projectId/deployment-rules/create': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesCreateRoute @@ -2328,12 +2354,14 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/$clusterId/settings/general' | '/organization/$organizationId/cluster/$clusterId/settings/image-registry' | '/organization/$organizationId/cluster/$clusterId/settings/network' + | '/organization/$organizationId/cluster/$clusterId/settings/platform' | '/organization/$organizationId/cluster/$clusterId/settings/resources' | '/organization/$organizationId/cluster/create/$slug/addons' | '/organization/$organizationId/cluster/create/$slug/eks' | '/organization/$organizationId/cluster/create/$slug/features' | '/organization/$organizationId/cluster/create/$slug/general' | '/organization/$organizationId/cluster/create/$slug/kubeconfig' + | '/organization/$organizationId/cluster/create/$slug/platform' | '/organization/$organizationId/cluster/create/$slug/resources' | '/organization/$organizationId/cluster/create/$slug/summary' | '/organization/$organizationId/project/$projectId/deployment-rules/create' @@ -2507,12 +2535,14 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/$clusterId/settings/general' | '/organization/$organizationId/cluster/$clusterId/settings/image-registry' | '/organization/$organizationId/cluster/$clusterId/settings/network' + | '/organization/$organizationId/cluster/$clusterId/settings/platform' | '/organization/$organizationId/cluster/$clusterId/settings/resources' | '/organization/$organizationId/cluster/create/$slug/addons' | '/organization/$organizationId/cluster/create/$slug/eks' | '/organization/$organizationId/cluster/create/$slug/features' | '/organization/$organizationId/cluster/create/$slug/general' | '/organization/$organizationId/cluster/create/$slug/kubeconfig' + | '/organization/$organizationId/cluster/create/$slug/platform' | '/organization/$organizationId/cluster/create/$slug/resources' | '/organization/$organizationId/cluster/create/$slug/summary' | '/organization/$organizationId/project/$projectId/deployment-rules/create' @@ -2680,12 +2710,14 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/general' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/image-registry' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/network' + | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/resources' | '/_authenticated/organization/$organizationId/cluster/create/$slug/addons' | '/_authenticated/organization/$organizationId/cluster/create/$slug/eks' | '/_authenticated/organization/$organizationId/cluster/create/$slug/features' | '/_authenticated/organization/$organizationId/cluster/create/$slug/general' | '/_authenticated/organization/$organizationId/cluster/create/$slug/kubeconfig' + | '/_authenticated/organization/$organizationId/cluster/create/$slug/platform' | '/_authenticated/organization/$organizationId/cluster/create/$slug/resources' | '/_authenticated/organization/$organizationId/cluster/create/$slug/summary' | '/_authenticated/organization/$organizationId/project/$projectId/deployment-rules/create' @@ -3261,6 +3293,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute } + '/_authenticated/organization/$organizationId/cluster/create/$slug/platform': { + id: '/_authenticated/organization/$organizationId/cluster/create/$slug/platform' + path: '/platform' + fullPath: '/organization/$organizationId/cluster/create/$slug/platform' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugPlatformRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute + } '/_authenticated/organization/$organizationId/cluster/create/$slug/kubeconfig': { id: '/_authenticated/organization/$organizationId/cluster/create/$slug/kubeconfig' path: '/kubeconfig' @@ -3303,6 +3342,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute } + '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform': { + id: '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform' + path: '/platform' + fullPath: '/organization/$organizationId/cluster/$clusterId/settings/platform' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsPlatformRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute + } '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/network': { id: '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/network' path: '/network' @@ -4214,6 +4260,7 @@ interface AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRo AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsGeneralRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsGeneralRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsImageRegistryRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsImageRegistryRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRoute + AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsPlatformRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsPlatformRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute } @@ -4238,6 +4285,8 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteC AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsImageRegistryRoute, AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRoute: AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRoute, + AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsPlatformRoute: + AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsPlatformRoute, AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRoute: AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRoute, AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute: @@ -4255,6 +4304,7 @@ interface AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteChil AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute AuthenticatedOrganizationOrganizationIdClusterCreateSlugKubeconfigRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugKubeconfigRoute + AuthenticatedOrganizationOrganizationIdClusterCreateSlugPlatformRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugPlatformRoute AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute AuthenticatedOrganizationOrganizationIdClusterCreateSlugSummaryRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugSummaryRoute AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRoute @@ -4272,6 +4322,8 @@ const AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteChildren AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute, AuthenticatedOrganizationOrganizationIdClusterCreateSlugKubeconfigRoute: AuthenticatedOrganizationOrganizationIdClusterCreateSlugKubeconfigRoute, + AuthenticatedOrganizationOrganizationIdClusterCreateSlugPlatformRoute: + AuthenticatedOrganizationOrganizationIdClusterCreateSlugPlatformRoute, AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute: AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute, AuthenticatedOrganizationOrganizationIdClusterCreateSlugSummaryRoute: diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform.tsx new file mode 100644 index 00000000000..b5f953dba38 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform.tsx @@ -0,0 +1,69 @@ +import { Navigate, createFileRoute, useParams } from '@tanstack/react-router' +import { useFeatureFlagEnabled } from 'posthog-js/react' +import { + PLATFORM_CONFIGURATION_FEATURE_FLAG, + PlatformConfiguration, + toPlatformCloudVendor, + toPlatformClusterMode, + useCluster, + usePlatformBinding, +} from '@qovery/domains/clusters/feature' +import { SettingsHeading } from '@qovery/shared/console-shared' +import { Section } from '@qovery/shared/ui' +import { useDocumentTitle } from '@qovery/shared/util-hooks' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + useDocumentTitle('Platform configuration - Cluster settings') + + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const isPlatformConfigurationEnabled = useFeatureFlagEnabled(PLATFORM_CONFIGURATION_FEATURE_FLAG) + const { data: cluster, isLoading: isClusterLoading } = useCluster({ organizationId, clusterId }) + const { data: platformBinding, isLoading: isPlatformBindingLoading } = usePlatformBinding({ + organizationId, + clusterId, + enabled: Boolean(isPlatformConfigurationEnabled), + }) + const clusterMode = toPlatformClusterMode(cluster?.kubernetes) + const cloudProvider = toPlatformCloudVendor(cluster?.cloud_provider) + + if (isPlatformConfigurationEnabled === undefined) return null + + if ( + isPlatformConfigurationEnabled === false || + (!isPlatformBindingLoading && !platformBinding) || + (!isClusterLoading && (!clusterMode || !cloudProvider)) + ) { + return ( + + ) + } + + if (isPlatformBindingLoading || isClusterLoading || !clusterMode || !cloudProvider) return null + + return ( +
+ +
+ +
+
+ ) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/route.tsx index 60270813095..f5fa90af25a 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/route.tsx @@ -2,7 +2,7 @@ import { Outlet, createFileRoute, useParams } from '@tanstack/react-router' import clsx from 'clsx' import { useFeatureFlagEnabled } from 'posthog-js/react' import { match } from 'ts-pattern' -import { useCluster } from '@qovery/domains/clusters/feature' +import { PLATFORM_CONFIGURATION_FEATURE_FLAG, useCluster, usePlatformBinding } from '@qovery/domains/clusters/feature' import { Sidebar } from '@qovery/shared/ui' import { twMerge } from '@qovery/shared/util-js' @@ -14,6 +14,12 @@ function RouteComponent() { const { organizationId = '', clusterId = '' } = useParams({ strict: false }) const { data: cluster } = useCluster({ organizationId, clusterId }) const isEksAnywhereEnabled = useFeatureFlagEnabled('eks-anywhere') + const isPlatformConfigurationEnabled = useFeatureFlagEnabled(PLATFORM_CONFIGURATION_FEATURE_FLAG) + const { data: platformBinding } = usePlatformBinding({ + organizationId, + clusterId, + enabled: Boolean(isPlatformConfigurationEnabled), + }) const pathSettings = `/organization/${organizationId}/cluster/${clusterId}/settings` @@ -71,6 +77,12 @@ function RouteComponent() { icon: 'gears' as const, } + const platformConfigurationLink = { + title: 'Platform configuration', + to: `${pathSettings}/platform`, + icon: 'layer-group' as const, + } + const dangerZoneLink = { title: 'Danger zone', to: `${pathSettings}/danger-zone`, @@ -78,6 +90,9 @@ function RouteComponent() { } const eksAnywhereCluster = isEksAnywhereEnabled && cluster?.kubernetes === 'PARTIALLY_MANAGED' + const hasPlatformTemplateBinding = Boolean(platformBinding?.templateKey && platformBinding.templateVersion) + const platformConfigurationLinks = + isPlatformConfigurationEnabled && hasPlatformTemplateBinding ? [platformConfigurationLink] : [] const LINKS_SETTINGS = match(cluster) .with({ kubernetes: 'SELF_MANAGED' }, () => [ @@ -85,6 +100,7 @@ function RouteComponent() { dnsProviderLink, imageRegistryLink, advancedSettingsLink, + ...platformConfigurationLinks, dangerZoneLink, ]) .with( @@ -101,6 +117,7 @@ function RouteComponent() { dnsProviderLink, addonsLink, ...(eksAnywhereCluster ? [] : [advancedSettingsLink]), + ...platformConfigurationLinks, dangerZoneLink, ] } @@ -113,6 +130,7 @@ function RouteComponent() { networkLink, dnsProviderLink, advancedSettingsLink, + ...platformConfigurationLinks, dangerZoneLink, ]) .with({ cloud_provider: 'GCP' }, () => [ @@ -123,6 +141,7 @@ function RouteComponent() { dnsProviderLink, addonsLink, advancedSettingsLink, + ...platformConfigurationLinks, dangerZoneLink, ]) .with({ cloud_provider: 'AZURE' }, () => [ @@ -133,6 +152,7 @@ function RouteComponent() { networkLink, dnsProviderLink, advancedSettingsLink, + ...platformConfigurationLinks, dangerZoneLink, ]) .otherwise(() => []) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/general.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/general.tsx index 902e91be53f..39be562dfc2 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/general.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/general.tsx @@ -1,6 +1,6 @@ import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { match } from 'ts-pattern' -import { StepGeneral } from '@qovery/domains/clusters/feature' +import { StepGeneral, useClusterContainerCreateContext } from '@qovery/domains/clusters/feature' import { LabelSetting } from '@qovery/domains/organizations/feature' import { type ClusterGeneralData } from '@qovery/shared/interfaces' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -13,12 +13,15 @@ function General() { useDocumentTitle('General - Create Cluster') const { organizationId = '', slug } = useParams({ strict: false }) const navigate = useNavigate() + const { isEngineV2SelfManaged } = useClusterContainerCreateContext() const creationFlowUrl = `/organization/${organizationId}/cluster/create/${slug}` const handleSubmit = (data: ClusterGeneralData) => { match(data) - .with({ installation_type: 'SELF_MANAGED' }, () => navigate({ to: `${creationFlowUrl}/kubeconfig` })) + .with({ installation_type: 'SELF_MANAGED' }, () => + navigate({ to: `${creationFlowUrl}/${isEngineV2SelfManaged ? 'platform' : 'kubeconfig'}` }) + ) .with({ installation_type: 'MANAGED', cloud_provider: 'GCP' }, () => navigate({ to: `${creationFlowUrl}/features` }) ) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/platform.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/platform.tsx new file mode 100644 index 00000000000..f5f03940748 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/platform.tsx @@ -0,0 +1,33 @@ +import { Navigate, createFileRoute, useNavigate, useParams } from '@tanstack/react-router' +import { StepPlatform, useClusterContainerCreateContext } from '@qovery/domains/clusters/feature' +import { useDocumentTitle } from '@qovery/shared/util-hooks' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/create/$slug/platform')({ + component: Platform, +}) + +function Platform() { + useDocumentTitle('Platform layers - Create Cluster') + const { organizationId = '', slug = '' } = useParams({ strict: false }) + const navigate = useNavigate() + const { isEngineV2SelfManaged } = useClusterContainerCreateContext() + const creationFlowUrl = `/organization/${organizationId}/cluster/create/${slug}` + + if (!isEngineV2SelfManaged) { + return ( + + ) + } + + return ( + navigate({ to: `${creationFlowUrl}/general` })} + onSubmit={() => navigate({ to: `${creationFlowUrl}/summary` })} + /> + ) +} diff --git a/libs/domains/clusters/data-access/src/index.ts b/libs/domains/clusters/data-access/src/index.ts index e8ca0eb7ad5..5b427ec6ae6 100644 --- a/libs/domains/clusters/data-access/src/index.ts +++ b/libs/domains/clusters/data-access/src/index.ts @@ -1,4 +1,6 @@ export * from './lib/domains-clusters-data-access' +export * from './lib/cluster-operator/cluster-operator' +export * from './lib/platform-configuration/platform-configuration' export { isGcpCluster } from './lib/cluster-checks/is-gcp-cluster' export { isAwsCluster } from './lib/cluster-checks/is-aws-cluster' export { excludeSecretManagerAccess } from './lib/secret-manager/exclude-secret-manager-access' diff --git a/libs/domains/clusters/data-access/src/lib/cluster-operator/cluster-operator.spec.ts b/libs/domains/clusters/data-access/src/lib/cluster-operator/cluster-operator.spec.ts new file mode 100644 index 00000000000..8cfd845a333 --- /dev/null +++ b/libs/domains/clusters/data-access/src/lib/cluster-operator/cluster-operator.spec.ts @@ -0,0 +1,64 @@ +import axios from 'axios' +import { clusterOperator } from './cluster-operator' + +describe('clusterOperator', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('maps the bootstrap response from the API snake case contract', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + chart_repository: 'https://helm.qovery.com', + chart_name: 'qovery-operator', + chart_version: '1.2.3', + namespace: 'qovery', + release_name: 'qovery-operator', + values: { clusterId: 'cluster-123' }, + values_yaml: 'clusterId: cluster-123', + helm_command: 'helm upgrade --install qovery-operator', + }, + }) + + const query = clusterOperator.bootstrap({ organizationId: 'org-123', clusterId: 'cluster-123' }) + const result = await query.queryFn({} as Parameters[0]) + + expect(result).toEqual({ + chartRepository: 'https://helm.qovery.com', + chartName: 'qovery-operator', + chartVersion: '1.2.3', + namespace: 'qovery', + releaseName: 'qovery-operator', + values: { clusterId: 'cluster-123' }, + valuesYaml: 'clusterId: cluster-123', + helmCommand: 'helm upgrade --install qovery-operator', + }) + }) + + it('maps the operator status response from the API snake case contract', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + organization_id: 'org-123', + cluster_id: 'cluster-123', + operator_connected: true, + last_heartbeat: '2026-07-15T15:00:00Z', + operator_version: '1.2.3', + controller_version: '1.2.3', + request_schema_version: '1', + }, + }) + + const query = clusterOperator.status({ organizationId: 'org-123', clusterId: 'cluster-123' }) + const result = await query.queryFn({} as Parameters[0]) + + expect(result).toEqual({ + organizationId: 'org-123', + clusterId: 'cluster-123', + operatorConnected: true, + lastHeartbeat: '2026-07-15T15:00:00Z', + operatorVersion: '1.2.3', + controllerVersion: '1.2.3', + requestSchemaVersion: '1', + }) + }) +}) diff --git a/libs/domains/clusters/data-access/src/lib/cluster-operator/cluster-operator.ts b/libs/domains/clusters/data-access/src/lib/cluster-operator/cluster-operator.ts new file mode 100644 index 00000000000..e7e4f6dd045 --- /dev/null +++ b/libs/domains/clusters/data-access/src/lib/cluster-operator/cluster-operator.ts @@ -0,0 +1,106 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory' +import axios from 'axios' +import { isHttpStatus } from '../http/is-http-status' + +export interface ClusterOperatorBootstrapResponse { + chartRepository: string + chartName: string + chartVersion: string + namespace: string + releaseName: string + values: Record + valuesYaml: string + helmCommand: string +} + +interface ClusterOperatorBootstrapApiResponse { + chart_repository: string + chart_name: string + chart_version: string + namespace: string + release_name: string + values: Record + values_yaml: string + helm_command: string +} + +export interface ClusterOperatorStatusResponse { + organizationId: string + clusterId: string + operatorConnected: boolean + lastHeartbeat?: string | null + operatorVersion?: string | null + controllerVersion?: string | null + requestSchemaVersion?: string | null +} + +interface ClusterOperatorStatusApiResponse { + organization_id: string + cluster_id: string + operator_connected: boolean + last_heartbeat?: string | null + operator_version?: string | null + controller_version?: string | null + request_schema_version?: string | null +} + +function toClusterOperatorBootstrapResponse( + response: ClusterOperatorBootstrapApiResponse +): ClusterOperatorBootstrapResponse { + return { + chartRepository: response.chart_repository, + chartName: response.chart_name, + chartVersion: response.chart_version, + namespace: response.namespace, + releaseName: response.release_name, + values: response.values, + valuesYaml: response.values_yaml, + helmCommand: response.helm_command, + } +} + +function toClusterOperatorStatusResponse(response: ClusterOperatorStatusApiResponse): ClusterOperatorStatusResponse { + return { + organizationId: response.organization_id, + clusterId: response.cluster_id, + operatorConnected: response.operator_connected, + lastHeartbeat: response.last_heartbeat, + operatorVersion: response.operator_version, + controllerVersion: response.controller_version, + requestSchemaVersion: response.request_schema_version, + } +} + +// These endpoints are not generated by qovery-typescript-axios yet. Keeping the raw +// calls behind this data-access module avoids leaking their transport details into UI code. +export const clusterOperator = createQueryKeys('clusterOperator', { + status: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({ + queryKey: [organizationId, clusterId], + async queryFn() { + try { + const response = await axios.get( + `/organization/${organizationId}/cluster/${clusterId}/operator/status` + ) + return toClusterOperatorStatusResponse(response.data) + } catch (error) { + if (isHttpStatus(error, 404)) return null + throw error + } + }, + }), + bootstrap: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({ + queryKey: [organizationId, clusterId], + async queryFn() { + const response = await axios.get( + `/organization/${organizationId}/cluster/${clusterId}/operator/bootstrap` + ) + return toClusterOperatorBootstrapResponse(response.data) + }, + }), +}) + +export const clusterOperatorMutations = { + async attach({ organizationId, clusterId }: { organizationId: string; clusterId: string }) { + await axios.post(`/organization/${organizationId}/cluster/${clusterId}/operator/attach`) + }, +} diff --git a/libs/domains/clusters/data-access/src/lib/http/is-http-status.ts b/libs/domains/clusters/data-access/src/lib/http/is-http-status.ts new file mode 100644 index 00000000000..8aa634a76a6 --- /dev/null +++ b/libs/domains/clusters/data-access/src/lib/http/is-http-status.ts @@ -0,0 +1,14 @@ +import { isAxiosError } from 'axios' + +export function isHttpStatus(error: unknown, status: number) { + if (isAxiosError(error)) return error.response?.status === status + return Boolean( + error && + typeof error === 'object' && + 'response' in error && + error.response && + typeof error.response === 'object' && + 'status' in error.response && + error.response.status === status + ) +} diff --git a/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.spec.ts b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.spec.ts new file mode 100644 index 00000000000..86bb192ef88 --- /dev/null +++ b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.spec.ts @@ -0,0 +1,85 @@ +import { PlatformConfigurationApi } from 'qovery-typescript-axios' +import { platformConfiguration } from './platform-configuration' + +describe('platformConfiguration', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('returns null for a serialized 404 response', async () => { + jest + .spyOn(PlatformConfigurationApi.prototype, 'getClusterPlatformBinding') + .mockRejectedValue({ response: { status: 404 } }) + + const query = platformConfiguration.binding({ organizationId: 'org-123', clusterId: 'cluster-123' }) + const result = await query.queryFn({} as Parameters[0]) + + expect(result).toBeNull() + }) + + it('keeps non-404 serialized errors', async () => { + const error = { response: { status: 500 } } + jest.spyOn(PlatformConfigurationApi.prototype, 'getClusterPlatformBinding').mockRejectedValue(error) + + const query = platformConfiguration.binding({ organizationId: 'org-123', clusterId: 'cluster-123' }) + + await expect(query.queryFn({} as Parameters[0])).rejects.toBe(error) + }) + + it('forwards the optional cluster context when listing templates', async () => { + const listPlatformTemplates = jest + .spyOn(PlatformConfigurationApi.prototype, 'listPlatformTemplates') + .mockResolvedValue({ data: { results: [] } } as never) + + const query = platformConfiguration.templates({ + organizationId: 'org-123', + clusterMode: 'CUSTOMER_MANAGED', + cloudProvider: 'GCP', + }) + + await query.queryFn({} as Parameters[0]) + + expect(listPlatformTemplates).toHaveBeenCalledWith('org-123', 'CUSTOMER_MANAGED', 'GCP') + }) + + it('resolves a component before cluster creation with the explicit template and cluster context', async () => { + const resolvePlatformTemplateComponentConfiguration = jest + .spyOn(PlatformConfigurationApi.prototype, 'resolvePlatformTemplateComponentConfiguration') + .mockResolvedValue({ + data: { + componentKey: 'loki', + fields: [], + requirements: [], + componentBindings: [], + violations: [], + }, + } as never) + const request = { + profileConfig: { storage: 'gcs' }, + clusterInputs: { 'infra.gcsBucketName': 'logs' }, + componentOutputs: {}, + } + + const query = platformConfiguration.templateComponentConfiguration({ + organizationId: 'org-123', + templateKey: 'qovery-cluster-v0', + templateVersion: '0.1.0', + componentKey: 'loki', + clusterMode: 'CUSTOMER_MANAGED', + cloudProvider: 'GCP', + request, + }) + + await query.queryFn({} as Parameters[0]) + + expect(resolvePlatformTemplateComponentConfiguration).toHaveBeenCalledWith( + 'org-123', + 'qovery-cluster-v0', + '0.1.0', + 'loki', + 'CUSTOMER_MANAGED', + 'GCP', + request + ) + }) +}) diff --git a/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts new file mode 100644 index 00000000000..cef1cccaf9a --- /dev/null +++ b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts @@ -0,0 +1,109 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory' +import { + type ClusterPlatformBindingRequest, + type PlatformCloudVendor, + type PlatformClusterMode, + type PlatformComponentConfigurationPreviewRequest, + PlatformConfigurationApi, +} from 'qovery-typescript-axios' +import { isHttpStatus } from '../http/is-http-status' + +const platformConfigurationApi = new PlatformConfigurationApi() + +export const platformConfiguration = createQueryKeys('platformConfiguration', { + templates: ({ + organizationId, + clusterMode, + cloudProvider, + }: { + organizationId: string + clusterMode?: PlatformClusterMode + cloudProvider?: PlatformCloudVendor + }) => ({ + queryKey: [organizationId, clusterMode, cloudProvider], + async queryFn() { + const response = await platformConfigurationApi.listPlatformTemplates(organizationId, clusterMode, cloudProvider) + return response.data.results + }, + }), + binding: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({ + queryKey: [organizationId, clusterId], + async queryFn() { + try { + const response = await platformConfigurationApi.getClusterPlatformBinding(organizationId, clusterId) + return response.data + } catch (error) { + if (isHttpStatus(error, 404)) return null + throw error + } + }, + }), + componentConfiguration: ({ + organizationId, + clusterId, + componentKey, + request, + }: { + organizationId: string + clusterId: string + componentKey: string + request: PlatformComponentConfigurationPreviewRequest + }) => ({ + queryKey: [organizationId, clusterId, componentKey, request], + async queryFn() { + const response = await platformConfigurationApi.resolvePlatformComponentConfiguration( + organizationId, + clusterId, + componentKey, + request + ) + return response.data + }, + }), + templateComponentConfiguration: ({ + organizationId, + templateKey, + templateVersion, + componentKey, + clusterMode, + cloudProvider, + request, + }: { + organizationId: string + templateKey: string + templateVersion: string + componentKey: string + clusterMode: PlatformClusterMode + cloudProvider: PlatformCloudVendor + request: PlatformComponentConfigurationPreviewRequest + }) => ({ + queryKey: [organizationId, templateKey, templateVersion, componentKey, clusterMode, cloudProvider, request], + async queryFn() { + const response = await platformConfigurationApi.resolvePlatformTemplateComponentConfiguration( + organizationId, + templateKey, + templateVersion, + componentKey, + clusterMode, + cloudProvider, + request + ) + return response.data + }, + }), +}) + +export const platformConfigurationMutations = { + async updateBinding({ + organizationId, + clusterId, + request, + }: { + organizationId: string + clusterId: string + request: ClusterPlatformBindingRequest + }) { + const response = await platformConfigurationApi.updateClusterPlatformBinding(organizationId, clusterId, request) + return response.data + }, +} diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 00aa35e7119..d2ab55ee45b 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -33,6 +33,7 @@ export * from './lib/cluster-creation-flow/step-eks/step-eks' export * from './lib/cluster-creation-flow/step-resources/step-resources' export * from './lib/cluster-creation-flow/step-features/step-features' export * from './lib/cluster-creation-flow/step-addons/step-addons' +export * from './lib/cluster-creation-flow/step-platform/step-platform' export * from './lib/cluster-creation-flow/step-summary/step-summary' export * from './lib/cluster-creation-flow/cluster-creation-flow' export * from './lib/cluster-addons' @@ -79,3 +80,8 @@ export * from './lib/gpu-resources-settings/gpu-resources-settings' export * from './lib/utils/has-gpu-instance' export * from './lib/utils/karpenter-feature-value' export * from './lib/cluster-resources-settings/cluster-resources-settings' +export * from './lib/platform-configuration/platform-configuration' +export * from './lib/platform-configuration/platform-configuration-utils' +export * from './lib/platform-configuration/platform-configuration-feature-flag' +export * from './lib/platform-configuration/hooks/use-platform-binding' +export * from './lib/platform-configuration/hooks/use-cluster-operator' diff --git a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx index 050e710e8f2..4189e1ebd9c 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx @@ -14,12 +14,21 @@ const mockCluster = clusterFactoryMock(1)[0] const mockOpenModal = jest.fn() const mockOpenModalConfirmation = jest.fn() const mockCopyToClipboard = jest.fn() +const mockDeployCluster = jest.fn() +const mockUseFeatureFlagEnabled = jest.fn(() => false) +const mockUsePlatformBinding = jest.fn( + (_props?: unknown): { data: { templateKey: string; templateVersion: string } | null } => ({ data: null }) +) let mockClusterStatus: ClusterStatus = { cluster_id: mockCluster.id, status: ClusterStateEnum.DEPLOYED, is_deployed: true, } +jest.mock('posthog-js/react', () => ({ + useFeatureFlagEnabled: (flag: string) => mockUseFeatureFlagEnabled(flag), +})) + jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => jest.fn(), @@ -51,9 +60,19 @@ jest.mock('@qovery/shared/util-hooks', () => ({ useCopyToClipboard: () => [undefined, mockCopyToClipboard], })) +jest.mock('../hooks/use-deploy-cluster/use-deploy-cluster', () => ({ + useDeployCluster: () => ({ mutate: mockDeployCluster }), +})) + +jest.mock('../platform-configuration/hooks/use-platform-binding', () => ({ + usePlatformBinding: (props: unknown) => mockUsePlatformBinding(props), +})) + describe('ClusterActions', () => { beforeEach(() => { jest.clearAllMocks() + mockUseFeatureFlagEnabled.mockReturnValue(false) + mockUsePlatformBinding.mockReturnValue({ data: null }) mockCluster.deployment_status = ClusterDeploymentStatusEnum.UP_TO_DATE mockClusterStatus = { cluster_id: mockCluster.id, @@ -134,6 +153,75 @@ describe('ClusterActions', () => { expect(screen.getByText('Update another version')).toBeInTheDocument() }) + it.each([ + { status: ClusterStateEnum.READY, isDeployed: false }, + { status: ClusterStateEnum.DEPLOYED, isDeployed: true }, + ])( + 'should deploy an Engine v2 self-managed cluster in $status state when the feature flag is enabled', + async ({ status, isDeployed }) => { + mockUseFeatureFlagEnabled.mockReturnValue(true) + mockUsePlatformBinding.mockReturnValue({ + data: { templateKey: 'qovery-cluster-v0', templateVersion: '0.1.0' }, + }) + const selfManagedCluster = { + ...mockCluster, + kubernetes: KubernetesEnum.SELF_MANAGED, + } + const clusterStatus: ClusterStatus = { + cluster_id: selfManagedCluster.id, + status, + is_deployed: isDeployed, + } + const { userEvent } = renderWithProviders( + , + { container: document.body } + ) + + expect(mockUsePlatformBinding).toHaveBeenCalledWith({ + organizationId: selfManagedCluster.organization.id, + clusterId: selfManagedCluster.id, + enabled: true, + }) + expect(screen.getByLabelText('Installation guide')).toBeInTheDocument() + await userEvent.click(screen.getByLabelText(/manage deployment/i)) + await userEvent.click(screen.getByRole('menuitem', { name: 'Deploy' })) + + expect(mockDeployCluster).toHaveBeenCalledWith({ + organizationId: selfManagedCluster.organization.id, + clusterId: selfManagedCluster.id, + }) + } + ) + + it('should not show deployment actions for a self-managed cluster when the feature flag is disabled', () => { + mockUsePlatformBinding.mockReturnValue({ + data: { templateKey: 'qovery-cluster-v0', templateVersion: '0.1.0' }, + }) + const selfManagedCluster = { + ...mockCluster, + kubernetes: KubernetesEnum.SELF_MANAGED, + } + + renderWithProviders() + + expect(screen.queryByLabelText(/manage deployment/i)).not.toBeInTheDocument() + expect(screen.getByLabelText('Installation guide')).toBeInTheDocument() + expect(mockUsePlatformBinding).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) + }) + + it('should not show deployment actions for a legacy self-managed cluster without a platform binding', () => { + mockUseFeatureFlagEnabled.mockReturnValue(true) + const selfManagedCluster = { + ...mockCluster, + kubernetes: KubernetesEnum.SELF_MANAGED, + } + + renderWithProviders() + + expect(screen.queryByLabelText(/manage deployment/i)).not.toBeInTheDocument() + expect(screen.getByLabelText('Installation guide')).toBeInTheDocument() + }) + it('should show "Update" instead of "Install" for non-deployed EKS Anywhere clusters', async () => { const eksAnywhereCluster = { ...clusterFactoryMock(1)[0], diff --git a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx index 5fdd45bdef1..a8f8dbc0afe 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx @@ -1,4 +1,5 @@ import { useLocation, useNavigate } from '@tanstack/react-router' +import { useFeatureFlagEnabled } from 'posthog-js/react' import { type Cluster, type ClusterStatus, @@ -28,6 +29,8 @@ import { useEksAnywhereClusterJwt } from '../hooks/use-eks-anywhere-cluster-jwt/ import { useStopCluster } from '../hooks/use-stop-cluster/use-stop-cluster' import { useUpdateEksAnywhereCommit } from '../hooks/use-update-eks-anywhere-commit/use-update-eks-anywhere-commit' import { useUpgradeCluster } from '../hooks/use-upgrade-cluster/use-upgrade-cluster' +import { usePlatformBinding } from '../platform-configuration/hooks/use-platform-binding' +import { PLATFORM_CONFIGURATION_FEATURE_FLAG } from '../platform-configuration/platform-configuration-feature-flag' import { SelectEksAnywhereCommitModal } from './select-eks-anywhere-commit-modal' type ActionToolbarVariant = 'default' | 'header' | 'card' @@ -66,6 +69,7 @@ function MenuManageDeployment({ cluster.kubernetes !== 'PARTIALLY_MANAGED' const clusterNeedUpdate = cluster.deployment_status !== 'UP_TO_DATE' && clusterStatus.status !== 'STOPPED' const isEksAnywhereCluster = cluster.kubernetes === 'PARTIALLY_MANAGED' + const isSelfManagedCluster = cluster.kubernetes === 'SELF_MANAGED' const hasEksAnywhereGitRepository = Boolean( cluster.infrastructure_charts_parameters?.eks_anywhere_parameters?.git_repository?.url ) @@ -77,7 +81,11 @@ function MenuManageDeployment({ isEksAnywhereCluster && hasEksAnywhereGitRepository && (isDeployAvailable(clusterStatus.status) || isRedeployAvailable(clusterStatus.status)) - const deployActionLabel = isEksAnywhereCluster ? 'Update' : clusterStatus.is_deployed ? 'Deploy' : 'Install' + const deployActionLabel = isEksAnywhereCluster + ? 'Update' + : isSelfManagedCluster || clusterStatus.is_deployed + ? 'Deploy' + : 'Install' const actionButtonVariant = hasTextActionButton ? 'solid' : 'outline' const actionButtonColor = clusterNeedUpdate || k8sUpdateAvailable ? 'yellow' : hasTextActionButton ? 'brand' : 'neutral' @@ -182,11 +190,11 @@ function MenuManageDeployment({ } - onSelect={mutationUpdate} + onSelect={isSelfManagedCluster ? mutationDeploy : mutationUpdate} className="relative" color={clusterNeedUpdate ? 'yellow' : 'brand'} > - Update + {isSelfManagedCluster ? 'Deploy' : 'Update'} {tooltipClusterNeedUpdate} ), @@ -417,11 +425,22 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: const navigate = useNavigate() const location = useLocation() const showSelfManagedGuideKey = 'show-self-managed-guide' + const isPlatformConfigurationEnabled = Boolean(useFeatureFlagEnabled(PLATFORM_CONFIGURATION_FEATURE_FLAG)) + const isSelfManaged = cluster.kubernetes === 'SELF_MANAGED' const { openModal, closeModal } = useModal() const { data: runningStatus } = useClusterRunningStatus({ organizationId: cluster.organization.id, clusterId: cluster.id, }) + const { data: platformBinding } = usePlatformBinding({ + organizationId: cluster.organization.id, + clusterId: cluster.id, + enabled: isPlatformConfigurationEnabled && isSelfManaged, + }) + const isEngineV2SelfManaged = + isPlatformConfigurationEnabled && + isSelfManaged && + Boolean(platformBinding?.templateKey && platformBinding.templateVersion) const searchParams = useMemo(() => { // @ts-expect-error TODO needs to be fixed @@ -470,10 +489,15 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: return () => (bool ? closeModal() : undefined) }, [searchParams, location.search, location.pathname, cluster.kubernetes, closeModal, openInstallationGuideModal]) - const primaryActionButton = match(cluster) + const installationGuideButton = match(cluster) .with({ cloud_provider: P.not('ON_PREMISE'), kubernetes: 'SELF_MANAGED' }, () => ( - @@ -481,6 +505,7 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: .with({ cloud_provider: 'ON_PREMISE', kubernetes: 'SELF_MANAGED' }, () => ( )) - .otherwise(() => ) + .otherwise(() => null) + const primaryActionButton = isSelfManaged ? ( + <> + {isEngineV2SelfManaged ? ( + + ) : null} + {installationGuideButton} + + ) : ( + + ) const logsButton = variant === 'card' && cluster.kubernetes !== 'SELF_MANAGED' ? ( diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.spec.tsx index 85cecbfe151..a601a7acd9a 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.spec.tsx @@ -85,6 +85,16 @@ describe('steps', () => { expect(result.map((s) => s.key)).toEqual(['general', 'kubeconfig', 'summary']) }) + it('should replace kubeconfig with platform layers for Engine v2 self-managed clusters', () => { + const data: ClusterGeneralData = { + installation_type: 'SELF_MANAGED', + } as ClusterGeneralData + + const result = steps(data, true) + + expect(result.map((step) => step.key)).toEqual(['general', 'platform', 'summary']) + }) + it('should return partially-managed steps', () => { const data: ClusterGeneralData = { installation_type: 'PARTIALLY_MANAGED', diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.tsx index 2739285054f..f6cefafaa0e 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.tsx @@ -1,5 +1,10 @@ import { useNavigate, useParams } from '@tanstack/react-router' -import { CloudProviderEnum, type SecretManagerAccess } from 'qovery-typescript-axios' +import { useFeatureFlagEnabled } from 'posthog-js/react' +import { + CloudProviderEnum, + type ClusterPlatformBindingRequest, + type SecretManagerAccess, +} from 'qovery-typescript-axios' import { type Dispatch, type PropsWithChildren, @@ -18,6 +23,7 @@ import { } from '@qovery/shared/interfaces' import { FunnelFlow } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { PLATFORM_CONFIGURATION_FEATURE_FLAG } from '../platform-configuration/platform-configuration-feature-flag' export interface ClusterContainerCreateContextInterface { currentStep: number @@ -32,6 +38,9 @@ export interface ClusterContainerCreateContextInterface { setKubeconfigData: Dispatch> addonsData: ClusterAddonsData setAddonsData: Dispatch> + platformConfigurationData?: ClusterPlatformBindingRequest + setPlatformConfigurationData?: Dispatch> + isEngineV2SelfManaged?: boolean creationFlowUrl: string } @@ -54,7 +63,7 @@ export const useClusterContainerCreateContext = () => { export const useMaybeClusterContainerCreateContext = () => useContext(ClusterContainerCreateContext) -export const steps = (clusterGeneralData?: ClusterGeneralData) => { +export const steps = (clusterGeneralData?: ClusterGeneralData, isEngineV2SelfManaged = false) => { return match(clusterGeneralData) .with({ installation_type: 'PARTIALLY_MANAGED' }, () => [ { title: 'Create new cluster', key: 'general' }, @@ -62,11 +71,19 @@ export const steps = (clusterGeneralData?: ClusterGeneralData) => { { title: 'EKS configuration', key: 'eks' }, { title: 'Ready to install', key: 'summary' }, ]) - .with({ installation_type: 'SELF_MANAGED' }, () => [ - { title: 'Create new cluster', key: 'general' }, - { title: 'Kubeconfig', key: 'kubeconfig' }, - { title: 'Ready to install', key: 'summary' }, - ]) + .with({ installation_type: 'SELF_MANAGED' }, () => + isEngineV2SelfManaged + ? [ + { title: 'Create new cluster', key: 'general' }, + { title: 'Platform layers', key: 'platform' }, + { title: 'Ready to install', key: 'summary' }, + ] + : [ + { title: 'Create new cluster', key: 'general' }, + { title: 'Kubeconfig', key: 'kubeconfig' }, + { title: 'Ready to install', key: 'summary' }, + ] + ) .with({ installation_type: 'MANAGED', cloud_provider: 'SCW' }, () => [ { title: 'Create new cluster', key: 'general' }, { title: 'Resources', key: 'resources' }, @@ -94,6 +111,10 @@ export const steps = (clusterGeneralData?: ClusterGeneralData) => { .otherwise(() => []) } +export function isEngineV2SelfManagedFlow(slug: string | undefined, featureEnabled: boolean) { + return featureEnabled && Boolean(slug?.endsWith('-self-managed')) +} + export const defaultResourcesData: ClusterResourcesData = { cluster_type: 'MANAGED', disk_size: 50, @@ -140,17 +161,34 @@ export function ClusterCreationFlow({ children }: PropsWithChildren) { kedaActivated: false, secretManagers: [], }) + const [platformConfigurationData, setPlatformConfigurationData] = useState< + ClusterPlatformBindingRequest | undefined + >() + const isPlatformConfigurationEnabled = useFeatureFlagEnabled(PLATFORM_CONFIGURATION_FEATURE_FLAG) + const isEngineV2SelfManaged = isEngineV2SelfManagedFlow(slug, Boolean(isPlatformConfigurationEnabled)) const navigate = useNavigate() useDocumentTitle('Creation - Cluster') const creationFlowUrl = `/organization/${organizationId}/cluster/create/${slug}` - const currentSteps = steps(generalData) + const currentSteps = steps(generalData, isEngineV2SelfManaged) useEffect(() => { if (slug) { const defaultOptions: Partial | undefined = match(slug) + .when( + () => isEngineV2SelfManaged, + () => { + const provider = slug.replace(/-self-managed$/, '').toUpperCase() + if (!Object.values(CloudProviderEnum).includes(provider as CloudProviderEnum)) return undefined + + return { + installation_type: 'SELF_MANAGED' as const, + cloud_provider: provider as CloudProviderEnum, + } + } + ) .with('aws-eks-anywhere', () => ({ installation_type: 'PARTIALLY_MANAGED' as ClusterGeneralData['installation_type'], cloud_provider: CloudProviderEnum.AWS, @@ -179,7 +217,7 @@ export function ClusterCreationFlow({ children }: PropsWithChildren) { }) } } - }, [setGeneralData, slug]) + }, [isEngineV2SelfManaged, setGeneralData, slug]) return ( diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.spec.tsx index 08343c16c3c..dc21300371f 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.spec.tsx @@ -6,8 +6,10 @@ jest.mock('posthog-js', () => ({ capture: jest.fn(), })) +const mockUseFeatureFlagEnabled = jest.fn((_flag: string) => false) + jest.mock('posthog-js/react', () => ({ - useFeatureFlagEnabled: jest.fn(() => false), + useFeatureFlagEnabled: (flag: string) => mockUseFeatureFlagEnabled(flag), })) const mockShowPylonForm = jest.fn() @@ -40,12 +42,14 @@ jest.mock('@qovery/shared/ui', () => ({ })) const mockUseParams = jest.fn((_options?: { strict?: boolean }) => ({ organizationId: 'test-org-id' })) +const mockNavigate = jest.fn() jest.mock('@tanstack/react-router', () => { const React = jest.requireActual('react') return { ...jest.requireActual('@tanstack/react-router'), useParams: (options?: { strict?: boolean }) => mockUseParams(options), + useNavigate: () => mockNavigate, Link: React.forwardRef( ( { children, ...props }: { children?: React.ReactNode; [key: string]: unknown }, @@ -67,6 +71,7 @@ describe('ClusterNew', () => { beforeEach(() => { jest.clearAllMocks() mockUseParams.mockReturnValue({ organizationId: 'test-org-id' }) + mockUseFeatureFlagEnabled.mockReturnValue(false) mockUseClusterCreationRestriction.mockReturnValue({ isClusterCreationRestricted: false, isNoCreditCardRestriction: false, @@ -140,6 +145,20 @@ describe('ClusterNew', () => { }) }) + it('should start the Engine v2 creation flow when clicking self-managed under the feature flag', async () => { + mockUseFeatureFlagEnabled.mockImplementation((flag) => flag === 'engine-v2-platform-configuration') + const { userEvent } = renderWithProviders() + + await userEvent.click(getProviderCard('Amazon Web Services') as Element) + await userEvent.click(getExpandedSelfManagedButton() as Element) + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/organization/$organizationId/cluster/create/$slug', + params: { organizationId: 'test-org-id', slug: 'aws-self-managed' }, + }) + expect(mockOpenModal).not.toHaveBeenCalled() + }) + it('should open demo modal when clicking local machine option', async () => { const { userEvent } = renderWithProviders() diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.tsx index c43fcd5e48b..037f3875d1b 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.tsx @@ -1,4 +1,4 @@ -import { Link, useParams } from '@tanstack/react-router' +import { Link, useNavigate, useParams } from '@tanstack/react-router' import clsx from 'clsx' import Azure from 'devicon/icons/azure/azure-original.svg' import DigitalOcean from 'devicon/icons/digitalocean/digitalocean-original.svg' @@ -16,6 +16,7 @@ import { twMerge } from '@qovery/shared/util-js' import { AddCreditCardModalFeature } from '../../add-credit-card-modal-feature/add-credit-card-modal-feature' import { ClusterInstallationGuideModal } from '../../cluster-installation-guide-modal/cluster-installation-guide-modal' import { useClusterCreationRestriction } from '../../hooks/use-cluster-creation-restriction/use-cluster-creation-restriction' +import { PLATFORM_CONFIGURATION_FEATURE_FLAG } from '../../platform-configuration/platform-configuration-feature-flag' const Qovery = '/assets/logos/logo-icon.svg' @@ -40,7 +41,8 @@ type CardOptionProps = RestrictedActionProps & { } | { selectedInstallationType: 'self-managed' - openInstallationGuideModal: () => void + onSelect: () => void + opensInstallationGuide: boolean } | { selectedInstallationType: 'partially-managed' @@ -109,28 +111,34 @@ function CardOption({ icon, title, description, selectedCloudProvider, recommend : undefined return match(props) - .with({ selectedInstallationType: 'self-managed' }, ({ selectedInstallationType, openInstallationGuideModal }) => ( - - )) + .with( + { selectedInstallationType: 'self-managed' }, + ({ selectedInstallationType, onSelect, opensInstallationGuide }) => ( + + ) + ) .with({ selectedInstallationType: 'managed' }, ({ selectedInstallationType }) => disabled ? ( + + + ) : 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. + + )} + + + ) +} + +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.isEngineV2SelfManaged ? ( + + ) : null}
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..f8a0faff2ca 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 @@ -16,6 +16,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 +47,23 @@ 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() const { data: cloudProviderInstanceTypes } = useCloudProviderInstanceTypes( match(generalData) @@ -81,6 +94,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 +103,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 +136,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,8 +163,11 @@ export function StepSummary({ organizationId }: StepSummaryProps) { } : {} - if (generalData.installation_type === 'SELF_MANAGED' && kubeconfigData) { + if (generalData.installation_type === 'SELF_MANAGED' && (isEngineV2SelfManaged || kubeconfigData)) { try { + if (isEngineV2SelfManaged && !platformConfigurationData) { + throw new Error('Platform configuration is required for an Engine v2 self-managed cluster') + } const cluster = await createCluster({ organizationId, clusterRequest: { @@ -160,11 +183,21 @@ export function StepSummary({ organizationId }: StepSummaryProps) { ...awsLabelsGroups, }, }) - await editCloudProviderInfo({ - organizationId, - clusterId: cluster.id, - cloudProviderInfoRequest: cloudProviderCredentials, - }) + 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 +454,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 +471,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..3626246ae21 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,29 @@ 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 + const canUseOperator = isEngineV2Enabled && 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 +61,21 @@ export function ClusterInstallationGuideModal({ type, onClose, ...props }: Clust } const isDemo = props.mode === 'CREATE' ? props.isDemo : props.cluster.is_demo + const isOperatorManaged = Boolean(operatorStatus) + const isOperatorGuideLoading = + canUseOperator && (isOperatorStatusLoading || (isOperatorManaged && isOperatorBootstrapLoading)) + const hasOperatorGuideError = + canUseOperator && (isOperatorStatusError || (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 +84,7 @@ export function ClusterInstallationGuideModal({ type, onClose, ...props }: Clust
- {props.mode === 'EDIT' && type === 'ON_PREMISE' && ( + {props.mode === 'EDIT' && type === 'ON_PREMISE' && !isOperatorManaged && !hasOperatorGuideError && ( @@ -53,7 +97,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. + + + + + ) : 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. +

    1. Download the operator values

    +

    + Save the generated values file. It contains the credentials assigned to this cluster. +

    + +
  2. +
  3. +

    2. Install the operator

    +

    + Run this command from the directory containing the downloaded values file. +

    +
    +                  
    +                    $ 
    +                    {operatorBootstrap.helmCommand}
    +                  
    +                  
    +                
    +
  4. +
  5. +

    3. Deploy your first environment

    +

    + Once the operator is connected, return to the Qovery console and deploy an environment on this + cluster. +

    +
  6. +
+ + ) : type === 'MANAGED' ? (
  1. Save the following yaml, it contains the Qovery configuration assigned to your cluster. @@ -79,16 +186,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..3508cffb1be --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-component-configuration.spec.tsx @@ -0,0 +1,232 @@ +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('allows editing catalog fields before a cluster exists using local validation', () => { + renderWithProviders() + + expect(screen.getByText('persistent-volume')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save configuration' })).toBeEnabled() + }) + + it('prevents saving an invalid numeric value during local validation', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Value must be between 1 and 52.')).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..1ee92efdd8f --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-component-configuration.tsx @@ -0,0 +1,243 @@ +import { + type PlatformComponentConfigurationResolutionResponse, + type PlatformTemplateComponentResponse, +} from 'qovery-typescript-axios' +import { match } from 'ts-pattern' +import { + Badge, + Button, + Callout, + CatalogVariableInput, + type CatalogVariableValue, + Heading, + Icon, +} from '@qovery/shared/ui' +import { + formatCatalogKey, + getCatalogFieldValidationError, + getCatalogVariableValue, + isCatalogFieldValueFulfilled, +} from '@qovery/shared/util-js' +import { + getFieldViolation, + getUnmappedViolations, + isPlatformConfigurationReady, + toCatalogVariableField, +} from './platform-configuration-utils' + +function getLocalFieldValidationError( + field: ReturnType, + value: CatalogVariableValue | undefined +) { + const validationError = getCatalogFieldValidationError(field, value) + if (validationError || typeof value !== 'string' || !value) return validationError + if (typeof field.min !== 'number' && typeof field.max !== 'number') return undefined + + const numberValue = Number(value) + if (!Number.isFinite(numberValue)) return 'Value must be a number.' + if ( + typeof field.min === 'number' && + typeof field.max === 'number' && + (numberValue < field.min || numberValue > field.max) + ) { + return `Value must be between ${field.min} and ${field.max}.` + } + if (typeof field.min === 'number' && numberValue < field.min) return `Value must be at least ${field.min}.` + if (typeof field.max === 'number' && numberValue > field.max) return `Value must be at most ${field.max}.` + return undefined +} + +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 + validationMode?: 'local' | 'resolver' +} + +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, + validationMode = 'resolver', +}: PlatformComponentConfigurationProps) { + const fields = preview?.fields ?? component.fields + const requirements = preview?.requirements ?? [] + const violations = preview?.violations ?? [] + const unmappedViolations = getUnmappedViolations(violations, fields, requirements) + const localValidationErrors = new Map( + validationMode === 'local' + ? fields.flatMap((field) => { + const catalogField = toCatalogVariableField(field) + const value = getCatalogVariableValue(field, profileConfig[field.key]) + const error = + catalogField.required && !isCatalogFieldValueFulfilled(value) + ? `${catalogField.label} is required.` + : getLocalFieldValidationError(catalogField, value) + return error ? [[field.key, error] as const] : [] + }) + : [] + ) + const ready = preview + ? isPlatformConfigurationReady(violations, requirements) + : validationMode === 'local' && localValidationErrors.size === 0 + + return ( +
+
+
+ {formatCatalogKey(component.key)} + {component.description ?

{component.description}

: null} +
+ {isFetching ? ( + + Checking… + + ) : (preview || validationMode === 'local') && !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} + + + ))} +
+ )} + +
+ +
+
+ ) +} 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..bd0299ced2d --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-catalog.spec.tsx @@ -0,0 +1,203 @@ +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 without opening component configuration', async () => { + const { userEvent } = renderWithProviders( + + ) + + expect(screen.queryByRole('button', { name: 'Loki HELM' })).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(defaultProps.onSave).toHaveBeenCalledTimes(1) + }) + + it('hides the template and management modes and keeps components without configuration static', 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() + expect(screen.queryByRole('button', { name: 'Cluster agent HELM' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Shell agent HELM' })).not.toBeInTheDocument() + + await userEvent.click(screen.getByText('Cluster agent')) + expect(defaultProps.onComponentSelect).not.toHaveBeenCalled() + }) +}) 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..17a6009bb4a --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-catalog.tsx @@ -0,0 +1,192 @@ +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' +import { hasConfigurableFields } from './platform-configuration-utils' + +interface PlatformConfigurationCatalogProps { + binding: ClusterPlatformBindingResponse | null | undefined + cloudProvider?: PlatformCloudVendor + clusterMode?: PlatformClusterMode + componentsConfigurable?: boolean + 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, + componentsConfigurable = true, + 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) + }} + /> + +
+ ) : null} + +
+ {layer.components.map((component) => { + const content = ( + <> + + {formatCatalogKey(component.key)} + {component.description ? ( + + {component.description} + + ) : null} + + + + {formatCatalogKey(component.kind)} + + {componentsConfigurable && hasConfigurableFields(component) ? ( + + ) : null} + + + ) + + return componentsConfigurable && hasConfigurableFields(component) ? ( + + ) : ( +
+ {content} +
+ ) + })} +
+ + {skipped ? ( +

+ {bindingLayer?.reason ?? 'Not applicable to this cluster.'} +

+ ) : null} +
+
+ ) + })} +
+ +
+ {onPrevious ? ( + + ) : ( + + )} + +
+
+ ) +} 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..9ae8181c1ba --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.spec.ts @@ -0,0 +1,231 @@ +import { + type ClusterPlatformBindingResponse, + type FieldSchemaResponse, + type PlatformTemplateSummaryResponse, +} from 'qovery-typescript-axios' +import { + applyPlatformConfigurationDefaults, + createPlatformConfigurationDraft, + getCurrentPlatformConfigurationPreview, + isPlatformConfigurationReady, + 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) + expect(toPlatformConfigurationValue(field, '')).toBeUndefined() + }) + + 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..4947defb5db --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.ts @@ -0,0 +1,191 @@ +import { + type CloudProviderEnum, + type CloudVendorEnum, + type ClusterPlatformBindingResponse, + type FieldSchemaResponse, + type KubernetesEnum, + type PlatformCloudVendor, + type PlatformClusterMode, + type PlatformComponentConfigurationResolutionResponse, + type PlatformComponentConfigurationViolationResponse, + type PlatformComponentInputRequirementResponse, + type PlatformTemplateComponentResponse, + type PlatformTemplateSummaryResponse, +} from 'qovery-typescript-axios' +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 { + if (!cloudProvider) return undefined + if (cloudProvider === 'ON_PREMISE') return 'UNKNOWN' + return cloudProvider +} + +export function toPlatformClusterMode(kubernetes: KubernetesEnum | undefined): PlatformClusterMode | undefined { + if (kubernetes === 'MANAGED') return 'QOVERY_MANAGED' + if (kubernetes === 'SELF_MANAGED') return 'CUSTOMER_MANAGED' + return 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' || value.trim() === '') return undefined + + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : value +} + +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') +} + +export function hasConfigurableFields(component: PlatformTemplateComponentResponse | undefined) { + return Boolean(component?.fields.length) +} 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..84eb91709ea --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration.tsx @@ -0,0 +1,214 @@ +import { type PlatformCloudVendor, type PlatformClusterMode } from 'qovery-typescript-axios' +import { useMemo, useState } from 'react' +import { Button, Callout, type CatalogVariableValue, Icon } from '@qovery/shared/ui' +import { useDebounce } from '@qovery/shared/util-hooks' +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, + hasConfigurableFields, + 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 + const previewRequest = useMemo(() => { + const componentKey = selectedComponent?.key + const storedProfileConfig = state && componentKey ? state.draft.managedConfig[componentKey] ?? {} : {} + return { + profileConfig: selectedComponent + ? applyPlatformConfigurationDefaults(selectedComponent.fields, storedProfileConfig) + : {}, + clusterInputs: state && componentKey ? state.draft.customerProvidedInputs[componentKey] ?? {} : {}, + componentOutputs: {}, + } + }, [selectedComponent, state]) + const { profileConfig, clusterInputs } = previewRequest + 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: hasConfigurableFields(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, + }) + + 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 ( +
+ + +
+ ) +} 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..d21af0cc8d2 --- /dev/null +++ b/libs/shared/ui/src/lib/components/catalog-variable-input/catalog-variable-input.spec.tsx @@ -0,0 +1,78 @@ +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('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..f9dd9b89923 --- /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 type { CatalogVariableField, CatalogVariableValue } from '@qovery/shared/util-js' + +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) { + 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) + }} + /> + ) + } + + if (field.type === 'bool') { + if (booleanControl === 'toggle') { + return ( +
+ + {error ?

{error}

: null} +
+ ) + } + + return ( +
+
+ { + if (checked === 'indeterminate') return + onChange(checked) + }} + /> + +
+ {field.description ? ( +

{field.description}

+ ) : null} + {error ?

{error}

: null} +
+ ) + } + + 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 From a2a0d1abe9ebae47cc8d9f5e2274d59ad853e2b7 Mon Sep 17 00:00:00 2001 From: Pierre Gerbelot Date: Thu, 16 Jul 2026 21:24:27 +0200 Subject: [PATCH 2/2] fix(engine-v2): harden platform configuration flows - guard operator queries against the undefined PostHog flag state (modal, create/settings platform routes) so flag-off orgs never hit operator endpoints and refreshes no longer bounce users off the platform step - make engine-v2 cluster creation idempotent on retry and redirect to the platform step when platform data is missing instead of failing silently - fix bool cluster-input requirements round-trip and keep requirement inputs mounted during preview refreshes (no more focus loss per keystroke) - keep cleared field values empty instead of resurrecting schema defaults; omit cleared markers from resolver and save payloads - make components without catalog fields openable so requirement-only components can be configured - exclude Stop / Upgrade K8s actions for self-managed clusters - restore bool-before-allowedValues precedence in CatalogVariableInput (blueprint regression) with a non-regression test - key PlatformConfiguration by cluster and re-seed when the template list changes; surface binding fetch errors instead of redirecting - add Back navigation to step-platform error/empty states - drop dead validationMode/componentsConfigurable props, use ts-pattern for enum mappers, import catalog types directly from util-js Co-Authored-By: Claude Fable 5 --- .../cluster/$clusterId/settings/platform.tsx | 28 +++++- .../cluster/create/$slug/platform.tsx | 12 ++- .../lib/cluster-actions/cluster-actions.tsx | 8 +- .../step-platform/step-platform.tsx | 68 +++++++++------ .../step-summary/step-summary.tsx | 49 +++++++---- .../cluster-installation-guide-modal.tsx | 15 ++-- .../platform-component-configuration.spec.tsx | 31 +------ .../platform-component-configuration.tsx | 86 +++++-------------- .../platform-configuration-catalog.spec.tsx | 24 +++--- .../platform-configuration-catalog.tsx | 18 +--- .../platform-configuration-utils.spec.ts | 11 ++- .../platform-configuration-utils.ts | 29 ++++--- .../platform-configuration.tsx | 57 +++++++++--- .../catalog-variable-input.spec.tsx | 16 ++++ .../catalog-variable-input.tsx | 38 ++++---- 15 files changed, 274 insertions(+), 216 deletions(-) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform.tsx index b5f953dba38..6e8137e7954 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/platform.tsx @@ -9,7 +9,7 @@ import { usePlatformBinding, } from '@qovery/domains/clusters/feature' import { SettingsHeading } from '@qovery/shared/console-shared' -import { Section } from '@qovery/shared/ui' +import { Callout, Icon, Section } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' export const Route = createFileRoute( @@ -24,7 +24,11 @@ function RouteComponent() { const { organizationId = '', clusterId = '' } = useParams({ strict: false }) const isPlatformConfigurationEnabled = useFeatureFlagEnabled(PLATFORM_CONFIGURATION_FEATURE_FLAG) const { data: cluster, isLoading: isClusterLoading } = useCluster({ organizationId, clusterId }) - const { data: platformBinding, isLoading: isPlatformBindingLoading } = usePlatformBinding({ + const { + data: platformBinding, + isLoading: isPlatformBindingLoading, + isError: isPlatformBindingError, + } = usePlatformBinding({ organizationId, clusterId, enabled: Boolean(isPlatformConfigurationEnabled), @@ -34,6 +38,25 @@ function RouteComponent() { if (isPlatformConfigurationEnabled === undefined) return null + // A binding fetch error is not "no binding": surface it instead of silently + // redirecting the user off the page. + if (isPlatformBindingError) { + return ( +
+ + + + + + The platform configuration could not be loaded. Refresh the page to try again. + +
+ ) + } + if ( isPlatformConfigurationEnabled === false || (!isPlatformBindingLoading && !platformBinding) || @@ -58,6 +81,7 @@ function RouteComponent() { />
} onSelect={mutationStop}> Stop 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 index c636a72d94b..2e1961cfc6f 100644 --- 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 @@ -1,15 +1,8 @@ import { type ClusterPlatformBindingRequest } from 'qovery-typescript-axios' import { useEffect, useMemo, useState } from 'react' -import { - Button, - Callout, - type CatalogVariableValue, - FunnelFlowBody, - Icon, - LoaderSpinner, - Section, -} from '@qovery/shared/ui' +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' @@ -19,6 +12,7 @@ import { createPlatformConfigurationDraft, findPlatformComponent, getCurrentPlatformConfigurationPreview, + omitEmptyValues, toPlatformCloudVendor, toPlatformConfigurationValue, } from '../../platform-configuration/platform-configuration-utils' @@ -68,17 +62,26 @@ export function StepPlatform({ organizationId, onPrevious, onSubmit }: StepPlatf : createPlatformConfigurationDraft(template, null) : undefined const selectedComponent = template ? findPlatformComponent(template, componentDraft?.componentKey) : undefined - const previewRequest = useMemo( + // 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 ?? {}, - componentOutputs: {}, }), [componentDraft?.clusterInputs, componentDraft?.managedConfig, selectedComponent] ) - const { profileConfig, clusterInputs } = previewRequest + const previewRequest = useMemo( + () => ({ + profileConfig: omitEmptyValues(profileConfig), + clusterInputs, + componentOutputs: {}, + }), + [profileConfig, clusterInputs] + ) const previewQuery = useMemo( () => ({ componentKey: selectedComponent?.key, request: previewRequest }), [previewRequest, selectedComponent?.key] @@ -175,7 +178,8 @@ export function StepPlatform({ organizationId, onPrevious, onSubmit }: StepPlatf ...current, managedConfig: { ...current.managedConfig, - [componentDraft.componentKey]: componentDraft.managedConfig, + // '' entries are only display markers for cleared fields — never persist them. + [componentDraft.componentKey]: omitEmptyValues(componentDraft.managedConfig), }, customerProvidedInputs, } @@ -191,12 +195,19 @@ export function StepPlatform({ organizationId, onPrevious, onSubmit }: StepPlatf
) : isError ? ( - - - - - Platform layers could not be loaded. Please try again. - +
+ + + + + Platform layers could not be loaded. Please try again. + +
+ +
+
) : template && draft && selectedComponent ? (
+
+
)}
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 f8a0faff2ca..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' @@ -64,6 +65,8 @@ export function StepSummary({ organizationId }: StepSummaryProps) { 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) @@ -164,25 +167,33 @@ export function StepSummary({ organizationId }: StepSummaryProps) { : {} 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 { - if (isEngineV2SelfManaged && !platformConfigurationData) { - throw new Error('Platform configuration is required for an Engine v2 self-managed cluster') - } - 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, - }, - }) + // 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, 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 3626246ae21..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 @@ -27,7 +27,10 @@ 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 - const canUseOperator = isEngineV2Enabled && cluster?.kubernetes === 'SELF_MANAGED' + // 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, @@ -61,11 +64,13 @@ export function ClusterInstallationGuideModal({ type, onClose, ...props }: Clust } const isDemo = props.mode === 'CREATE' ? props.isDemo : props.cluster.is_demo - const isOperatorManaged = Boolean(operatorStatus) + const isOperatorManaged = canUseOperator && Boolean(operatorStatus) const isOperatorGuideLoading = - canUseOperator && (isOperatorStatusLoading || (isOperatorManaged && isOperatorBootstrapLoading)) - const hasOperatorGuideError = - canUseOperator && (isOperatorStatusError || (isOperatorManaged && isOperatorBootstrapError)) + 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() 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 index 3508cffb1be..8c374c4a92e 100644 --- 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 @@ -77,37 +77,10 @@ describe('PlatformComponentConfiguration', () => { expect(screen.queryByRole('option', { name: 'object-storage' })).not.toBeInTheDocument() }) - it('allows editing catalog fields before a cluster exists using local validation', () => { - renderWithProviders() + it('keeps saving disabled until a resolver preview is available', () => { + renderWithProviders() expect(screen.getByText('persistent-volume')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Save configuration' })).toBeEnabled() - }) - - it('prevents saving an invalid numeric value during local validation', () => { - renderWithProviders( - - ) - - expect(screen.getByText('Value must be between 1 and 52.')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save configuration' })).toBeDisabled() }) 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 index 1ee92efdd8f..cbb9a80357c 100644 --- 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 @@ -1,23 +1,12 @@ 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, - type CatalogVariableValue, - Heading, - Icon, -} from '@qovery/shared/ui' -import { - formatCatalogKey, - getCatalogFieldValidationError, - getCatalogVariableValue, - isCatalogFieldValueFulfilled, -} from '@qovery/shared/util-js' +import { Badge, Button, Callout, CatalogVariableInput, Heading, Icon } from '@qovery/shared/ui' +import { type CatalogVariableValue, formatCatalogKey, getCatalogVariableValue } from '@qovery/shared/util-js' import { getFieldViolation, getUnmappedViolations, @@ -25,28 +14,6 @@ import { toCatalogVariableField, } from './platform-configuration-utils' -function getLocalFieldValidationError( - field: ReturnType, - value: CatalogVariableValue | undefined -) { - const validationError = getCatalogFieldValidationError(field, value) - if (validationError || typeof value !== 'string' || !value) return validationError - if (typeof field.min !== 'number' && typeof field.max !== 'number') return undefined - - const numberValue = Number(value) - if (!Number.isFinite(numberValue)) return 'Value must be a number.' - if ( - typeof field.min === 'number' && - typeof field.max === 'number' && - (numberValue < field.min || numberValue > field.max) - ) { - return `Value must be between ${field.min} and ${field.max}.` - } - if (typeof field.min === 'number' && numberValue < field.min) return `Value must be at least ${field.min}.` - if (typeof field.max === 'number' && numberValue > field.max) return `Value must be at most ${field.max}.` - return undefined -} - interface PlatformComponentConfigurationProps { clusterInputs: Record component: PlatformTemplateComponentResponse @@ -58,7 +25,6 @@ interface PlatformComponentConfigurationProps { onSave: () => void preview?: PlatformComponentConfigurationResolutionResponse profileConfig: Record - validationMode?: 'local' | 'resolver' } function RequirementStatus({ status }: { status: 'MISSING' | 'READY' }) { @@ -83,28 +49,24 @@ export function PlatformComponentConfiguration({ onSave, preview, profileConfig, - validationMode = 'resolver', }: PlatformComponentConfigurationProps) { const fields = preview?.fields ?? component.fields - const requirements = preview?.requirements ?? [] + // 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 localValidationErrors = new Map( - validationMode === 'local' - ? fields.flatMap((field) => { - const catalogField = toCatalogVariableField(field) - const value = getCatalogVariableValue(field, profileConfig[field.key]) - const error = - catalogField.required && !isCatalogFieldValueFulfilled(value) - ? `${catalogField.label} is required.` - : getLocalFieldValidationError(catalogField, value) - return error ? [[field.key, error] as const] : [] - }) - : [] - ) - const ready = preview - ? isPlatformConfigurationReady(violations, requirements) - : validationMode === 'local' && localValidationErrors.size === 0 + const ready = preview ? isPlatformConfigurationReady(violations, requirements) : false return (
@@ -117,7 +79,7 @@ export function PlatformComponentConfiguration({ Checking… - ) : (preview || validationMode === 'local') && !ready ? ( + ) : preview && !ready ? ( Action required @@ -154,7 +116,7 @@ export function PlatformComponentConfiguration({ booleanControl="checkbox" field={toCatalogVariableField(field)} value={getCatalogVariableValue(field, profileConfig[field.key])} - error={getFieldViolation(violations, field.key) ?? localValidationErrors.get(field.key)} + error={getFieldViolation(violations, field.key)} onChange={(value) => onProfileConfigChange(field.key, value)} /> ))} @@ -179,7 +141,7 @@ export function PlatformComponentConfiguration({ onClusterInputChange(requirement.key, value)} /> @@ -228,11 +190,7 @@ export function PlatformComponentConfiguration({ type="button" size="lg" loading={isSaving} - disabled={ - validationMode === 'local' - ? fields.length > 0 && !ready - : !preview || isFetching || hasPreviewError || !ready - } + disabled={!preview || isFetching || hasPreviewError || !ready} onClick={onSave} > 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 index bd0299ced2d..9a1f4b04c9b 100644 --- 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 @@ -154,22 +154,20 @@ describe('PlatformConfigurationCatalog', () => { expect(defaultProps.onSave).toHaveBeenCalledTimes(1) }) - it('reuses the catalog for creation without opening component configuration', async () => { + it('reuses the catalog for creation with its own labels and back navigation', async () => { + const onPrevious = jest.fn() const { userEvent } = renderWithProviders( - + ) - expect(screen.queryByRole('button', { name: 'Loki HELM' })).not.toBeInTheDocument() 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 configuration static', async () => { + it('hides the template and management modes and keeps components without catalog fields selectable', async () => { const agentTemplate = { ...template, layers: [ @@ -194,10 +192,10 @@ describe('PlatformConfigurationCatalog', () => { expect(screen.queryByText('Platform template')).not.toBeInTheDocument() expect(screen.queryByText('CUSTOMER MANAGED')).not.toBeInTheDocument() expect(screen.queryByText('QOVERY MANAGED')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Cluster agent HELM' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Shell agent HELM' })).not.toBeInTheDocument() - await userEvent.click(screen.getByText('Cluster agent')) - expect(defaultProps.onComponentSelect).not.toHaveBeenCalled() + // 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 index 17a6009bb4a..30cf864bd43 100644 --- 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 @@ -7,13 +7,11 @@ import { import { match } from 'ts-pattern' import { Accordion, Badge, Button, Checkbox, Heading, Icon } from '@qovery/shared/ui' import { formatCatalogKey } from '@qovery/shared/util-js' -import { hasConfigurableFields } from './platform-configuration-utils' interface PlatformConfigurationCatalogProps { binding: ClusterPlatformBindingResponse | null | undefined cloudProvider?: PlatformCloudVendor clusterMode?: PlatformClusterMode - componentsConfigurable?: boolean description?: string layerSelections: Record isSaving: boolean @@ -37,7 +35,6 @@ export function PlatformConfigurationCatalog({ binding, cloudProvider, clusterMode, - componentsConfigurable = true, description = 'Enable the layers to include in the next cluster deployment, then configure their components.', layerSelections, isSaving, @@ -134,14 +131,14 @@ export function PlatformConfigurationCatalog({ {formatCatalogKey(component.kind)} - {componentsConfigurable && hasConfigurableFields(component) ? ( - - ) : null} + ) - return componentsConfigurable && hasConfigurableFields(component) ? ( + // Components without catalog fields stay openable: their configuration + // may consist solely of resolver-provided cluster-input requirements. + return ( - ) : ( -
- {content} -
) })}
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 index 9ae8181c1ba..e9cb13f6e93 100644 --- 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 @@ -8,6 +8,7 @@ import { createPlatformConfigurationDraft, getCurrentPlatformConfigurationPreview, isPlatformConfigurationReady, + omitEmptyValues, toCatalogVariableField, toPlatformCloudVendor, toPlatformClusterMode, @@ -36,7 +37,15 @@ describe('platform configuration utils', () => { it('converts number inputs at the API boundary without losing their type', () => { expect(toPlatformConfigurationValue(field, '24')).toBe(24) - expect(toPlatformConfigurationValue(field, '')).toBeUndefined() + // '' 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', () => { 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 index 4947defb5db..7640ab35f65 100644 --- 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 @@ -9,9 +9,9 @@ import { type PlatformComponentConfigurationResolutionResponse, type PlatformComponentConfigurationViolationResponse, type PlatformComponentInputRequirementResponse, - type PlatformTemplateComponentResponse, 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 { @@ -25,15 +25,17 @@ export interface PlatformConfigurationDraft { export function toPlatformCloudVendor( cloudProvider: CloudProviderEnum | CloudVendorEnum | undefined ): PlatformCloudVendor | undefined { - if (!cloudProvider) return undefined - if (cloudProvider === 'ON_PREMISE') return 'UNKNOWN' - return cloudProvider + return match(cloudProvider) + .with(undefined, () => undefined) + .with('ON_PREMISE', () => 'UNKNOWN' as const) + .otherwise((provider) => provider) } export function toPlatformClusterMode(kubernetes: KubernetesEnum | undefined): PlatformClusterMode | undefined { - if (kubernetes === 'MANAGED') return 'QOVERY_MANAGED' - if (kubernetes === 'SELF_MANAGED') return 'CUSTOMER_MANAGED' - return undefined + return match(kubernetes) + .with('MANAGED', () => 'QOVERY_MANAGED' as const) + .with('SELF_MANAGED', () => 'CUSTOMER_MANAGED' as const) + .otherwise(() => undefined) } type PlatformFieldDescriptor = Pick< @@ -132,12 +134,19 @@ export function applyPlatformConfigurationDefaults( export function toPlatformConfigurationValue(field: Pick, value: CatalogVariableValue) { if (field.type !== 'number') return value - if (typeof value !== 'string' || value.trim() === '') return undefined + 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, @@ -185,7 +194,3 @@ export function isPlatformConfigurationReady( ) { return violations.length === 0 && requirements.every((requirement) => requirement.status === 'READY') } - -export function hasConfigurableFields(component: PlatformTemplateComponentResponse | undefined) { - return Boolean(component?.fields.length) -} 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 index 84eb91709ea..91d7963691d 100644 --- a/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration.tsx +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration.tsx @@ -1,7 +1,8 @@ import { type PlatformCloudVendor, type PlatformClusterMode } from 'qovery-typescript-axios' -import { useMemo, useState } from 'react' -import { Button, Callout, type CatalogVariableValue, Icon } from '@qovery/shared/ui' +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' @@ -15,7 +16,7 @@ import { findPlatformComponent, getCurrentPlatformConfigurationPreview, getTemplateId, - hasConfigurableFields, + omitEmptyValues, toPlatformConfigurationValue, updateComponentValue, } from './platform-configuration-utils' @@ -63,18 +64,45 @@ export function PlatformConfiguration({ const selectedTemplate = templates.find((template) => getTemplateId(template) === state?.templateId) const selectedComponent = selectedTemplate ? findPlatformComponent(selectedTemplate, state?.componentKey) : undefined - const previewRequest = useMemo(() => { + + // 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 - const storedProfileConfig = state && componentKey ? state.draft.managedConfig[componentKey] ?? {} : {} return { profileConfig: selectedComponent - ? applyPlatformConfigurationDefaults(selectedComponent.fields, storedProfileConfig) + ? applyPlatformConfigurationDefaults( + selectedComponent.fields, + state && componentKey ? state.draft.managedConfig[componentKey] ?? {} : {} + ) : {}, clusterInputs: state && componentKey ? state.draft.customerProvidedInputs[componentKey] ?? {} : {}, - componentOutputs: {}, } }, [selectedComponent, state]) - const { profileConfig, clusterInputs } = previewRequest + const previewRequest = useMemo( + () => ({ + profileConfig: omitEmptyValues(profileConfig), + clusterInputs, + componentOutputs: {}, + }), + [profileConfig, clusterInputs] + ) const previewQuery = useMemo( () => ({ componentKey: selectedComponent?.key, request: previewRequest }), [previewRequest, selectedComponent?.key] @@ -90,7 +118,7 @@ export function PlatformConfiguration({ clusterId, componentKey: debouncedPreviewQuery.componentKey, request: debouncedPreviewQuery.request, - enabled: hasConfigurableFields(selectedComponent) && debouncedPreviewQuery.componentKey === selectedComponent?.key, + enabled: Boolean(selectedComponent) && debouncedPreviewQuery.componentKey === selectedComponent?.key, }) const preview = getCurrentPlatformConfigurationPreview(previewData, selectedComponent?.key, isPreviewPending) @@ -154,7 +182,16 @@ export function PlatformConfiguration({ updateBinding({ organizationId, clusterId, - request: state.draft, + 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) { 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 index d21af0cc8d2..47c68b789fb 100644 --- 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 @@ -58,6 +58,22 @@ describe('CatalogVariableInput', () => { 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( 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 index f9dd9b89923..b4786d65019 100644 --- 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 @@ -4,8 +4,6 @@ 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 type { CatalogVariableField, CatalogVariableValue } from '@qovery/shared/util-js' - export interface CatalogVariableInputProps { autoFocus?: boolean booleanControl?: 'checkbox' | 'toggle' @@ -23,23 +21,8 @@ export function CatalogVariableInput({ onChange, value, }: CatalogVariableInputProps) { - 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) - }} - /> - ) - } - + // 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 ( @@ -85,6 +68,23 @@ export function CatalogVariableInput({ ) } + 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 (