- {searchQuery && !isLoading && !activeComponent ? (
+ {!isLoading && !isError && !activeComponent ? (
No settings match your search}
+ title={
+
+ {searchQuery ? 'No settings match your search' : 'Nothing to configure for this cluster'}
+
+ }
className="h-auto w-full p-8 shadow-sm"
/>
diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx
index c40866bfdfd..9d200b95ef2 100644
--- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx
+++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx
@@ -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()
})
@@ -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(
)
- 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',
@@ -94,6 +96,17 @@ describe('ProfileOperatorFooter', () => {
})
})
+ it('reaches the update action from the keyboard', async () => {
+ mockUseClusterOperatorStatus.mockReturnValue({ data: operatorStatus, isLoading: false })
+ const { userEvent } = renderWithProviders(
)
+
+ 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' },
@@ -101,9 +114,9 @@ describe('ProfileOperatorFooter', () => {
})
const { userEvent } = renderWithProviders(
)
- 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()
})
})
diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx
index 33b2e9cfd71..c9ae44b6b1b 100644
--- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx
+++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx
@@ -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,
@@ -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.
@@ -50,17 +51,14 @@ export function ProfileOperatorFooter() {
)
}
+ // The panel holds an action: a popover keeps it reachable by keyboard, unlike a tooltip.
return operatorStatus ? (
-
}
- >
- {trigger}
-
+
+ {trigger}
+
+
+
+
) : (
trigger
)
diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.spec.ts b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.spec.ts
index ef4483bc894..e479d3b09c3 100644
--- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.spec.ts
+++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.spec.ts
@@ -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 })
+ })
})
diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.ts b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.ts
index 9ee2166991b..c889bac00d5 100644
--- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.ts
+++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.ts
@@ -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
)
}
diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.spec.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.spec.ts
index 5f60195ced8..5c0a39133bf 100644
--- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.spec.ts
+++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.spec.ts
@@ -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]
diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts
index 3033e90b260..8906d11076d 100644
--- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts
+++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts
@@ -7,6 +7,8 @@ export interface UseClusterOperatorStatusProps {
enabled?: boolean
// Poll interval in milliseconds, disabled when unset.
refetchInterval?: number
+ // Stop polling once the cluster is known to have no Operator state (404), instead of waiting for it to appear.
+ stopPollingWhenMissing?: boolean
staleTime?: number
}
@@ -15,12 +17,15 @@ export function useClusterOperatorStatus({
clusterId,
enabled = true,
refetchInterval,
+ stopPollingWhenMissing = false,
staleTime,
}: UseClusterOperatorStatusProps) {
return useQuery({
...queries.clusters.operatorStatus({ organizationId, clusterId }),
enabled: enabled && Boolean(organizationId) && Boolean(clusterId),
- refetchInterval: refetchInterval ? (data) => (data === null ? false : refetchInterval) : undefined,
+ refetchInterval: refetchInterval
+ ? (data) => (stopPollingWhenMissing && data === null ? false : refetchInterval)
+ : undefined,
staleTime,
})
}
diff --git a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx
index 909f569a29b..00ca9107a8a 100644
--- a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx
+++ b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx
@@ -114,6 +114,19 @@ describe('CatalogVariableInput', () => {
expect(screen.getByLabelText('Token')).toHaveAttribute('type', 'password')
})
+ it.each(['card', 'row'] as const)('masks sensitive number values in the %s layout', (layout) => {
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByLabelText('Pin')).toHaveAttribute('type', 'password')
+ })
+
it('keeps the toggle presentation as the default for boolean fields', async () => {
const onChange = jest.fn()
const { userEvent } = renderWithProviders(
diff --git a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.tsx b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.tsx
index a82715b565c..45198a16f91 100644
--- a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.tsx
+++ b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.tsx
@@ -102,7 +102,7 @@ function CatalogVariableControl({
id={field.key}
label={field.label}
className="w-full [&>div[data-testid=input]]:!h-10 [&>div[data-testid=input]]:!min-h-10"
- type={field.type === 'number' ? 'number' : field.sensitive ? 'password' : 'text'}
+ type={field.sensitive ? 'password' : field.type === 'number' ? 'number' : 'text'}
value={typeof value === 'string' ? value : ''}
error={error}
hasShowPasswordButton={field.sensitive}
@@ -240,7 +240,7 @@ export function CatalogVariableInput({