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 @@ -19,6 +19,11 @@ jest.mock('posthog-js', () => ({
default: { capture: jest.fn() },
}))
jest.mock('posthog-js/react', () => ({ useFeatureFlagEnabled: () => false }))
const mockShowPylonForm = jest.fn()
jest.mock('@qovery/shared/util-hooks', () => ({
...jest.requireActual('@qovery/shared/util-hooks'),
useSupportChat: () => ({ showPylonForm: mockShowPylonForm }),
}))
jest.mock('../hooks/use-cluster-creation-restriction/use-cluster-creation-restriction', () => ({
useClusterCreationRestriction: () => ({ isClusterCreationRestricted: false }),
}))
Expand All @@ -43,6 +48,14 @@ describe('ClusterAdd', () => {
expect(await screen.findByText('Creation flow')).toBeInTheDocument()
}

it('asks for EKS Anywhere access when it is not enabled', async () => {
const { userEvent } = renderWithProviders(<ClusterAdd />)

await userEvent.click(screen.getByRole('button', { name: /Amazon Web Services EKS Anywhere\s*Request access/ }))

expect(mockShowPylonForm).toHaveBeenCalledWith('request-access-eks-anywhere')
})

it('lets the creation flow be dismissed before the cluster is created', async () => {
const { userEvent } = renderWithProviders(<ClusterAdd />)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type ProviderCardProps = {
title: string
icon: string | ReactElement
disabled?: boolean
actionLabel?: string
analytics: { selectedCloudProvider: string; selectedInstallationType: string }
} & ({ slug: string } | { onClick: () => void })

Expand All @@ -32,7 +33,7 @@ function ProviderIcon({ icon, title }: { icon: string | ReactElement; title: str
)
}

function ProviderCard({ title, icon, disabled = false, analytics, ...props }: ProviderCardProps) {
function ProviderCard({ title, icon, disabled = false, actionLabel, analytics, ...props }: ProviderCardProps) {
const { organizationId = '' } = useParams({ strict: false })
const className = twMerge(
'flex h-[52px] items-center gap-2 rounded-md border border-neutral bg-surface-neutral px-4 text-left text-sm font-medium text-neutral transition-colors',
Expand All @@ -42,7 +43,11 @@ function ProviderCard({ title, icon, disabled = false, analytics, ...props }: Pr
<>
<ProviderIcon icon={icon} title={title} />
<span className="min-w-0 flex-1 truncate">{title}</span>
<Icon iconName="angle-right" iconStyle="regular" className="text-neutral-subtle" />
{actionLabel ? (
<span className="shrink-0 text-ssm font-normal text-neutral-subtle">{actionLabel}</span>
) : (
<Icon iconName="angle-right" iconStyle="regular" className="text-neutral-subtle" />
)}
</>
)
const capture = () => posthog.capture('select-cluster', analytics)
Expand Down Expand Up @@ -165,6 +170,7 @@ export function ClusterAdd() {
<ProviderCard
title="Amazon Web Services EKS Anywhere"
icon={<Icon name="AWS" />}
actionLabel="Request access"
onClick={() => showPylonForm('request-access-eks-anywhere')}
analytics={{ selectedCloudProvider: 'AWS', selectedInstallationType: 'partially-managed' }}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export function ClusterProfileSidebar({
const showEmptyState = !isLoading && !isError && layers.length === 0

return (
<aside aria-label="Cluster layers" className="hidden w-[272px] shrink-0 flex-col bg-background-secondary lg:flex">
<aside aria-label="Cluster layers" className="flex w-56 shrink-0 flex-col bg-background-secondary lg:w-[272px]">
<div className="p-3">
<InputSearch
ariaLabel="Search layers"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ jest.mock('@qovery/shared/util-hooks', () => ({
...jest.requireActual('@qovery/shared/util-hooks'),
useDebounce: <T,>(value: T) => value,
}))
jest.mock('@qovery/shared/ui', () => {
const React = jest.requireActual('react')
const ui = jest.requireActual('@qovery/shared/ui')
return {
...ui,
Icon: (props: { name?: string }) =>
React.createElement('span', { 'data-icon-name': props.name }, React.createElement(ui.Icon, props)),
}
})
jest.mock('../hooks/use-cluster/use-cluster')
jest.mock('../hooks/use-cluster-operator-status/use-cluster-operator-status', () => ({
useClusterOperatorStatus: () => ({ data: null, isLoading: false }),
Expand Down Expand Up @@ -255,6 +264,23 @@ describe('ClusterProfileFeature', () => {
mockUseDeployCluster.mockReturnValue({ mutateAsync: mockDeployCluster, isLoading: false })
})

it.each([
['AWS', 'AWS'],
['GCP', 'GCP'],
['OVH', 'OVH_CLOUD'],
['ORACLE', 'ORACLE_CLOUD'],
['IBM', 'IBM_CLOUD'],
])('shows the %s provider icon in the header', (cloudProvider, iconName) => {
mockUseCluster.mockReturnValue({
data: { name: 'my-cluster', cloud_provider: cloudProvider, kubernetes: 'SELF_MANAGED' },
isError: false,
isLoading: false,
} as ReturnType<typeof useCluster>)
renderWithProviders(<ClusterProfileFeature />)

expect(screen.getByRole('banner').querySelector(`[data-icon-name="${iconName}"]`)).toBeInTheDocument()
})

it('renders API-defined configuration fields', () => {
const { container } = renderWithProviders(<ClusterProfileFeature />)

Expand Down Expand Up @@ -331,6 +357,8 @@ describe('ClusterProfileFeature', () => {
it('keeps the form visible while a field edit triggers a resolver refetch', async () => {
const { userEvent } = renderWithProviders(<ClusterProfileFeature />)
const highAvailability = screen.getByRole('switch', { name: 'High availability' })
// Queries keep their previous data while refetching.
mockUsePlatformComponentConfigurations.mockReturnValue(createComponentQueries(['loki', 'alloy'], true))

await userEvent.click(highAvailability)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import equal from 'fast-deep-equal'
import { type PlatformComponentConfigurationResolutionResponse } from 'qovery-typescript-axios'
import { type CloudVendorEnum, type PlatformComponentConfigurationResolutionResponse } from 'qovery-typescript-axios'
import { useEffect, useMemo, useRef } from 'react'
import { match } from 'ts-pattern'
import { CatalogVariableInput } from '@qovery/shared/console-shared'
import { IconEnum } from '@qovery/shared/enums'
import { EmptyState, Heading, Icon, Navbar, Skeleton } from '@qovery/shared/ui'
Expand Down Expand Up @@ -73,6 +74,14 @@ function filterProfileTree(profileTree: ProfileTreeItem[], query: string): Profi
})
}

function getCloudProviderIconName(cloudProvider: CloudVendorEnum) {
return match(cloudProvider)
.with('OVH', () => IconEnum.OVH_CLOUD)
.with('ORACLE', () => IconEnum.ORACLE_CLOUD)
.with('IBM', () => IconEnum.IBM_CLOUD)
.otherwise((provider) => provider)
}

function getProfileSections(
component: ProfileComponent | undefined,
layer: ProfileTreeItem | undefined,
Expand Down Expand Up @@ -434,7 +443,9 @@ function ClusterProfileView({
<div className="flex h-page-container min-h-0 flex-col overflow-hidden bg-background-secondary text-sm">
<header className="flex min-h-11 items-center justify-between gap-4 px-4 py-2">
<div className="flex min-w-0 items-center gap-2">
<Icon name={IconEnum.AWS} width={20} height={20} />
{cluster?.cloud_provider ? (
<Icon name={getCloudProviderIconName(cluster.cloud_provider)} width={20} height={20} />
) : null}
<p className="truncate font-medium text-neutral">{cluster?.name ?? 'Cluster'}</p>
</div>
</header>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests'
import { type PlatformFieldDescriptor } from '../platform-configuration/platform-configuration-utils'
import { ProfileArrayItemModal } from './profile-array-item-modal'

const cidrField = {
key: 'cidr',
label: 'CIDR',
type: 'string',
required: false,
sensitive: false,
constraints: {},
} as PlatformFieldDescriptor

describe('ProfileArrayItemModal', () => {
it.each([
['optional fields', cidrField, {}],
['prefilled required fields', { ...cidrField, required: true, defaultValue: '10.0.0.0/8' }, { cidr: '10.0.0.0/8' }],
])('lets an item made of %s be added without editing it', async (_, field, submittedValues) => {
const onSubmit = jest.fn()
const { userEvent } = renderWithProviders(
<ProfileArrayItemModal
title="Trusted CIDRs"
fields={[field as PlatformFieldDescriptor]}
values={{}}
isEdit={false}
onClose={jest.fn()}
onSubmit={onSubmit}
/>
)

const addButton = screen.getByRole('button', { name: 'Add' })
await waitFor(() => expect(addButton).toBeEnabled())
await userEvent.click(addButton)

expect(onSubmit).toHaveBeenCalledWith(submittedValues)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function ProfileChangesBar({
size="md"
className="gap-1.5"
loading={isSaving}
disabled={isDeploying || !canSubmit}
disabled={isPending || !canSubmit}
onClick={onSave}
>
<Icon iconName="floppy-disk" iconStyle="regular" />
Expand All @@ -71,7 +71,7 @@ export function ProfileChangesBar({
size="md"
className="gap-1.5"
loading={isDeploying}
disabled={isSaving || !canSubmit}
disabled={isPending || !canSubmit}
onClick={onSaveAndDeploy}
>
<Icon iconName="rocket" iconStyle="regular" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { type FieldSchemaResponse } from 'qovery-typescript-axios'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { ProfileConfigurationField } from './profile-configuration-field'

const scalarItemField = {
key: 'name',
label: 'Name',
type: 'string',
required: true,
sensitive: false,
constraints: {},
}

function createArrayField(itemFields: unknown[]) {
return {
key: 'listeners',
label: 'Listeners',
type: 'array',
required: false,
sensitive: false,
constraints: {},
items: { type: 'object', fields: itemFields },
itemFields: [],
} as unknown as FieldSchemaResponse
}

describe('ProfileConfigurationField', () => {
it('lets object items with scalar fields be added', () => {
renderWithProviders(
<ProfileConfigurationField
field={createArrayField([scalarItemField])}
path="listeners"
value={[]}
violations={[]}
onChange={jest.fn()}
/>
)

expect(screen.getByRole('button', { name: 'Add item to Listeners' })).toBeEnabled()
})

it('does not add object items whose nested settings the modal cannot edit', () => {
renderWithProviders(
<ProfileConfigurationField
field={createArrayField([
scalarItemField,
{
key: 'tls',
label: 'TLS',
type: 'object',
required: true,
sensitive: false,
constraints: {},
fields: [{ ...scalarItemField, key: 'secret', label: 'Secret' }],
},
])}
path="listeners"
value={[{ name: 'https', tls: { secret: 'certificate' } }]}
violations={[]}
onChange={jest.fn()}
/>
)

expect(screen.getByRole('button', { name: 'Add item to Listeners' })).toBeDisabled()
expect(screen.getByText('Items with nested settings cannot be added here yet.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Edit https' })).toBeEnabled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,10 @@ function ProfileArrayField({
const isItemModalOpenRef = useRef(false)
const items: unknown[] = Array.isArray(value) ? value : []
const { minItems, maxItems } = field.constraints
const canAdd = typeof maxItems !== 'number' || items.length < maxItems
const isUnderLimit = typeof maxItems !== 'number' || items.length < maxItems
const hasNestedItemFields =
field.items.type === 'object' && field.items.fields.some((itemField) => !isPlatformScalarField(itemField))
const canAdd = isUnderLimit && !hasNestedItemFields
const canRemove = typeof minItems !== 'number' || items.length > minItems
const error = getFieldViolation(violations, path)

Expand Down Expand Up @@ -239,7 +242,9 @@ function ProfileArrayField({
<Icon iconName="circle-plus" iconStyle="regular" />
Add item
</Button>
{typeof maxItems === 'number' && !canAdd ? (
{hasNestedItemFields ? (
<p className="text-xs text-neutral-subtle">Items with nested settings cannot be added here yet.</p>
) : typeof maxItems === 'number' && !isUnderLimit ? (
<p className="text-xs text-neutral-subtle">Limit of {maxItems} reached.</p>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('ProfileOperatorFooter', () => {
expect(container).toBeEmptyDOMElement()
})

it('refreshes the Operator status while displayed', () => {
it('configures polling for the Operator status', () => {
mockUseClusterOperatorStatus.mockReturnValue({ data: operatorStatus, isLoading: false })
renderWithProviders(<ProfileOperatorFooter organizationId="org-123" clusterId="cluster-123" />)

Expand All @@ -50,6 +50,29 @@ describe('ProfileOperatorFooter', () => {
expect(screen.getByRole('button', { name: 'Qovery operator: Update available' })).toBeInTheDocument()
})

it('tells when the Operator status cannot be retrieved', async () => {
mockUseClusterOperatorStatus.mockReturnValue({ data: undefined, isLoading: false, isError: true })
const { userEvent } = renderWithProviders(
<ProfileOperatorFooter organizationId="org-123" clusterId="cluster-123" />
)

await userEvent.hover(screen.getByRole('button', { name: 'Qovery operator: status unavailable' }))

expect((await screen.findAllByText('The Operator status could not be retrieved.'))[0]).toBeInTheDocument()
})

it('does not keep showing a stale status once a refresh fails', async () => {
mockUseClusterOperatorStatus.mockReturnValue({ data: operatorStatus, isLoading: false, isError: true })
const { userEvent } = renderWithProviders(
<ProfileOperatorFooter organizationId="org-123" clusterId="cluster-123" />
)

await userEvent.hover(screen.getByRole('button', { name: 'Qovery operator: status unavailable' }))

expect((await screen.findAllByText('The Operator status could not be retrieved.'))[0]).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Update Operator' })).not.toBeInTheDocument()
})

it('shows the Operator versions in a tooltip and starts an update', async () => {
mockUseClusterOperatorStatus.mockReturnValue({ data: operatorStatus, isLoading: false })
const { userEvent } = renderWithProviders(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,46 @@ import { useClusterOperatorStatus } from '../hooks/use-cluster-operator-status/u
const OPERATOR_STATUS_REFRESH_INTERVAL = 30_000

export function ProfileOperatorFooter({ organizationId, clusterId }: { organizationId: string; clusterId: string }) {
const { data: operatorStatus, isLoading } = useClusterOperatorStatus({
const {
data: operatorStatus,
isLoading,
isError,
} = useClusterOperatorStatus({
organizationId,
clusterId,
refetchInterval: OPERATOR_STATUS_REFRESH_INTERVAL,
})

// Clusters without Operator state (q-core answers 404) have no Operator to describe.
if (!isLoading && !operatorStatus) return null
if (!isLoading && !isError && !operatorStatus) return null

const display = operatorStatus ? getClusterOperatorStatusDisplay(operatorStatus.status) : undefined
const display = operatorStatus && !isError ? getClusterOperatorStatusDisplay(operatorStatus.status) : undefined

const trigger = (
<button
type="button"
aria-label={`Qovery operator: ${display?.label ?? 'loading'}`}
aria-label={`Qovery operator: ${display?.label ?? (isError ? 'status unavailable' : 'loading')}`}
className="focus-visible:ring-brand flex w-full shrink-0 items-center gap-2 bg-background-secondary p-3 text-left text-sm font-medium text-neutral outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
Qovery operator
{display ? (
<Icon iconName={display.icon.name} iconStyle="regular" className={`text-sm ${display.icon.className}`} />
) : isError ? (
<Icon iconName="circle-exclamation" iconStyle="regular" className="text-sm text-warning" />
) : (
<LoaderSpinner className="w-3" />
)}
</button>
)

if (isError) {
return (
<Tooltip side="right" content="The Operator status could not be retrieved.">
{trigger}
</Tooltip>
)
}

return operatorStatus ? (
<Tooltip
side="right"
Expand Down
Loading
Loading