From 8f5169473b218eaaed67d0bc4320c00cdec80004 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 14 Oct 2026 10:57:30 +0200 Subject: [PATCH] feat(clusters): only show the profile for clusters attached to the Operator The Profile tab and route were gated on the feature flag alone, so every cluster had them. They now also require a self-managed cluster attached to the Qovery Operator, like the Operator-specific cluster actions. useIsOperatorAttached reports undefined until the flags, the cluster and its Operator status are loaded, so the route does not redirect too early. Co-Authored-By: Claude Opus 5.5 --- .../cluster/$clusterId/profile/route.tsx | 12 ++++---- .../_authenticated/organization/route.tsx | 7 +++-- .../use-is-operator-attached.spec.ts | 28 +++++++++++++++++++ .../use-is-operator-attached.ts | 18 +++++++----- 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route.tsx index 95375ee8f2d..87296f8db54 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route.tsx @@ -1,6 +1,6 @@ import { Navigate, Outlet, createFileRoute, useParams } from '@tanstack/react-router' -import { useFeatureFlagEnabled } from 'posthog-js/react' import { z } from 'zod' +import { useCluster, useIsOperatorAttached } from '@qovery/domains/clusters/feature' import { LoaderSpinner } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -12,10 +12,12 @@ export const Route = createFileRoute('/_authenticated/organization/$organization function RouteComponent() { useDocumentTitle('Cluster - Profile') const { organizationId = '', clusterId = '' } = useParams({ strict: false }) - // Undefined until PostHog has loaded the flags: redirecting then would turn away users who have access. - const isProfileEnabled = useFeatureFlagEnabled('engine-v2-platform-configuration') + const { data: cluster, isLoading: isClusterLoading } = useCluster({ organizationId, clusterId }) + // Only Engine v2 clusters, attached to the Qovery Operator, have a profile. + const isOperatorAttached = useIsOperatorAttached(cluster) - if (isProfileEnabled === undefined) { + // Redirecting before the flags, the cluster and its Operator status are loaded would turn away users who have access. + if (isClusterLoading || (cluster && isOperatorAttached === undefined)) { return (
@@ -23,7 +25,7 @@ function RouteComponent() { ) } - if (!isProfileEnabled && organizationId && clusterId) { + if (!isOperatorAttached && organizationId && clusterId) { return ( cluster.metrics_parameters?.configuration?.alerting?.enabled) const currentCluster = clusters.find((cluster) => cluster.id === environment?.cluster_id) + // Only Engine v2 clusters, attached to the Qovery Operator, have a profile. + const isProfileAvailable = Boolean(useIsOperatorAttached(clusters.find((cluster) => cluster.id === params.clusterId))) const blueprintId = service && isBlueprintService(service) && isTerraform(service) ? service.blueprint_id : '' const { data: blueprint } = useBlueprint({ blueprintId, @@ -431,7 +432,7 @@ function useNavigationContext(): NavigationContext | null { : context.type === 'organization' ? context.tabs.filter((tab) => hasAlerting || tab.id !== 'alerts') : context.type === 'cluster' - ? context.tabs.filter((tab) => isEngineV2PlatformConfigurationEnabled || tab.id !== 'profile') + ? context.tabs.filter((tab) => isProfileAvailable || tab.id !== 'profile') : context.type === 'environment' ? context.tabs.filter((tab) => isAgenticWorkflowEnabled || tab.id !== 'automation') : context.tabs diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-is-operator-attached/use-is-operator-attached.spec.ts b/libs/domains/clusters/feature/src/lib/hooks/use-is-operator-attached/use-is-operator-attached.spec.ts index 63332aec01f..04c14b855e9 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-is-operator-attached/use-is-operator-attached.spec.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-is-operator-attached/use-is-operator-attached.spec.ts @@ -48,4 +48,32 @@ describe('useIsOperatorAttached', () => { expect(result.current).toBe(false) }) + + it('ignores self-managed clusters without Operator state', () => { + mockUseClusterOperatorStatus.mockReturnValue({ data: null }) + const { result } = renderHook(() => useIsOperatorAttached(cluster)) + + expect(result.current).toBe(false) + }) + + it('is unknown while the feature flags are loading', () => { + mockUseFeatureFlagEnabled.mockReturnValue(undefined) + const { result } = renderHook(() => useIsOperatorAttached(cluster)) + + expect(result.current).toBeUndefined() + }) + + it('is unknown while the cluster is loading', () => { + const { result } = renderHook(() => useIsOperatorAttached(undefined)) + + expect(mockUseClusterOperatorStatus).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) + expect(result.current).toBeUndefined() + }) + + it('is unknown while the Operator status is loading', () => { + mockUseClusterOperatorStatus.mockReturnValue({ data: undefined, isLoading: true }) + const { result } = renderHook(() => useIsOperatorAttached(cluster)) + + expect(result.current).toBeUndefined() + }) }) diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-is-operator-attached/use-is-operator-attached.ts b/libs/domains/clusters/feature/src/lib/hooks/use-is-operator-attached/use-is-operator-attached.ts index 55821470e76..23f842bb265 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-is-operator-attached/use-is-operator-attached.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-is-operator-attached/use-is-operator-attached.ts @@ -4,18 +4,22 @@ import { useClusterOperatorStatus } from '../use-cluster-operator-status/use-clu // Self-managed clusters may be attached to the Qovery Operator: the Cluster model does not say it, // only the Operator status does (q-core answers 404 for clusters without Operator state). -export function useIsOperatorAttached(cluster: Pick) { - const isEngineV2Enabled = Boolean(useFeatureFlagEnabled('engine-v2-platform-configuration')) - const enabled = isEngineV2Enabled && cluster.kubernetes === 'SELF_MANAGED' - const { data: operatorStatus } = useClusterOperatorStatus({ - organizationId: cluster.organization.id, - clusterId: cluster.id, +// Undefined until it is known: while the feature flags, the cluster or its Operator status are loading. +export function useIsOperatorAttached(cluster?: Pick) { + const isEngineV2Enabled = useFeatureFlagEnabled('engine-v2-platform-configuration') + const enabled = Boolean(isEngineV2Enabled) && cluster?.kubernetes === 'SELF_MANAGED' + const { data: operatorStatus, isLoading } = useClusterOperatorStatus({ + organizationId: cluster?.organization.id ?? '', + clusterId: cluster?.id ?? '', enabled, staleTime: 60_000, }) + if (isEngineV2Enabled === undefined || !cluster) return undefined // A disabled query still exposes cached data, which other views may have fetched without the flag. - return enabled && Boolean(operatorStatus && operatorStatus.status !== 'NOT_ATTACHED') + if (!enabled) return false + if (isLoading) return undefined + return Boolean(operatorStatus && operatorStatus.status !== 'NOT_ATTACHED') } export default useIsOperatorAttached