Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
425c8b4
Add Profile page
rmnbrd Sep 22, 2026
510b3c4
Implement main Profile page UI elements
rmnbrd Sep 22, 2026
7048e55
fix(clusters): disable unavailable cluster profile controls
rmnbrd Sep 22, 2026
14bdda4
feat(clusters): support dynamic platform configuration
rmnbrd Sep 22, 2026
491632f
feat(clusters): add the cluster profile layer sidebar
rmnbrd Sep 23, 2026
9a35d57
feat(clusters): arrange profile inputs in compact rows
rmnbrd Sep 23, 2026
ceedb39
feat(clusters): show positive empty state for unconfigured components
rmnbrd Sep 23, 2026
0c3a0ab
fix(clusters): constrain profile page to its container
rmnbrd Sep 23, 2026
8e26916
fix(clusters): match profile skeleton to configuration rows
rmnbrd Sep 23, 2026
011a43d
chore(clusters): log profile schema responses in development
rmnbrd Sep 23, 2026
140e228
fix(clusters): preserve resolved profile fields on refresh
rmnbrd Sep 23, 2026
9047d07
fix(clusters): match profile tab icons to sidebar
rmnbrd Sep 23, 2026
f4b141f
fix(clusters): remove operator footer from profile sidebar
rmnbrd Sep 24, 2026
9488c60
feat(clusters): support nested profile configuration fields
rmnbrd Sep 24, 2026
0b4e6aa
feat(clusters): remove heading
rmnbrd Sep 24, 2026
c8b64f6
fix(clusters): use section for profile content region
rmnbrd Sep 24, 2026
3347ef1
feat(clusters): add searchable cluster profile settings
rmnbrd Sep 24, 2026
ace665a
feat(clusters): save and deploy profile changes
rmnbrd Sep 25, 2026
1baf3fd
refactor(clusters): move profile state into context
rmnbrd Sep 25, 2026
3c3c077
fix(shared-ui): order highlight text classes consistently
rmnbrd Sep 25, 2026
cc2b245
fix(clusters): remove profile header surface background
rmnbrd Sep 25, 2026
a1f9427
feat(clusters): add Engine V2 cluster creation flow
rmnbrd Sep 30, 2026
fabe731
fix(clusters): disable components without configuration
rmnbrd Oct 1, 2026
a20fa2f
fix(clusters): reflect resolved layer statuses in profile
rmnbrd Oct 1, 2026
836e5c1
Merge remote-tracking branch 'origin/staging' into feat/cluster-engin…
rmnbrd Oct 1, 2026
7a61abf
fix(ui): preserve input defaults and select accessibility
rmnbrd Oct 1, 2026
5312633
fix(clusters): detect Engine v2 clusters for deployment actions
rmnbrd Oct 1, 2026
3810adf
feat(clusters): show Operator status in cluster profile
rmnbrd Oct 1, 2026
b2de6fb
fix(clusters): prevent dismissing install flow
rmnbrd Oct 1, 2026
2565d34
fix(clusters): create self-managed clusters without a template
rmnbrd Oct 2, 2026
00586e1
Merge remote-tracking branch 'origin/staging' into feat/cluster-engin…
rmnbrd Oct 2, 2026
43c5acf
fix(clusters): block creation when templates fail to load
rmnbrd Oct 2, 2026
8709cfe
fix(clusters): remove obsolete requirement status badge
rmnbrd Oct 2, 2026
4d2754f
fix(clusters): use list items for layer sidebar states
rmnbrd Oct 2, 2026
8ce71be
fix(clusters): use navbar links for profile components
rmnbrd Oct 2, 2026
aa201fa
fix(clusters): prevent leaving flow during cluster creation
rmnbrd Oct 2, 2026
9a31b28
fix(clusters): block saving invalid profile changes
rmnbrd Oct 2, 2026
209ccbe
fix(clusters): reset profile state when switching clusters
rmnbrd Oct 2, 2026
77db79a
feat(clusters): add component routes to cluster profile
rmnbrd Oct 2, 2026
b7de672
fix(clusters): ignore cached Operator status when disabled
rmnbrd Oct 2, 2026
ea4ea90
fix(clusters): prevent duplicate Operator updates
rmnbrd Oct 2, 2026
fde4e31
fix(clusters): remove obsolete update button comment
rmnbrd Oct 2, 2026
02eafc1
fix(clusters): wait for profile feature flag to load
rmnbrd Oct 2, 2026
224c3d0
fix(clusters): expose profile navbar as navigation
rmnbrd Oct 2, 2026
bffea20
fix(clusters): stop loading when template queries are disabled
rmnbrd Oct 2, 2026
5d487a4
fix(clusters): remove gateway API warning indicator
rmnbrd Oct 2, 2026
f2f3aee
test(clusters): verify self-managed cluster selection tracking
rmnbrd Oct 2, 2026
939fb13
fix(clusters): block saving for offscreen invalid components
rmnbrd Oct 2, 2026
6e5f526
test(clusters): cover Operator update action visibility
rmnbrd Oct 2, 2026
8960bf1
fix(clusters): remove disabled profile controls
rmnbrd Oct 2, 2026
827fcb9
fix(clusters): close item modal when profile unmounts
rmnbrd Oct 2, 2026
a7ce69c
fix(clusters): address engine v2 review comments (#3057)
rmnbrd Oct 2, 2026
0c4f236
chore(clusters): engine v2 review improvements (#3060)
rmnbrd Oct 5, 2026
7b35347
fix(clusters): address remaining Cubic and Copilot review feedback on…
rmnbrd Oct 5, 2026
4f11880
fix(clusters): close out the remaining engine v2 review threads (#3064)
rmnbrd Oct 5, 2026
d3ca88d
Merge branch 'staging' into feat/cluster-engine-v2
rmnbrd Oct 12, 2026
15bed3a
fix(clusters): resolve engine v2 build and profile issues
rmnbrd Oct 12, 2026
748855f
fix(clusters): show resolved values for read-only fields
rmnbrd Oct 12, 2026
1d5ddce
fix(clusters): keep disabled sensitive fields masked
rmnbrd Oct 12, 2026
e587248
refactor(clusters): address engine v2 review comments (#3099)
rmnbrd Oct 14, 2026
6a64b55
Merge remote-tracking branch 'origin/staging' into feat/cluster-engin…
rmnbrd Oct 14, 2026
9f0daed
feat(clusters): only show the profile for Engine v2 clusters (#3105)
rmnbrd Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions apps/console/src/routeTree.gen.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Navigate, createFileRoute, useParams } from '@tanstack/react-router'
import { ClusterProfileFeature, resolveProfileSelection, useClusterProfileTree } from '@qovery/domains/clusters/feature'

export const Route = createFileRoute(
'/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey'
)({
component: RouteComponent,
})

function RouteComponent() {
const { organizationId = '', clusterId = '' } = useParams({ strict: false })
const { componentKey } = Route.useParams()
const { search } = Route.useSearch()
const navigate = Route.useNavigate()
const { profileTree, isLoading } = useClusterProfileTree({ organizationId, clusterId })
// Disabled layers and components with nothing to configure cannot be opened: the URL follows what is displayed.
const resolvedComponentKey = resolveProfileSelection(profileTree, componentKey).component?.key

if (!isLoading && resolvedComponentKey && resolvedComponentKey !== componentKey) {
return (
<Navigate
to="/organization/$organizationId/cluster/$clusterId/profile/$componentKey"
params={{ organizationId, clusterId, componentKey: resolvedComponentKey }}
search={(previous) => previous}
replace
/>
)
}

return (
<ClusterProfileFeature
// The route is reused across clusters: remount so unsaved edits never carry over to another cluster.
key={clusterId}
activeComponentKey={componentKey}
search={search}
onActiveComponentChange={(nextComponentKey) =>
navigate({
to: '/organization/$organizationId/cluster/$clusterId/profile/$componentKey',
params: (previous) => ({ ...previous, componentKey: nextComponentKey }),
search: (previous) => previous,
})
}
onSearchChange={(nextSearch) =>
navigate({ search: (previous) => ({ ...previous, search: nextSearch || undefined }), replace: true })
}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Navigate, createFileRoute, useParams } from '@tanstack/react-router'
import { ClusterProfileFeature, resolveProfileSelection, useClusterProfileTree } from '@qovery/domains/clusters/feature'
import { LoaderSpinner } from '@qovery/shared/ui'

export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/$clusterId/profile/')({
component: RouteComponent,
})

function RouteComponent() {
const { organizationId = '', clusterId = '' } = useParams({ strict: false })
const { profileTree, isLoading, isError } = useClusterProfileTree({ organizationId, clusterId })
const componentKey = resolveProfileSelection(profileTree).component?.key

if (isLoading) {
return (
<div className="flex min-h-page-container items-center justify-center">
<LoaderSpinner />
</div>
)
}

// Without a component to open, the feature shows the error or empty state.
if (isError || !componentKey) {
return <ClusterProfileFeature key={clusterId} />
}

return (
<Navigate
to="/organization/$organizationId/cluster/$clusterId/profile/$componentKey"
params={{ organizationId, clusterId, componentKey }}
search={(previous) => previous}
replace
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Navigate, Outlet, createFileRoute, useParams } from '@tanstack/react-router'
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'

export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/$clusterId/profile')({
component: RouteComponent,
validateSearch: z.object({ search: z.string().optional() }),
})

function RouteComponent() {
useDocumentTitle('Cluster - Profile')
const { organizationId = '', clusterId = '' } = useParams({ strict: false })
const { data: cluster, isLoading: isClusterLoading } = useCluster({ organizationId, clusterId })
// Only Engine v2 clusters, attached to the Qovery Operator, have a profile.
const isOperatorAttached = useIsOperatorAttached(cluster)

// 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 (
<div className="flex min-h-page-container items-center justify-center">
<LoaderSpinner />
</div>
)
}

if (!isOperatorAttached && organizationId && clusterId) {
return (
<Navigate
to="/organization/$organizationId/cluster/$clusterId/overview"
params={{ organizationId, clusterId }}
replace
/>
)
}

return <Outlet />
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createFileRoute, useParams } from '@tanstack/react-router'
import { ClusterNew } from '@qovery/domains/clusters/feature'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { ClusterAdd, ClusterNew } from '@qovery/domains/clusters/feature'
import { Heading, Icon, Link, Section } from '@qovery/shared/ui'
import { useDocumentTitle } from '@qovery/shared/util-hooks'

Expand All @@ -9,7 +10,8 @@ export const Route = createFileRoute('/_authenticated/organization/$organization

function RouteComponent() {
const { organizationId = '' } = useParams({ strict: false })
useDocumentTitle('Create new cluster')
const isEngineV2Enabled = Boolean(useFeatureFlagEnabled('engine-v2-platform-configuration'))
useDocumentTitle(isEngineV2Enabled ? 'Add cluster' : 'Create new cluster')

return (
<Section className="flex w-full flex-1 flex-col pb-48 pt-6">
Expand All @@ -20,12 +22,12 @@ function RouteComponent() {
className="mb-2 text-xs"
>
<Icon iconName="arrow-left" className="mr-1" />
Back to clusters
{isEngineV2Enabled ? 'Back to cluster list' : 'Back to clusters'}
</Link>
<div className="flex flex-col border-b border-neutral pb-6">
<Heading className="text-2xl">Install cluster</Heading>
<Heading className="text-2xl">{isEngineV2Enabled ? 'Add cluster' : 'Install cluster'}</Heading>
</div>
<ClusterNew />
{isEngineV2Enabled ? <ClusterAdd /> : <ClusterNew />}
</Section>
)
}
19 changes: 15 additions & 4 deletions apps/console/src/routes/_authenticated/organization/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import posthog from 'posthog-js'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { type BlueprintDetailsResponse, type Cluster } from 'qovery-typescript-axios'
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useClusters } from '@qovery/domains/clusters/feature'
import { useClusters, useIsOperatorAttached } from '@qovery/domains/clusters/feature'
import { useEnvironment } from '@qovery/domains/environments/feature'
import { useProject } from '@qovery/domains/projects/feature'
import {
Expand Down Expand Up @@ -118,6 +118,12 @@ const CLUSTER_TABS: NavigationTab[] = [
iconName: 'terminal',
routeId: '/_authenticated/organization/$organizationId/cluster/$clusterId/cloud-shell',
},
{
id: 'profile',
label: 'Profile',
iconName: 'layer-group',
routeId: '/_authenticated/organization/$organizationId/cluster/$clusterId/profile',
},
{
id: 'settings',
label: 'Settings',
Expand Down Expand Up @@ -393,6 +399,8 @@ function useNavigationContext(): NavigationContext | null {
})
const hasAlerting = clusters.some((cluster) => 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,
Expand Down Expand Up @@ -423,9 +431,11 @@ function useNavigationContext(): NavigationContext | null {
? getServiceTabs(service, currentCluster, isAgenticWorkflowEnabled, blueprint)
: context.type === 'organization'
? context.tabs.filter((tab) => hasAlerting || tab.id !== 'alerts')
: context.type === 'environment'
? context.tabs.filter((tab) => isAgenticWorkflowEnabled || tab.id !== 'automation')
: context.tabs
: context.type === 'cluster'
? context.tabs.filter((tab) => isProfileAvailable || tab.id !== 'profile')
: context.type === 'environment'
? context.tabs.filter((tab) => isAgenticWorkflowEnabled || tab.id !== 'automation')
: context.tabs

return {
type: context.type,
Expand Down Expand Up @@ -546,6 +556,7 @@ const fullWidthRouteIds: FileRouteTypes['id'][] = [
'/_authenticated/organization/$organizationId/cluster/$clusterId/cluster-logs',
'/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId',
'/_authenticated/organization/$organizationId/cluster/$clusterId/cloud-shell',
'/_authenticated/organization/$organizationId/cluster/$clusterId/profile',
'/_authenticated/organization/$organizationId/cluster/$clusterId/settings',
'/_authenticated/organization/$organizationId/project/$projectId/settings',
'/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/settings',
Expand Down
1 change: 1 addition & 0 deletions apps/console/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default defineConfig(({ mode }) => {
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
routeFileIgnorePattern: '\\.(spec|test)\\.tsx?$',
}),
viteStaticCopy({
targets: [
Expand Down
1 change: 1 addition & 0 deletions libs/domains/clusters/data-access/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './lib/domains-clusters-data-access'
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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,22 @@ import {
type ClusterDeleteMode,
ClusterDeploymentHistoryApi,
type ClusterDnsProviderRequest,
ClusterOperatorApi,
type ClusterOperatorUpdateRequest,
type ClusterRequest,
type ClusterRoutingTableRequest,
ClustersApi,
KubernetesEnum,
OrganizationMainCallsApi,
SecretManagerAccessApi,
type SelfManagedClusterRequest,
} from 'qovery-typescript-axios'
import { type ClusterMetricsDto, type ClusterStatusDto } from 'qovery-ws-typescript-axios'
import { isHttpStatus } from '@qovery/shared/utils'

const clusterApi = new ClustersApi()
const clusterDeploymentHistoryApi = new ClusterDeploymentHistoryApi()
const clusterOperatorApi = new ClusterOperatorApi()
const argoCdApi = new ArgoCDApi()
const secretManagerApi = new SecretManagerAccessApi()
const organizationApi = new OrganizationMainCallsApi()
Expand Down Expand Up @@ -50,6 +55,26 @@ export const clusters = createQueryKeys('clusters', {
return response.data
},
}),
operatorStatus: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({
queryKey: [organizationId, clusterId],
async queryFn() {
try {
const response = await clusterOperatorApi.getClusterOperatorStatus(organizationId, clusterId)
return response.data
} catch (error) {
// No Operator state: the cluster is not enrolled in the Engine v2 Operator path.
if (isHttpStatus(error, 404)) return null
throw error
}
},
}),
operatorBootstrap: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({
queryKey: [organizationId, clusterId],
async queryFn() {
const response = await clusterOperatorApi.getClusterOperatorBootstrap(organizationId, clusterId)
return response.data
},
}),
eksAnywhereCommits: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({
queryKey: [organizationId, clusterId],
async queryFn() {
Expand Down Expand Up @@ -203,6 +228,33 @@ export const clusters = createQueryKeys('clusters', {
})

export const mutations = {
async updateClusterOperator({
organizationId,
clusterId,
chartVersion,
imageVersion,
}: {
organizationId: string
clusterId: string
chartVersion: ClusterOperatorUpdateRequest['chart_version']
imageVersion?: ClusterOperatorUpdateRequest['image_version']
}) {
const response = await clusterOperatorApi.updateClusterOperator(organizationId, clusterId, {
chart_version: chartVersion,
image_version: imageVersion,
})
return response.data
},
async createSelfManagedCluster({
organizationId,
clusterRequest,
}: {
organizationId: string
clusterRequest: SelfManagedClusterRequest
}) {
const response = await clusterApi.createSelfManagedCluster(organizationId, clusterRequest)
return response.data
},
async createCluster({ organizationId, clusterRequest }: { organizationId: string; clusterRequest: ClusterRequest }) {
const response = await clusterApi.createCluster(organizationId, clusterRequest)
return response.data
Expand Down
Loading
Loading