Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -54,6 +54,18 @@ describe('ClusterAdd', () => {
await userEvent.click(screen.getByRole('button', { name: /Amazon Web Services EKS Anywhere\s*Request access/ }))

expect(mockShowPylonForm).toHaveBeenCalledWith('request-access-eks-anywhere')
expect(posthog.capture).toHaveBeenCalledWith('select-cluster', {
selectedCloudProvider: 'AWS',
selectedInstallationType: 'partially-managed',
})
})

it('names each provider card once for assistive technologies', () => {
const { container } = renderWithProviders(<ClusterAdd />)

expect(screen.getByRole('button', { name: 'AWS' })).toBeInTheDocument()
expect(screen.queryAllByRole('img')).toHaveLength(0)
expect(container.querySelectorAll('svg:not([aria-hidden="true"])')).toHaveLength(0)
})

it('lets the creation flow be dismissed before the cluster is created', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ type ProviderCardProps = {
analytics: { selectedCloudProvider: string; selectedInstallationType: string }
} & ({ slug: string } | { onClick: () => void })

function ProviderIcon({ icon, title }: { icon: string | ReactElement; title: string }) {
// Decorative: the card title already names the provider.
function ProviderIcon({ icon }: { icon: string | ReactElement }) {
return typeof icon === 'string' ? (
<img className="size-5 select-none" src={icon} alt={title} />
<img className="size-5 select-none" src={icon} alt="" />
Comment thread
rmnbrd marked this conversation as resolved.
) : (
cloneElement(icon, { className: 'size-5 select-none' })
cloneElement(icon, { className: 'size-5 select-none', 'aria-hidden': true })
)
}

Expand All @@ -41,7 +42,7 @@ function ProviderCard({ title, icon, disabled = false, actionLabel, analytics, .
)
const content = (
<>
<ProviderIcon icon={icon} title={title} />
<ProviderIcon icon={icon} />
<span className="min-w-0 flex-1 truncate">{title}</span>
{actionLabel ? (
<span className="shrink-0 text-ssm font-normal text-neutral-subtle">{actionLabel}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ describe('ClusterOperatorStatus', () => {

const button = screen.getByRole('button', { name: 'Update Operator' })
expect(button).toBeDisabled()
button.focus()
await userEvent.keyboard('{Enter}')
await userEvent.click(button)

expect(mockUpdateOperator).not.toHaveBeenCalled()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,28 +31,43 @@ function getDisplayedScalarValue(field: PlatformScalarField, value: unknown) {
}

// Local edits differing from the saved (or default) value; edits reverted by hand do not count.
function getChangedProfileValues(
profileTree: ProfileTreeItem[],
configuration: ClusterPlatformConfigurationResponse | null | undefined,
profileValues: ProfileValues
): ProfileValues {
const fieldsByComponent = new Map(
profileTree.flatMap((layer) => layer.children).map((component) => [component.key, component.fields])
)
return Object.fromEntries(
Object.entries(profileValues).flatMap(([componentKey, values]) => {
const fields = fieldsByComponent.get(componentKey) ?? []
const savedValues = applyPlatformConfigurationDefaults(
fields,
configuration?.platform.managedConfig?.[componentKey] ?? {}
)
const changedValues = Object.fromEntries(
Object.entries(values).filter(([fieldKey, value]) => {
const field = fields.find(({ key }) => key === fieldKey)
return field && isPlatformScalarField(field)
? getDisplayedScalarValue(field, value) !== getDisplayedScalarValue(field, savedValues[fieldKey])
: !equal(value, savedValues[fieldKey])
})
)
return Object.keys(changedValues).length ? [[componentKey, changedValues]] : []
})
)
}

function countProfileChanges(
profileTree: ProfileTreeItem[],
configuration: ClusterPlatformConfigurationResponse | null | undefined,
profileValues: ProfileValues,
clusterInputs: ClusterInputValues
) {
const fieldsByComponent = new Map(
profileTree.flatMap((layer) => layer.children).map((component) => [component.key, component.fields])
const profileChanges = Object.values(getChangedProfileValues(profileTree, configuration, profileValues)).flatMap(
(values) => Object.keys(values)
)
const profileChanges = Object.entries(profileValues).flatMap(([componentKey, values]) => {
const fields = fieldsByComponent.get(componentKey) ?? []
const savedValues = applyPlatformConfigurationDefaults(
fields,
configuration?.platform.managedConfig?.[componentKey] ?? {}
)
return Object.entries(values).filter(([fieldKey, value]) => {
const field = fields.find(({ key }) => key === fieldKey)
return field && isPlatformScalarField(field)
? getDisplayedScalarValue(field, value) !== getDisplayedScalarValue(field, savedValues[fieldKey])
: !equal(value, savedValues[fieldKey])
})
})
const clusterInputChanges = Object.entries(clusterInputs).flatMap(([componentKey, values]) =>
Object.entries(values).filter(
([inputKey, value]) => value !== (configuration?.clusterInputs[componentKey]?.[inputKey] ?? '')
Expand All @@ -73,16 +88,20 @@ function omitSubmittedValues<T extends Record<string, Record<string, unknown>>>(
) as T
}

// PUT replaces the whole configuration: unedited components and inputs are sent back as saved.
// PUT replaces the whole configuration: unedited components and inputs are sent back as saved, and edits reverted
// by hand are left out so the field keeps following the schema default.
function getConfigurationRequest(
template: PlatformTemplateSummaryResponse,
profileTree: ProfileTreeItem[],
configuration: ClusterPlatformConfigurationResponse | null | undefined,
profileValues: ProfileValues,
clusterInputs: ClusterInputValues
): ClusterPlatformConfigurationRequest {
const savedManagedConfig = configuration?.platform.managedConfig
const managedConfig = { ...savedManagedConfig }
for (const [componentKey, values] of Object.entries(profileValues)) {
for (const [componentKey, values] of Object.entries(
getChangedProfileValues(profileTree, configuration, profileValues)
)) {
managedConfig[componentKey] = omitEmptyValues({ ...savedManagedConfig?.[componentKey], ...values })
}
const nextClusterInputs = { ...configuration?.clusterInputs }
Expand Down Expand Up @@ -157,7 +176,13 @@ export function ClusterProfileProvider({ children }: PropsWithChildren) {
if (!selectedTemplate) return
await updatePlatformConfiguration({
clusterId,
configurationRequest: getConfigurationRequest(selectedTemplate, configuration, profileValues, clusterInputs),
configurationRequest: getConfigurationRequest(
selectedTemplate,
profileTree,
configuration,
profileValues,
clusterInputs
),
})
// The saved configuration now carries the submitted edits, so their local copies can go.
setProfileValues((currentValues) => omitSubmittedValues(currentValues, profileValues))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,14 @@ describe('ClusterProfileFeature', () => {
expect(onActiveComponentChange).not.toHaveBeenCalled()
})

it('does not open a component without configuration matching the search', () => {
renderWithProviders(<ClusterProfileFeature search="karpenter crd" />)

expect(screen.getByRole('button', { name: 'Karpenter crd' })).toBeDisabled()
expect(screen.getByText('No settings match your search')).toBeInTheDocument()
expect(screen.queryByText('No configuration needed for this component')).not.toBeInTheDocument()
})

it('shows empty states when nothing matches', () => {
renderWithProviders(<ClusterProfileFeature search="CPUza" />)

Expand Down Expand Up @@ -861,6 +869,24 @@ describe('ClusterProfileFeature', () => {
expect(mockDeployCluster).not.toHaveBeenCalled()
})

it('does not save edits that were reverted by hand', async () => {
const { userEvent } = renderWithProviders(<ClusterProfileFeature />)

await userEvent.click(screen.getByRole('switch', { name: 'High availability' }))
await userEvent.click(screen.getByRole('switch', { name: 'High availability' }))
await userEvent.clear(screen.getByRole('spinbutton', { name: 'Retention period' }))
await userEvent.type(screen.getByRole('spinbutton', { name: 'Retention period' }), '24')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))

expect(mockUpdatePlatformConfiguration).toHaveBeenCalledWith({
clusterId: 'cluster-id',
configurationRequest: {
platform: expect.objectContaining({ managedConfig: { loki: { 'retention-period': 24 } } }),
clusterInputs: {},
},
})
})

it('keeps the edits made while saving', async () => {
let resolveSave = () => undefined as unknown
mockUpdatePlatformConfiguration.mockReturnValue(new Promise((resolve) => (resolveSave = resolve)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ function filterProfileTree(profileTree: ProfileTreeItem[], query: string): Profi
getSectionFields(section).some((field) => fieldMatchesProfileSearch(field, query))
)
)
return children.length ? [{ ...layer, children }] : []
// A layer matched through components with nothing to configure has nothing left to open.
return children.length
? [{ ...layer, children, configurable: children.some(({ configurable }) => configurable) }]
: []
})
}

Expand Down Expand Up @@ -478,11 +481,15 @@ function ClusterProfileView({

<div className="min-h-0 flex-1 overflow-y-auto">
<div key={formKey} className="flex min-h-full flex-col">
{searchQuery && !isLoading && !activeComponent ? (
{!isLoading && !isError && !activeComponent ? (
<div className="p-4">
<EmptyState
icon="wave-pulse"
title={<span className="font-normal leading-5">No settings match your search</span>}
title={
<span className="font-normal leading-5">
{searchQuery ? 'No settings match your search' : 'Nothing to configure for this cluster'}
</span>
}
className="h-auto w-full p-8 shadow-sm"
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ describe('ProfileOperatorFooter', () => {
organizationId: 'org-123',
clusterId: 'cluster-123',
refetchInterval: 30_000,
stopPollingWhenMissing: true,
})
expect(screen.getByRole('button', { name: 'Qovery Operator: Update available' })).toBeInTheDocument()
})
Expand All @@ -71,20 +72,21 @@ describe('ProfileOperatorFooter', () => {
expect(screen.queryByRole('button', { name: 'Update Operator' })).not.toBeInTheDocument()
})

it('shows the Operator versions in a tooltip and starts an update', async () => {
it('shows the Operator versions in a popover and starts an update', async () => {
mockUseClusterOperatorStatus.mockReturnValue({ data: operatorStatus, isLoading: false })
const { userEvent } = renderWithProviders(<ProfileOperatorFooter />)

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

const status = (await screen.findAllByRole('region', { name: 'Qovery Operator status' }))[0]
expect(await screen.findByRole('dialog', { name: 'Qovery Operator status' })).toBeInTheDocument()
const status = await screen.findByRole('region', { name: 'Qovery Operator status' })
expect(status).toHaveTextContent('Update available')
expect(status).toHaveTextContent('v1.202.0')
expect(status).toHaveTextContent('Target: v1.203.0')
expect(status).toHaveTextContent('0.2.0')
expect(status).toHaveTextContent('Target: 0.2.1')

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

expect(mockUpdateOperator).toHaveBeenCalledWith({
organizationId: 'org-123',
Expand All @@ -94,16 +96,27 @@ describe('ProfileOperatorFooter', () => {
})
})

it('reaches the update action from the keyboard', async () => {
mockUseClusterOperatorStatus.mockReturnValue({ data: operatorStatus, isLoading: false })
const { userEvent } = renderWithProviders(<ProfileOperatorFooter />)

screen.getByRole('button', { name: 'Qovery Operator: Update available' }).focus()
await userEvent.keyboard('{Enter}')
await userEvent.tab()

expect(screen.getByRole('button', { name: 'Update Operator' })).toHaveFocus()
})

it('hides the update action while the Operator is disconnected', async () => {
mockUseClusterOperatorStatus.mockReturnValue({
data: { ...operatorStatus, operator_connected: false, status: 'DISCONNECTED' },
isLoading: false,
})
const { userEvent } = renderWithProviders(<ProfileOperatorFooter />)

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

expect((await screen.findAllByText('No recent heartbeat was received from the Operator.'))[0]).toBeInTheDocument()
expect(await screen.findByText('No recent heartbeat was received from the Operator.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Update Operator' })).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useParams } from '@tanstack/react-router'
import { Icon, LoaderSpinner, Tooltip } from '@qovery/shared/ui'
import { Icon, LoaderSpinner, Popover, Tooltip } from '@qovery/shared/ui'
import {
ClusterOperatorStatus,
getClusterOperatorStatusDisplay,
Expand All @@ -18,6 +18,7 @@ export function ProfileOperatorFooter() {
organizationId,
clusterId,
refetchInterval: OPERATOR_STATUS_REFRESH_INTERVAL,
stopPollingWhenMissing: true,
})

// Clusters without Operator state (q-core answers 404) have no Operator to describe.
Expand Down Expand Up @@ -50,17 +51,14 @@ export function ProfileOperatorFooter() {
)
}

// The panel holds an action: a popover keeps it reachable by keyboard, unlike a tooltip.
return operatorStatus ? (
<Tooltip
side="right"
align="end"
collisionPadding={8}
classNameContent="w-[480px] rounded-lg border border-neutral bg-surface-neutral p-4 text-neutral shadow-lg"
classNameArrow="fill-surface-neutral"
content={<ClusterOperatorStatus operatorStatus={operatorStatus} />}
>
{trigger}
</Tooltip>
<Popover.Root>
<Popover.Trigger>{trigger}</Popover.Trigger>
<Popover.Content side="right" align="end" aria-label="Qovery Operator status" className="w-[480px] rounded-lg">
<ClusterOperatorStatus operatorStatus={operatorStatus} />
</Popover.Content>
</Popover.Root>
) : (
trigger
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,26 @@ describe('resolveProfileSelection', () => {
expect(resolveProfileSelection(profileTree, requestedKey).component?.key).toBe('karpenter-configuration')
}
)

it('selects nothing when the only enabled layer has nothing to configure', () => {
const emptyTree = getProfileTree({
...template,
layers: [{ key: 'crds', components: [{ key: 'karpenter-crd', fields: [] }] }],
} as unknown as PlatformTemplateSummaryResponse)

expect(emptyTree[0]).toMatchObject({ status: 'success', configurable: false })
expect(resolveProfileSelection(emptyTree, undefined)).toEqual({ layer: undefined, component: undefined })
expect(resolveProfileSelection(emptyTree, 'karpenter-crd')).toEqual({ layer: undefined, component: undefined })
})

it('selects nothing when every layer is skipped or managed by Qovery', () => {
const lockedTree = getProfileTree(template, [
{ key: 'network', status: 'SKIPPED', reason: '', componentKeys: [] },
{ key: 'log-infra', status: 'DISABLED', reason: '', componentKeys: [] },
{ key: 'karpenter', status: 'DISABLED', reason: '', componentKeys: [] },
])

expect(resolveProfileSelection(lockedTree, undefined)).toEqual({ layer: undefined, component: undefined })
expect(resolveProfileSelection(lockedTree, 'loki')).toEqual({ layer: undefined, component: undefined })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,12 @@ function findProfileLayer(profileTree: ProfileTreeItem[], requestedKey?: string)
// Skipped or Qovery-managed layers, and layers with nothing to configure, cannot be opened.
const isOpenableLayer = (item: ProfileTreeItem) => item.status !== 'disabled' && item.configurable

// Nothing is selected when no layer can be opened.
function getDefaultProfileComponent(profileTree: ProfileTreeItem[]) {
return getFirstConfigurableComponent(
(
profileTree.find((item) => item.label.toLowerCase() === 'log infra' && isOpenableLayer(item)) ??
profileTree.find(isOpenableLayer) ??
profileTree.find((item) => item.children?.length)
profileTree.find(isOpenableLayer)
)?.children
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,23 @@ jest.mock('@tanstack/react-query', () => ({
const mockUseQuery = useQuery as jest.Mock

describe('useClusterOperatorStatus', () => {
it('stops polling once the cluster is known to have no Operator', () => {
it('keeps polling while the Operator state is missing by default', () => {
renderHook(() =>
useClusterOperatorStatus({ organizationId: 'org-123', clusterId: 'cluster-123', refetchInterval: 30_000 })
useClusterOperatorStatus({ organizationId: 'org-123', clusterId: 'cluster-123', refetchInterval: 5_000 })
)

const { refetchInterval } = mockUseQuery.mock.calls[0][0]
expect(refetchInterval(null)).toBe(5_000)
})

it('stops polling once the cluster is known to have no Operator when asked to', () => {
renderHook(() =>
useClusterOperatorStatus({
organizationId: 'org-123',
clusterId: 'cluster-123',
refetchInterval: 30_000,
stopPollingWhenMissing: true,
})
)

const { refetchInterval } = mockUseQuery.mock.calls[0][0]
Expand Down
Loading
Loading