Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
016526a
fix(clusters): keep read-only fields disabled in the array item modal
rmnbrd Oct 13, 2026
6c376a6
feat(clusters): warn before leaving the profile with unsaved changes
rmnbrd Oct 13, 2026
82e2a23
refactor(shared): move isHttpStatus next to the Axios interceptor
rmnbrd Oct 13, 2026
f29d2fc
refactor(clusters): rename useIsEngineV2Cluster to useIsOperatorManag…
rmnbrd Oct 13, 2026
05436ea
refactor(clusters): default platform configuration hook keys to empty…
rmnbrd Oct 13, 2026
0a26c7b
refactor(clusters): move platform configuration hooks next to the oth…
rmnbrd Oct 13, 2026
bf23da1
refactor(clusters): move platform configuration utils next to the oth…
rmnbrd Oct 13, 2026
53b636c
refactor(clusters): use the shared Heading and Section in the self-ma…
rmnbrd Oct 13, 2026
b996d15
refactor(clusters): type the creation flow layouts with PropsWithChil…
rmnbrd Oct 13, 2026
35bb222
refactor(clusters): show unmapped Operator violations in a Callout
rmnbrd Oct 13, 2026
4fac647
chore(clusters): export the profile tree like the other modules
rmnbrd Oct 13, 2026
f7f8a11
refactor(catalog): move the catalog variable helpers and input into t…
rmnbrd Oct 13, 2026
1f167d0
refactor(ui): use cva for the EmptyState size and variant classes
rmnbrd Oct 13, 2026
6ce247a
docs(ui): add HighlightText stories
rmnbrd Oct 13, 2026
426eb67
refactor(ui): drop the unused Tooltip arrow class and collision paddi…
rmnbrd Oct 13, 2026
8e747d3
refactor(ui): control the InputSearch value instead of syncing its de…
rmnbrd Oct 13, 2026
4878ae3
refactor(clusters): rename useIsOperatorManagedCluster to useIsOperat…
rmnbrd Oct 13, 2026
a76f1cd
refactor(ui): keep the InputSelectSmall accessible name opt-in
rmnbrd Oct 13, 2026
ebb0363
refactor(clusters): simplify the cluster profile layout
rmnbrd Oct 13, 2026
96e89fa
fix(clusters): show empty state when no settings match search
rmnbrd Oct 13, 2026
8475e97
refactor(clusters): clarify layer is not configurable yet
rmnbrd Oct 13, 2026
f513e48
refactor(clusters): clarify when the Operator status row is clickable
rmnbrd Oct 13, 2026
1972e84
test(clusters): clarify Operator status row clickability coverage
rmnbrd Oct 13, 2026
1362fcf
fix(clusters): keep profile saving available during checks
rmnbrd Oct 13, 2026
1de6f66
fix(clusters): keep the refresh error visible when no setting matches…
rmnbrd Oct 13, 2026
1d9acf9
docs(clusters): describe that the last save verdict holds during rech…
rmnbrd Oct 14, 2026
2e8c136
refactor(schema-fields): rename the catalog lib to a shared schema fi…
rmnbrd Oct 14, 2026
39a7ad6
test(ui): render InputSearch specs with the shared test utils
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
type SelfManagedClusterRequest,
} from 'qovery-typescript-axios'
import { type ClusterMetricsDto, type ClusterStatusDto } from 'qovery-ws-typescript-axios'
import { isHttpStatus } from './http/is-http-status'
import { isHttpStatus } from '@qovery/shared/utils'

const clusterApi = new ClustersApi()
const clusterDeploymentHistoryApi = new ClusterDeploymentHistoryApi()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type PlatformComponentConfigurationPreviewRequest,
PlatformConfigurationApi,
} from 'qovery-typescript-axios'
import { isHttpStatus } from '../http/is-http-status'
import { isHttpStatus } from '@qovery/shared/utils'

const platformConfigurationApi = new PlatformConfigurationApi()

Expand Down
12 changes: 6 additions & 6 deletions libs/domains/clusters/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export * from './lib/cluster-avatar/cluster-avatar'
export * from './lib/cluster-advanced-settings/cluster-advanced-settings'
export * from './lib/cluster-advanced-settings/cluster-advanced-settings-feature'
export * from './lib/cluster-profile/cluster-profile'
export { resolveProfileSelection } from './lib/cluster-profile/profile-tree'
export * from './lib/cluster-profile/profile-tree'
export * from './lib/cluster-profile/use-cluster-profile-tree'
export * from './lib/cluster-advanced-settings/init-form-values'
export * from './lib/cluster-setup/cluster-setup'
Expand Down Expand Up @@ -63,9 +63,9 @@ export * from './lib/hooks/use-services-cluster/use-services-cluster'
export * from './lib/hooks/use-cluster-statuses/use-cluster-statuses'
export * from './lib/hooks/use-cluster/use-cluster'
export * from './lib/hooks/use-platform-templates/use-platform-templates'
export * from './lib/platform-configuration/hooks/use-platform-configuration'
export * from './lib/platform-configuration/hooks/use-platform-component-configuration'
export * from './lib/platform-configuration/hooks/use-platform-component-configurations'
export * from './lib/hooks/use-platform-configuration/use-platform-configuration'
export * from './lib/hooks/use-platform-component-configuration/use-platform-component-configuration'
export * from './lib/hooks/use-platform-component-configurations/use-platform-component-configurations'
export * from './lib/hooks/use-clusters/use-clusters'
export * from './lib/hooks/use-argocd-credentials/use-argocd-credentials'
export * from './lib/hooks/use-cluster-advanced-settings/use-cluster-advanced-settings'
Expand Down Expand Up @@ -101,7 +101,7 @@ export * from './lib/cluster-resources-settings/cluster-resources-settings'
export * from './lib/cluster-add/cluster-add'
export * from './lib/hooks/use-create-self-managed-cluster/use-create-self-managed-cluster'
export * from './lib/hooks/use-cluster-operator-status/use-cluster-operator-status'
export * from './lib/platform-configuration/hooks/use-update-platform-configuration'
export * from './lib/hooks/use-update-platform-configuration/use-update-platform-configuration'
export * from './lib/hooks/use-cluster-operator-bootstrap/use-cluster-operator-bootstrap'
export * from './lib/hooks/use-is-engine-v2-cluster/use-is-engine-v2-cluster'
export * from './lib/hooks/use-is-operator-attached/use-is-operator-attached'
export * from './lib/hooks/use-update-cluster-operator/use-update-cluster-operator'
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import {
import type { ReactNode } from 'react'
import { clusterFactoryMock } from '@qovery/shared/factories'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { useIsEngineV2Cluster } from '../hooks/use-is-engine-v2-cluster/use-is-engine-v2-cluster'
import { useIsOperatorAttached } from '../hooks/use-is-operator-attached/use-is-operator-attached'
import { ClusterActions } from './cluster-actions'

jest.mock('../hooks/use-is-engine-v2-cluster/use-is-engine-v2-cluster')
const mockUseIsEngineV2Cluster = useIsEngineV2Cluster as jest.Mock
jest.mock('../hooks/use-is-operator-attached/use-is-operator-attached')
const mockUseIsOperatorAttached = useIsOperatorAttached as jest.Mock

const mockCluster = clusterFactoryMock(1)[0]
const mockOpenModal = jest.fn()
Expand Down Expand Up @@ -58,7 +58,7 @@ jest.mock('@qovery/shared/util-hooks', () => ({
describe('ClusterActions', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseIsEngineV2Cluster.mockReturnValue(false)
mockUseIsOperatorAttached.mockReturnValue(false)
mockCluster.deployment_status = ClusterDeploymentStatusEnum.UP_TO_DATE
mockClusterStatus = {
cluster_id: mockCluster.id,
Expand Down Expand Up @@ -316,14 +316,14 @@ describe('ClusterActions', () => {
expect(screen.queryByRole('button', { name: 'Logs' })).not.toBeInTheDocument()
})

describe('Engine v2 clusters', () => {
describe('clusters with an attached Operator', () => {
const selfManagedCluster = {
...mockCluster,
kubernetes: KubernetesEnum.SELF_MANAGED,
cloud_provider: 'AWS' as const,
}

it('keeps the installation guide for self-managed clusters outside Engine v2', () => {
it('keeps the installation guide for self-managed clusters without an attached Operator', () => {
const { container } = renderWithProviders(
<ClusterActions cluster={selfManagedCluster} clusterStatus={mockClusterStatus} />
)
Expand All @@ -333,8 +333,8 @@ describe('ClusterActions', () => {
expect(screen.queryByLabelText(/manage deployment/i)).not.toBeInTheDocument()
})

it('offers to deploy an Engine v2 cluster, without stopping it', async () => {
mockUseIsEngineV2Cluster.mockReturnValue(true)
it('offers to deploy a cluster with an attached Operator, without stopping it', async () => {
mockUseIsOperatorAttached.mockReturnValue(true)
const { userEvent } = renderWithProviders(
<ClusterActions
cluster={selfManagedCluster}
Expand All @@ -350,8 +350,8 @@ describe('ClusterActions', () => {
expect(screen.queryByRole('menuitem', { name: /stop/i })).not.toBeInTheDocument()
})

it('offers to redeploy a deployed Engine v2 cluster, without upgrading Kubernetes', async () => {
mockUseIsEngineV2Cluster.mockReturnValue(true)
it('offers to redeploy a deployed cluster with an attached Operator, without upgrading Kubernetes', async () => {
mockUseIsOperatorAttached.mockReturnValue(true)
const { userEvent } = renderWithProviders(
<ClusterActions
cluster={selfManagedCluster}
Expand All @@ -367,8 +367,8 @@ describe('ClusterActions', () => {
expect(screen.queryByRole('menuitem', { name: /stop/i })).not.toBeInTheDocument()
})

it('shows the deployments card action for Engine v2 clusters', () => {
mockUseIsEngineV2Cluster.mockReturnValue(true)
it('shows the deployments card action for clusters with an attached Operator', () => {
mockUseIsOperatorAttached.mockReturnValue(true)
renderWithProviders(
<ClusterActions cluster={selfManagedCluster} clusterStatus={mockClusterStatus} variant="card" />
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { useClusterRunningStatus } from '../hooks/use-cluster-running-status/use
import { useDeployCluster } from '../hooks/use-deploy-cluster/use-deploy-cluster'
import { useDownloadKubeconfig } from '../hooks/use-download-kubeconfig/use-download-kubeconfig'
import { useEksAnywhereClusterJwt } from '../hooks/use-eks-anywhere-cluster-jwt/use-eks-anywhere-cluster-jwt'
import { useIsEngineV2Cluster } from '../hooks/use-is-engine-v2-cluster/use-is-engine-v2-cluster'
import { useIsOperatorAttached } from '../hooks/use-is-operator-attached/use-is-operator-attached'
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'
Expand All @@ -37,13 +37,13 @@ function MenuManageDeployment({
cluster,
clusterStatus,
variant = 'default',
isEngineV2Cluster = false,
isOperatorAttached = false,
}: {
cluster: Cluster
clusterStatus: ClusterStatus
variant?: ActionToolbarVariant
// The customer runs an Engine v2 cluster: Qovery only deploys its platform profile, it cannot stop or upgrade it.
isEngineV2Cluster?: boolean
// The cluster is attached to the Qovery Operator: Qovery only deploys its platform profile, it cannot stop or upgrade it.
isOperatorAttached?: boolean
}) {
const { openModalConfirmation } = useModalConfirmation()
const { openModal, closeModal } = useModal()
Expand All @@ -68,7 +68,7 @@ function MenuManageDeployment({
clusterStatus.next_k8s_available_version !== null &&
clusterStatus.status === 'DEPLOYED' &&
cluster.kubernetes !== 'PARTIALLY_MANAGED' &&
!isEngineV2Cluster
!isOperatorAttached
const clusterNeedUpdate = cluster.deployment_status !== 'UP_TO_DATE' && clusterStatus.status !== 'STOPPED'
const isEksAnywhereCluster = cluster.kubernetes === 'PARTIALLY_MANAGED'
const hasEksAnywhereGitRepository = Boolean(
Expand Down Expand Up @@ -207,7 +207,7 @@ function MenuManageDeployment({
{tooltipClusterNeedUpdate}
</DropdownMenu.Item>
),
!isEngineV2Cluster &&
!isOperatorAttached &&
cluster.cloud_provider !== 'GCP' &&
cluster.cloud_provider !== 'AZURE' &&
isStopAvailable(clusterStatus.status) &&
Expand Down Expand Up @@ -428,7 +428,7 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }:
organizationId: cluster.organization.id,
clusterId: cluster.id,
})
const isEngineV2Cluster = useIsEngineV2Cluster(cluster)
const isOperatorAttached = useIsOperatorAttached(cluster)

const searchParams = useMemo(() => {
// @ts-expect-error TODO needs to be fixed
Expand Down Expand Up @@ -479,8 +479,10 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }:

const primaryActionButton = match(cluster)
.when(
() => isEngineV2Cluster,
() => <MenuManageDeployment cluster={cluster} clusterStatus={clusterStatus} variant={variant} isEngineV2Cluster />
() => isOperatorAttached,
() => (
<MenuManageDeployment cluster={cluster} clusterStatus={clusterStatus} variant={variant} isOperatorAttached />
)
)
.with({ cloud_provider: P.not('ON_PREMISE'), kubernetes: 'SELF_MANAGED' }, () => (
<Tooltip content="Installation guide">
Expand All @@ -504,7 +506,7 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }:
))
.otherwise(() => <MenuManageDeployment cluster={cluster} clusterStatus={clusterStatus} variant={variant} />)
const logsButton =
variant === 'card' && (cluster.kubernetes !== 'SELF_MANAGED' || isEngineV2Cluster) ? (
variant === 'card' && (cluster.kubernetes !== 'SELF_MANAGED' || isOperatorAttached) ? (
<Tooltip content="Deployments">
<Button
aria-label="Deployments"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,35 @@
import { useParams } from '@tanstack/react-router'
import { useBlocker, useParams } from '@tanstack/react-router'
import equal from 'fast-deep-equal'
import {
type Cluster,
type ClusterPlatformConfigurationRequest,
type ClusterPlatformConfigurationResponse,
type PlatformTemplateSummaryResponse,
} from 'qovery-typescript-axios'
import { type PropsWithChildren, createContext, useContext, useState } from 'react'
import { type SchemaFieldValue, getSchemaFieldValue } from '@qovery/shared/schema-fields/feature'
import { toast } from '@qovery/shared/ui'
import { type CatalogVariableValue, getCatalogVariableValue } from '@qovery/shared/util-js'
import { useDeployCluster } from '../hooks/use-deploy-cluster/use-deploy-cluster'
import { useUpdatePlatformConfiguration } from '../platform-configuration/hooks/use-update-platform-configuration'
import { useUpdatePlatformConfiguration } from '../hooks/use-update-platform-configuration/use-update-platform-configuration'
import {
type PlatformFieldDescriptor,
type PlatformScalarField,
applyPlatformConfigurationDefaults,
isPlatformScalarField,
omitEmptyValues,
updateComponentValue,
} from '../platform-configuration/platform-configuration-utils'
} from '../utils/platform-configuration-utils'
import { type ProfileTreeItem } from './profile-tree'
import { useClusterProfileTree } from './use-cluster-profile-tree'

const PROFILE_ROUTE_ID = '/_authenticated/organization/$organizationId/cluster/$clusterId/profile'
const LEAVE_UNSAVED_CHANGES_MESSAGE = 'You have unsaved changes on this profile. Leave the page and discard them?'

export type ProfileValues = Record<string, Record<string, unknown>>
export type ClusterInputValues = Record<string, Record<string, string>>

// Scalars are compared as the inputs display them: an unset toggle shows as off, an unset text as empty.
function getDisplayedScalarValue(field: Pick<PlatformFieldDescriptor, 'type' | 'defaultValue'>, value: unknown) {
return getCatalogVariableValue(field, value) ?? (field.type === 'bool' ? false : '')
return getSchemaFieldValue(field, value) ?? (field.type === 'bool' ? false : '')
}

// Local edits differing from the saved (or default) value; edits reverted by hand do not count.
Expand Down Expand Up @@ -124,7 +126,6 @@ function getConfigurationRequest(
interface ClusterProfileContextValue {
organizationId: string
clusterId: string
cluster?: Cluster
templates?: PlatformTemplateSummaryResponse[]
configuration?: ClusterPlatformConfigurationResponse | null
profileTree: ProfileTreeItem[]
Expand All @@ -139,7 +140,7 @@ interface ClusterProfileContextValue {
isSaving: boolean
isDeploying: boolean
updateProfileConfig: (componentKey: string, fieldKey: string, value: unknown) => void
updateClusterInput: (componentKey: string, field: PlatformFieldDescriptor, value: CatalogVariableValue) => void
updateClusterInput: (componentKey: string, field: PlatformFieldDescriptor, value: SchemaFieldValue) => void
resetChanges: () => void
saveChanges: () => Promise<void>
saveAndDeployChanges: () => Promise<void>
Expand All @@ -156,14 +157,16 @@ export function ClusterProfileProvider({ children }: PropsWithChildren) {
const { mutateAsync: updatePlatformConfiguration, isLoading: isSavingConfiguration } =
useUpdatePlatformConfiguration()
const { mutateAsync: deployCluster } = useDeployCluster()
const { cluster, templates, configuration, selectedTemplate, profileTree, isLoading, isError } =
useClusterProfileTree({ organizationId, clusterId })
const { templates, configuration, selectedTemplate, profileTree, isLoading, isError } = useClusterProfileTree({
organizationId,
clusterId,
})

const updateProfileConfig = (componentKey: string, fieldKey: string, value: unknown) => {
setProfileValues((currentValues) => updateComponentValue(currentValues, componentKey, fieldKey, value))
}

const updateClusterInput = (componentKey: string, field: PlatformFieldDescriptor, value: CatalogVariableValue) => {
const updateClusterInput = (componentKey: string, field: PlatformFieldDescriptor, value: SchemaFieldValue) => {
// The input shows its default while unsaved: setting it back to what is displayed is no edit.
const nextValue = String(value)
const isDisplayedValue =
Expand All @@ -174,6 +177,21 @@ export function ClusterProfileProvider({ children }: PropsWithChildren) {
)
}

const changeCount = countProfileChanges(profileTree, configuration, profileValues, clusterInputs)
const hasChanges = changeCount > 0

useBlocker({
disabled: !hasChanges,
// Edits are kept while switching components of this profile: only leaving it would drop them.
shouldBlockFn: ({ next }) =>
!(
next.routeId.startsWith(PROFILE_ROUTE_ID) &&
'clusterId' in next.params &&
next.params.clusterId === clusterId
) && !window.confirm(LEAVE_UNSAVED_CHANGES_MESSAGE),
enableBeforeUnload: hasChanges,
})

const resetChanges = () => {
setProfileValues({})
setClusterInputs({})
Expand Down Expand Up @@ -221,7 +239,6 @@ export function ClusterProfileProvider({ children }: PropsWithChildren) {
const value: ClusterProfileContextValue = {
organizationId,
clusterId,
cluster,
templates,
configuration,
profileTree,
Expand All @@ -230,7 +247,7 @@ export function ClusterProfileProvider({ children }: PropsWithChildren) {
profileValues,
clusterInputs,
formKey,
changeCount: countProfileChanges(profileTree, configuration, profileValues, clusterInputs),
changeCount,
isSaving: isSavingConfiguration && !isSaveAndDeployPending,
isDeploying: isSaveAndDeployPending,
updateProfileConfig,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { type ReactNode } from 'react'
import { type ReactNode, useState } from 'react'
import { IconEnum } from '@qovery/shared/enums'
import { Icon, InputSearch, Tooltip } from '@qovery/shared/ui'

type LayerStatus = 'disabled' | 'success'

export const NOT_CONFIGURABLE_COMPONENT_REASON = 'No configuration needed for this component'
export const NOT_CONFIGURABLE_LAYER_REASON = 'No configuration needed for this layer'
export const NOT_CONFIGURABLE_LAYER_REASON = 'No configuration needed for this layer yet'

export interface ClusterProfileSidebarItem {
id: string
Expand Down Expand Up @@ -157,16 +157,29 @@ export function ClusterProfileSidebar({
footer,
}: ClusterProfileSidebarProps) {
const showEmptyState = !isLoading && !isError && layers.length === 0
// The URL search lags behind typing, so the input keeps its own value and follows the URL when navigation changes it.
const [searchInput, setSearchInput] = useState(search)
const [previousSearch, setPreviousSearch] = useState(search)
if (search !== previousSearch) {
Comment thread
rmnbrd marked this conversation as resolved.
setPreviousSearch(search)
setSearchInput(search)
}

return (
<aside aria-label="Cluster layers" className="flex w-56 shrink-0 flex-col bg-background-secondary lg:w-[272px]">
<aside
aria-label="Cluster layers"
className="flex w-56 shrink-0 flex-col border-r border-neutral bg-background-secondary lg:w-[272px]"
>
<div className="p-3">
<InputSearch
ariaLabel="Search layers"
placeholder="Search…"
customSize="h-7 text-ssm"
defaultValue={search}
onChange={onSearchChange}
value={searchInput}
onChange={(nextSearch) => {
setSearchInput(nextSearch)
onSearchChange(nextSearch)
}}
/>
</div>

Expand Down
Loading
Loading