Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1da98ec
fix(clusters): keep profile edits made while saving
rmnbrd Oct 2, 2026
79eccbc
fix(clusters): redirect only once when the Operator connects
rmnbrd Oct 2, 2026
3cca8df
fix(clusters): track the item modal however it is dismissed
rmnbrd Oct 2, 2026
0f8101b
perf(clusters): only debounce profile edits, not component switches
rmnbrd Oct 2, 2026
f9a3eed
fix(clusters): use the brand focus outline on profile sidebar buttons
rmnbrd Oct 2, 2026
774eb98
fix(clusters): show OVH, Oracle and IBM icons in cluster avatars
rmnbrd Oct 2, 2026
2c80cc3
refactor(clusters): use ClusterAvatar in the profile header
rmnbrd Oct 2, 2026
7c98a96
fix(clusters): align Operator and profile search copy
rmnbrd Oct 2, 2026
49f03ec
refactor(clusters): use the layer-group icon in the profile sidebar
rmnbrd Oct 2, 2026
f27a8c0
refactor(clusters): keep the HTTP status helper private to data-access
rmnbrd Oct 2, 2026
9dc6724
test(ui): drive InputSearch with userEvent
rmnbrd Oct 2, 2026
f9d6c6c
refactor(clusters): read route ids in the Operator status components
rmnbrd Oct 2, 2026
4ecf7a6
test(clusters): cover the platform configuration helpers
rmnbrd Oct 2, 2026
8c417de
test(clusters): drop an unused testing import
rmnbrd Oct 2, 2026
e8a571c
UI tweak
rmnbrd Oct 2, 2026
f5a577c
perf(clusters): debounce profile edits instead of preview requests
rmnbrd Oct 2, 2026
b9ca9ee
fix(clusters): only mention the search when the sidebar has one
rmnbrd Oct 2, 2026
1b54b61
fix(clusters): report the item modal mount state explicitly
rmnbrd Oct 2, 2026
4734090
chore(clusters): remove profile API response logging
rmnbrd Oct 2, 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
10 changes: 0 additions & 10 deletions apps/console/public/assets/sidebar-test/layer.svg

This file was deleted.

1 change: 0 additions & 1 deletion libs/domains/clusters/data-access/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export * from './lib/domains-clusters-data-access'
export * from './lib/http/is-http-status'
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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export function ClusterAdd() {

<ProviderSection
title="Connect existing cluster (BYOK)"
description="Install the Qovery operator on a cluster you already run"
description="Install the Qovery Operator on a cluster you already run"
>
<ProviderCard
title="Local machine (Demo)"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { renderWithProviders } from '@qovery/shared/util-tests'
import { ClusterAvatar } from './cluster-avatar'

jest.mock('@qovery/shared/ui', () => {
const React = jest.requireActual('react')
const ui = jest.requireActual('@qovery/shared/ui')
return {
...ui,
Icon: (props: { name?: string; iconName?: string }) =>
React.createElement('span', { 'data-icon-name': props.name ?? props.iconName }),
}
})

describe('ClusterAvatar', () => {
it.each([
['AWS', 'AWS'],
['GCP', 'GCP'],
['ON_PREMISE', 'KUBERNETES'],
['OVH', 'OVH_CLOUD'],
['ORACLE', 'ORACLE_CLOUD'],
['IBM', 'IBM_CLOUD'],
] as const)('shows the %s provider icon', (cloudProvider, iconName) => {
const { container } = renderWithProviders(<ClusterAvatar cloudProvider={cloudProvider} />)

expect(container.querySelector(`[data-icon-name="${iconName}"]`)).toBeInTheDocument()
})

it('shows a demo icon for demo clusters', () => {
const { container } = renderWithProviders(
<ClusterAvatar cluster={{ id: 'cluster-id', is_demo: true, cloud_provider: 'ON_PREMISE' } as never} />
)

expect(container.querySelector('[data-icon-name="laptop-code"]')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { type CloudProviderEnum, type Cluster, type ClusterOverviewResponse } from 'qovery-typescript-axios'
import { type CloudVendorEnum, type Cluster, type ClusterOverviewResponse } from 'qovery-typescript-axios'
import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react'
import { match } from 'ts-pattern'
import { IconEnum } from '@qovery/shared/enums'
import { Avatar, Icon } from '@qovery/shared/ui'

export interface ClusterAvatarProps extends Omit<ComponentPropsWithoutRef<typeof Avatar>, 'fallback'> {
cloudProvider?: CloudProviderEnum
cloudProvider?: CloudVendorEnum
cluster?: Cluster | ClusterOverviewResponse
}

Expand All @@ -18,6 +19,9 @@ export const ClusterAvatar = forwardRef<ElementRef<typeof Avatar>, ClusterAvatar
<Icon iconName="laptop-code" className="text-base text-neutral-subtle" />
))
.with({ localCloudProvider: 'ON_PREMISE' }, () => <Icon name="KUBERNETES" height="65%" width="65%" />)
.with({ localCloudProvider: 'OVH' }, () => <Icon name={IconEnum.OVH_CLOUD} height="65%" width="65%" />)
.with({ localCloudProvider: 'ORACLE' }, () => <Icon name={IconEnum.ORACLE_CLOUD} height="65%" width="65%" />)
.with({ localCloudProvider: 'IBM' }, () => <Icon name={IconEnum.IBM_CLOUD} height="65%" width="65%" />)
.otherwise(() => <Icon name={localCloudProvider} height="65%" width="65%" />)

return <Avatar ref={ref} fallback={fallback} {...props} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { useUpdateClusterOperator } from '../hooks/use-update-cluster-operator/use-update-cluster-operator'
import { ClusterOperatorStatus } from './cluster-operator-status'

jest.mock('@tanstack/react-router', () => ({
...jest.requireActual('@tanstack/react-router'),
useParams: () => ({ organizationId: 'organization-id', clusterId: 'cluster-id' }),
}))
jest.mock('../hooks/use-update-cluster-operator/use-update-cluster-operator')

const mockUseUpdateClusterOperator = useUpdateClusterOperator as jest.Mock
Expand All @@ -22,9 +26,7 @@ describe('ClusterOperatorStatus', () => {
})

it('updates the Operator to the target versions', async () => {
const { userEvent } = renderWithProviders(
<ClusterOperatorStatus organizationId="organization-id" clusterId="cluster-id" operatorStatus={operatorStatus} />
)
const { userEvent } = renderWithProviders(<ClusterOperatorStatus operatorStatus={operatorStatus} />)

await userEvent.click(screen.getByRole('button', { name: 'Update Operator' }))

Expand All @@ -38,9 +40,7 @@ describe('ClusterOperatorStatus', () => {

it('does not start another update while one is in progress', async () => {
mockUseUpdateClusterOperator.mockReturnValue({ mutate: mockUpdateOperator, isLoading: true })
const { userEvent } = renderWithProviders(
<ClusterOperatorStatus organizationId="organization-id" clusterId="cluster-id" operatorStatus={operatorStatus} />
)
const { userEvent } = renderWithProviders(<ClusterOperatorStatus operatorStatus={operatorStatus} />)

const button = screen.getByRole('button', { name: 'Update Operator' })
expect(button).toBeDisabled()
Expand All @@ -51,25 +51,13 @@ describe('ClusterOperatorStatus', () => {
})

it('hides the update action while the Operator is not connected', () => {
renderWithProviders(
<ClusterOperatorStatus
organizationId="organization-id"
clusterId="cluster-id"
operatorStatus={{ ...operatorStatus, operator_connected: false }}
/>
)
renderWithProviders(<ClusterOperatorStatus operatorStatus={{ ...operatorStatus, operator_connected: false }} />)

expect(screen.queryByRole('button', { name: 'Update Operator' })).not.toBeInTheDocument()
})

it('disables the update action without a target chart version', () => {
renderWithProviders(
<ClusterOperatorStatus
organizationId="organization-id"
clusterId="cluster-id"
operatorStatus={{ ...operatorStatus, desired_chart_version: null }}
/>
)
renderWithProviders(<ClusterOperatorStatus operatorStatus={{ ...operatorStatus, desired_chart_version: null }} />)

expect(screen.getByRole('button', { name: 'Update Operator' })).toBeDisabled()
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type IconName } from '@fortawesome/fontawesome-common-types'
import { useParams } from '@tanstack/react-router'
import { type ClusterOperatorFleetStatus, type ClusterOperatorStatusResponse } from 'qovery-typescript-axios'
import { match } from 'ts-pattern'
import { Badge, Button, StatusChip } from '@qovery/shared/ui'
Expand Down Expand Up @@ -76,12 +77,11 @@ function Version({ installed, target }: { installed?: string | null; target?: st
}

export interface ClusterOperatorStatusProps {
organizationId: string
clusterId: string
operatorStatus: ClusterOperatorStatusResponse
}

export function ClusterOperatorStatus({ organizationId, clusterId, operatorStatus }: ClusterOperatorStatusProps) {
export function ClusterOperatorStatus({ operatorStatus }: ClusterOperatorStatusProps) {
const { organizationId = '', clusterId = '' } = useParams({ strict: false })
const { mutate: updateOperator, isLoading: isUpdating } = useUpdateClusterOperator()
const display = getClusterOperatorStatusDisplay(operatorStatus.status)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ function countProfileChanges(
return profileChanges.length + clusterInputChanges.length
}

function omitSubmittedValues<T extends Record<string, Record<string, unknown>>>(currentValues: T, submittedValues: T) {
return Object.fromEntries(
Object.entries(currentValues).flatMap(([componentKey, values]) => {
const remainingValues = Object.fromEntries(
Object.entries(values).filter(([key, value]) => !equal(value, submittedValues[componentKey]?.[key]))
)
return Object.keys(remainingValues).length ? [[componentKey, remainingValues]] : []
})
) as T
}

// PUT replaces the whole configuration: unedited components and inputs are sent back as saved.
function getConfigurationRequest(
template: PlatformTemplateSummaryResponse,
Expand Down Expand Up @@ -148,9 +159,9 @@ export function ClusterProfileProvider({ children }: PropsWithChildren) {
clusterId,
configurationRequest: getConfigurationRequest(selectedTemplate, configuration, profileValues, clusterInputs),
})
// The saved configuration now carries the edits, so the local copies can go.
setProfileValues({})
setClusterInputs({})
// The saved configuration now carries the submitted edits, so their local copies can go.
setProfileValues((currentValues) => omitSubmittedValues(currentValues, profileValues))
setClusterInputs((currentValues) => omitSubmittedValues(currentValues, clusterInputs))
}

const saveChanges = async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,12 @@ interface LayerSectionRowProps {
onSelectItem: (itemId: string) => void
}

function LayerIcon({ className = '' }: { className?: string }) {
return (
<span
aria-hidden="true"
className={`size-3.5 shrink-0 bg-current [mask-image:url('/assets/sidebar-test/layer.svg')] [mask-position:center] [mask-repeat:no-repeat] [mask-size:contain] ${className}`}
/>
)
function LayerIcon() {
return <Icon iconName="layer-group" iconStyle="regular" aria-hidden="true" className="shrink-0 text-sm" />
Comment thread
rmnbrd marked this conversation as resolved.
}

export function ClusterProfileItemIcon() {
return <Icon name={IconEnum.HELM_OFFICIAL} width="14" height="14" className="shrink-0" aria-hidden="true" />
return <Icon name={IconEnum.HELM_OFFICIAL} width="15" height="15" className="shrink-0" aria-hidden="true" />
}

function LayerSectionRow({
Expand All @@ -62,7 +57,7 @@ function LayerSectionRow({
type="button"
disabled={isDisabled}
aria-current={isSelected && !selectedItemId ? 'page' : undefined}
className={`focus-visible:ring-brand flex h-8 w-full items-center gap-1.5 rounded px-3 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-inset disabled:cursor-not-allowed disabled:text-neutral-disabled ${
className={`flex h-8 w-full items-center gap-1.5 rounded px-3 text-left text-sm outline-none focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-strong disabled:cursor-not-allowed disabled:text-neutral-disabled ${
isDisabled
? ''
: `${isSelected ? 'text-neutral' : 'text-neutral-subtle'} hover:bg-surface-neutral-component hover:text-neutral`
Expand Down Expand Up @@ -93,7 +88,7 @@ function LayerSectionRow({
type="button"
disabled={isItemDisabled}
aria-current={isItemSelected ? 'page' : undefined}
className={`focus-visible:ring-brand group flex h-7 w-full items-center gap-1.5 px-3 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-inset disabled:cursor-not-allowed disabled:text-neutral-disabled ${
className={`group flex h-7 w-full items-center gap-1.5 px-3 text-left text-sm outline-none focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-strong disabled:cursor-not-allowed disabled:text-neutral-disabled ${
isItemDisabled
? ''
: isItemSelected
Expand Down Expand Up @@ -179,11 +174,15 @@ export function ClusterProfileSidebar({
<h2 className="flex h-8 shrink-0 items-center px-3 text-sm font-medium">Layers</h2>
<nav aria-label="Layers" className="min-h-0 flex-1 overflow-y-auto">
<ul>
{isLoading ? <li className="px-3 py-1 text-xs text-neutral-subtle">Loading layers...</li> : null}
{isError ? <li className="px-3 py-1 text-xs text-negative">Unable to load layers.</li> : null}
{isLoading ? <li className="px-3 py-1 text-xs text-neutral-subtle">Loading layers…</li> : null}
{isError ? (
<li className="px-3 py-1 text-xs text-negative">
Layers could not be loaded. Refresh the page to try again.
</li>
) : null}
{showEmptyState ? (
<li className="px-3 py-1 text-ssm text-neutral-subtle">
No results found. Review your search or applied filters.
{search.trim() ? 'No layers match your search' : 'No layers available'}
</li>
) : null}
{!isLoading && !isError
Expand Down
Loading
Loading