From 425c8b41d1a10bcbf5d950e896b2265d6697ee9b Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 22 Sep 2026 13:04:37 +0200 Subject: [PATCH 01/21] Add Profile page --- apps/console/src/routeTree.gen.ts | 25 +++++++++++++++++++ .../cluster/$clusterId/profile.tsx | 17 +++++++++++++ .../_authenticated/organization/route.tsx | 6 +++++ 3 files changed, 48 insertions(+) create mode 100644 apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile.tsx diff --git a/apps/console/src/routeTree.gen.ts b/apps/console/src/routeTree.gen.ts index 4327c6663da..7dc56b4d088 100644 --- a/apps/console/src/routeTree.gen.ts +++ b/apps/console/src/routeTree.gen.ts @@ -62,6 +62,7 @@ import { Route as AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRou import { Route as AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRouteImport } from './routes/_authenticated/organization/$organizationId/settings/agents/mcps' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/variables' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/overview' +import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/overview' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/cluster-logs' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdCloudShellRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/cloud-shell' @@ -582,6 +583,14 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute = getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRoute = + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteImport.update( + { + id: '/cluster/$clusterId/profile', + path: '/cluster/$clusterId/profile', + getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute = AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRouteImport.update( { @@ -1936,6 +1945,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/cluster/$clusterId/cloud-shell': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdCloudShellRoute '/organization/$organizationId/cluster/$clusterId/cluster-logs': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute '/organization/$organizationId/cluster/$clusterId/overview': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute + '/organization/$organizationId/cluster/$clusterId/profile': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRoute '/organization/$organizationId/project/$projectId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute '/organization/$organizationId/project/$projectId/variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRoute '/organization/$organizationId/settings/agents/mcps': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRoute @@ -2131,6 +2141,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/cluster/$clusterId/cloud-shell': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdCloudShellRoute '/organization/$organizationId/cluster/$clusterId/cluster-logs': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute '/organization/$organizationId/cluster/$clusterId/overview': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute + '/organization/$organizationId/cluster/$clusterId/profile': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRoute '/organization/$organizationId/project/$projectId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute '/organization/$organizationId/project/$projectId/variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRoute '/organization/$organizationId/settings/agents/mcps': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRoute @@ -2321,6 +2332,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/cluster/$clusterId/cloud-shell': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdCloudShellRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/cluster-logs': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/overview': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRoute '/_authenticated/organization/$organizationId/project/$projectId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute '/_authenticated/organization/$organizationId/project/$projectId/variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRoute '/_authenticated/organization/$organizationId/settings/agents/mcps': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRoute @@ -2526,6 +2538,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/$clusterId/cloud-shell' | '/organization/$organizationId/cluster/$clusterId/cluster-logs' | '/organization/$organizationId/cluster/$clusterId/overview' + | '/organization/$organizationId/cluster/$clusterId/profile' | '/organization/$organizationId/project/$projectId/overview' | '/organization/$organizationId/project/$projectId/variables' | '/organization/$organizationId/settings/agents/mcps' @@ -2721,6 +2734,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/$clusterId/cloud-shell' | '/organization/$organizationId/cluster/$clusterId/cluster-logs' | '/organization/$organizationId/cluster/$clusterId/overview' + | '/organization/$organizationId/cluster/$clusterId/profile' | '/organization/$organizationId/project/$projectId/overview' | '/organization/$organizationId/project/$projectId/variables' | '/organization/$organizationId/settings/agents/mcps' @@ -2910,6 +2924,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/cluster/$clusterId/cloud-shell' | '/_authenticated/organization/$organizationId/cluster/$clusterId/cluster-logs' | '/_authenticated/organization/$organizationId/cluster/$clusterId/overview' + | '/_authenticated/organization/$organizationId/cluster/$clusterId/profile' | '/_authenticated/organization/$organizationId/project/$projectId/overview' | '/_authenticated/organization/$organizationId/project/$projectId/variables' | '/_authenticated/organization/$organizationId/settings/agents/mcps' @@ -3443,6 +3458,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute } + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile': { + id: '/_authenticated/organization/$organizationId/cluster/$clusterId/profile' + path: '/cluster/$clusterId/profile' + fullPath: '/organization/$organizationId/cluster/$clusterId/profile' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute + } '/_authenticated/organization/$organizationId/cluster/$clusterId/overview': { id: '/_authenticated/organization/$organizationId/cluster/$clusterId/overview' path: '/cluster/$clusterId/overview' @@ -5179,6 +5201,7 @@ interface AuthenticatedOrganizationOrganizationIdRouteRouteChildren { AuthenticatedOrganizationOrganizationIdClusterClusterIdCloudShellRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdCloudShellRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute @@ -5248,6 +5271,8 @@ const AuthenticatedOrganizationOrganizationIdRouteRouteChildren: AuthenticatedOr AuthenticatedOrganizationOrganizationIdClusterClusterIdClusterLogsRoute, AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute: AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute, + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRoute: + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRoute: diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile.tsx new file mode 100644 index 00000000000..d3447001981 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Heading, Section } from '@qovery/shared/ui' +import { useDocumentTitle } from '@qovery/shared/util-hooks' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/$clusterId/profile')({ + component: RouteComponent, +}) + +function RouteComponent() { + useDocumentTitle('Cluster - Profile') + + return ( +
+ Profile +
+ ) +} diff --git a/apps/console/src/routes/_authenticated/organization/route.tsx b/apps/console/src/routes/_authenticated/organization/route.tsx index 9e421016edf..d17aeb05cbc 100644 --- a/apps/console/src/routes/_authenticated/organization/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/route.tsx @@ -97,6 +97,12 @@ const CLUSTER_TABS: NavigationTab[] = [ iconName: 'terminal', routeId: '/_authenticated/organization/$organizationId/cluster/$clusterId/cloud-shell', }, + { + id: 'profile', + label: 'Profile', + iconName: 'layer-group', + routeId: '/_authenticated/organization/$organizationId/cluster/$clusterId/profile', + }, { id: 'settings', label: 'Settings', From 510b3c40fcd7fbb0aef53f62296700a0df9261fe Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 22 Sep 2026 17:01:18 +0200 Subject: [PATCH 02/21] Implement main Profile page UI elements --- .../cluster/$clusterId/profile.tsx | 31 +- .../_authenticated/organization/route.tsx | 10 +- .../domains/clusters/data-access/src/index.ts | 1 + .../platform-configuration.ts | 22 + libs/domains/clusters/feature/src/index.ts | 2 + .../cluster-profile/cluster-profile.spec.tsx | 110 +++++ .../lib/cluster-profile/cluster-profile.tsx | 448 ++++++++++++++++++ .../use-platform-templates.ts | 22 + .../util-queries/src/lib/queries/queries.ts | 3 +- 9 files changed, 641 insertions(+), 8 deletions(-) create mode 100644 libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts create mode 100644 libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx create mode 100644 libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx create mode 100644 libs/domains/clusters/feature/src/lib/hooks/use-platform-templates/use-platform-templates.ts diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile.tsx index d3447001981..e02e807c510 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile.tsx @@ -1,17 +1,38 @@ -import { createFileRoute } from '@tanstack/react-router' -import { Heading, Section } from '@qovery/shared/ui' +import { Navigate, createFileRoute, useParams } from '@tanstack/react-router' +import { useFeatureFlagEnabled } from 'posthog-js/react' +import { z } from 'zod' +import { ClusterProfileFeature, ENGINE_V2_PLATFORM_CONFIGURATION_FEATURE_FLAG } from '@qovery/domains/clusters/feature' import { useDocumentTitle } from '@qovery/shared/util-hooks' export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/$clusterId/profile')({ component: RouteComponent, + validateSearch: z.object({ + component: z.string().optional(), + }), }) function RouteComponent() { useDocumentTitle('Cluster - Profile') + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const { component } = Route.useSearch() + const navigate = Route.useNavigate() + const isProfileEnabled = Boolean(useFeatureFlagEnabled(ENGINE_V2_PLATFORM_CONFIGURATION_FEATURE_FLAG)) + + if (!isProfileEnabled && organizationId && clusterId) { + return ( + + ) + } return ( -
- Profile -
+ navigate({ search: { component: nextComponent } })} + /> ) } diff --git a/apps/console/src/routes/_authenticated/organization/route.tsx b/apps/console/src/routes/_authenticated/organization/route.tsx index d17aeb05cbc..f879752aee8 100644 --- a/apps/console/src/routes/_authenticated/organization/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/route.tsx @@ -4,7 +4,7 @@ import posthog from 'posthog-js' import { useFeatureFlagEnabled } from 'posthog-js/react' import { type Cluster } from 'qovery-typescript-axios' import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { useClusters } from '@qovery/domains/clusters/feature' +import { ENGINE_V2_PLATFORM_CONFIGURATION_FEATURE_FLAG, useClusters } from '@qovery/domains/clusters/feature' import { useEnvironment } from '@qovery/domains/environments/feature' import { useProject } from '@qovery/domains/projects/feature' import { type AnyService, isAgenticWorkflow, isArgoCd, isManagedDatabase } from '@qovery/domains/services/data-access' @@ -341,6 +341,9 @@ function useNavigationContext(): NavigationContext | null { const pathname = location.pathname const organizationId = typeof params.organizationId === 'string' ? params.organizationId : '' const isAgenticWorkflowEnabled = Boolean(useFeatureFlagEnabled('argentic-workflow')) + const isEngineV2PlatformConfigurationEnabled = Boolean( + useFeatureFlagEnabled(ENGINE_V2_PLATFORM_CONFIGURATION_FEATURE_FLAG) + ) const { data: service } = useServiceSummary({ environmentId: params.environmentId, serviceId: params.serviceId, @@ -380,7 +383,9 @@ function useNavigationContext(): NavigationContext | null { ? getServiceTabs(service, currentCluster, isAgenticWorkflowEnabled) : context.type === 'organization' ? context.tabs.filter((tab) => hasAlerting || tab.id !== 'alerts') - : context.tabs + : context.type === 'cluster' + ? context.tabs.filter((tab) => isEngineV2PlatformConfigurationEnabled || tab.id !== 'profile') + : context.tabs return { type: context.type, @@ -478,6 +483,7 @@ const fullWidthRouteIds: FileRouteTypes['id'][] = [ '/_authenticated/organization/$organizationId/cluster/$clusterId/cluster-logs', '/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId', '/_authenticated/organization/$organizationId/cluster/$clusterId/cloud-shell', + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile', '/_authenticated/organization/$organizationId/cluster/$clusterId/settings', '/_authenticated/organization/$organizationId/project/$projectId/settings', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/settings', diff --git a/libs/domains/clusters/data-access/src/index.ts b/libs/domains/clusters/data-access/src/index.ts index e8ca0eb7ad5..8e8229f53d9 100644 --- a/libs/domains/clusters/data-access/src/index.ts +++ b/libs/domains/clusters/data-access/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/domains-clusters-data-access' +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' export { excludeSecretManagerAccess } from './lib/secret-manager/exclude-secret-manager-access' diff --git a/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts new file mode 100644 index 00000000000..360479c1ea1 --- /dev/null +++ b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts @@ -0,0 +1,22 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory' +import { type PlatformCloudVendor, type PlatformClusterMode, PlatformConfigurationApi } from 'qovery-typescript-axios' + +const platformConfigurationApi = new PlatformConfigurationApi() + +export const platformConfiguration = createQueryKeys('platformConfiguration', { + templates: ({ + organizationId, + clusterMode, + cloudProvider, + }: { + organizationId: string + clusterMode?: PlatformClusterMode + cloudProvider?: PlatformCloudVendor + }) => ({ + queryKey: [organizationId, clusterMode, cloudProvider], + async queryFn() { + const response = await platformConfigurationApi.listPlatformTemplates(organizationId, clusterMode, cloudProvider) + return response.data.results + }, + }), +}) diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 129cf64613a..413d9a4f6c0 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -7,6 +7,7 @@ export * from './lib/cluster-actions/cluster-actions' 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 * from './lib/cluster-advanced-settings/init-form-values' export * from './lib/cluster-setup/cluster-setup' export * from './lib/cluster-need-redeploy-flag/cluster-need-redeploy-flag' @@ -56,6 +57,7 @@ export * from './lib/hooks/use-cluster-logs/use-cluster-logs' export * from './lib/hooks/use-cluster-status/use-cluster-status' 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/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' diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx new file mode 100644 index 00000000000..97620d092ef --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx @@ -0,0 +1,110 @@ +import { type PlatformTemplateSummaryResponse } from 'qovery-typescript-axios' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { usePlatformTemplates } from '../hooks/use-platform-templates/use-platform-templates' +import { ClusterProfileFeature } from './cluster-profile' + +jest.mock('../hooks/use-platform-templates/use-platform-templates') + +const mockUsePlatformTemplates = usePlatformTemplates as jest.MockedFunction +const mockTemplates = [ + { + layers: [ + { key: 'infrastructure', mandatory: false, enabledByDefault: false, components: [] }, + { + key: 'qovery-stack', + mandatory: true, + enabledByDefault: true, + components: [{ key: 'cluster-agent' }, { key: 'shell-agent' }, { key: 'qovery-priority-class' }], + }, + { + key: 'log-infra', + mandatory: true, + enabledByDefault: true, + components: [{ key: 'loki' }, { key: 'alloy' }], + }, + { + key: 'network', + mandatory: false, + enabledByDefault: true, + components: [{ key: 'envoy' }], + }, + ], + }, +] as unknown as PlatformTemplateSummaryResponse[] + +describe('ClusterProfileFeature', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUsePlatformTemplates.mockReturnValue({ + data: mockTemplates, + isError: false, + isLoading: false, + } as ReturnType) + }) + + it('renders the Loki configuration view', () => { + const { container } = renderWithProviders() + + expect(mockUsePlatformTemplates).toHaveBeenCalledWith({ organizationId: 'organization-id' }) + expect(screen.getByRole('banner')).not.toHaveClass('border-b') + expect(container.querySelectorAll('.fa-circle-check')).toHaveLength(1) + expect(container.querySelectorAll('.fa-circle-minus')).toHaveLength(1) + expect(screen.getByRole('heading', { name: 'Log infra' })).toBeInTheDocument() + expect( + screen.getByText('Collects logs from everything running on this cluster and makes them searchable in Qovery') + ).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Loki' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('spinbutton', { name: 'Retention period' })).toHaveValue(12) + expect(screen.getByText('Resource profile')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Restore all' })).toBeDisabled() + expect(screen.getAllByText('Loki').length).toBeGreaterThan(0) + expect(screen.getAllByText('Alloy').length).toBeGreaterThan(0) + }) + + it('allows the UI controls to be previewed locally', async () => { + const { userEvent } = renderWithProviders() + + expect(screen.getByRole('tab', { name: 'Loki' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('spinbutton', { name: 'Retention period' })).toHaveValue(12) + + const highAvailability = screen.getByRole('switch', { name: 'High availability' }) + expect(highAvailability).toBeChecked() + await userEvent.click(highAvailability) + expect(highAvailability).not.toBeChecked() + + const toggle = screen.getByRole('switch', { name: 'Enable log infrastructure' }) + expect(toggle).toBeChecked() + await userEvent.click(toggle) + expect(toggle).not.toBeChecked() + }) + + it('uses the URL-selected component as the active sidebar item', async () => { + const onActiveComponentChange = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + expect(screen.getByRole('button', { name: 'Log infra' })).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('button', { name: 'Alloy' })).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('tab', { name: 'Alloy' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('button', { name: 'Loki' })).not.toHaveAttribute('aria-current', 'page') + + await userEvent.click(screen.getByRole('tab', { name: 'Loki' })) + + expect(onActiveComponentChange).toHaveBeenCalledWith('loki') + }) + + it('filters the layer tree from the search input', async () => { + const { userEvent } = renderWithProviders() + const search = screen.getByRole('textbox', { name: 'Search layers' }) + + await userEvent.type(search, 'network') + + expect(screen.getByText('Network')).toBeInTheDocument() + expect(screen.queryByText('Qovery stack')).not.toBeInTheDocument() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx new file mode 100644 index 00000000000..bbf9cb87031 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx @@ -0,0 +1,448 @@ +import { type PlatformTemplateSummaryResponse } from 'qovery-typescript-axios' +import { type ReactNode, useState } from 'react' +import { IconEnum } from '@qovery/shared/enums' +import { Button, Heading, Icon, InputToggle } from '@qovery/shared/ui' +import { usePlatformTemplates } from '../hooks/use-platform-templates/use-platform-templates' + +export const ENGINE_V2_PLATFORM_CONFIGURATION_FEATURE_FLAG = 'engine-v2-platform-configuration' + +type ProfileTab = { + id: string + label: string + iconName: 'scroll' | 'code' +} + +type ConfigurationRowProps = { + label: string + description: string + children: ReactNode +} + +type ProfileTreeItem = { + id: string + label: string + status: 'none' | 'disabled' | 'dot' + state: 'disabled' | 'default' + children?: readonly { id: string; key: string; label: string }[] +} + +function formatProfileLabel(value: string) { + const label = value.replace(/[-_]+/g, ' ').trim().toLowerCase() + return label ? `${label[0].toUpperCase()}${label.slice(1)}` : value +} + +function getProfileTree(template: PlatformTemplateSummaryResponse | undefined): ProfileTreeItem[] { + return ( + template?.layers.map((layer) => { + const label = formatProfileLabel(layer.key) + const normalizedLabel = label.toLowerCase() + const isDisabled = normalizedLabel === 'infrastructure' || normalizedLabel === 'qovery stack' + + return { + id: layer.key, + label, + status: normalizedLabel === 'infrastructure' ? 'disabled' : normalizedLabel === 'gateway api' ? 'dot' : 'none', + state: isDisabled ? 'disabled' : 'default', + children: layer.components.map((component) => ({ + id: `${layer.key}/${component.key}`, + key: component.key, + label: formatProfileLabel(component.key), + })), + } + }) ?? [] + ) +} + +function getComponentIconName(componentKey: string): ProfileTab['iconName'] { + return componentKey.toLowerCase() === 'loki' ? 'scroll' : 'code' +} + +function findProfileComponent(profileTree: ProfileTreeItem[], requestedKey?: string) { + if (!requestedKey) return undefined + + return profileTree + .flatMap((item) => item.children ?? []) + .find((component) => component.key === requestedKey || component.id === requestedKey) +} + +function findProfileLayer(profileTree: ProfileTreeItem[], requestedKey?: string) { + if (!requestedKey) return undefined + + return profileTree.find((item) => item.id === requestedKey) +} + +function getDefaultProfileComponent(profileTree: ProfileTreeItem[]) { + return ( + profileTree.find((item) => item.label.toLowerCase() === 'log infra')?.children?.[0] ?? + profileTree.find((item) => item.children?.length)?.children?.[0] + ) +} + +const RESOURCE_ROWS = [ + { + label: 'Resource profile', + description: + 'How are CPU/memory budget applied to the active Loki workloads. CHART_DEFAULT will keep the chart behavior, while the presets apply Qovery’s versioned budgets.', + options: ['CHART_DEFAULT', 'SMALL', 'MEDIUM', 'LARGE'], + }, + { + label: 'Storage', + description: 'Storage backend used by Loki.', + options: ['PVC', 's3', 'gcs', 'azure'], + }, +] + +function ConfigurationRow({ label, description, children }: ConfigurationRowProps) { + return ( +
+
+

{label}

+

{description}

+
+
{children}
+
+ ) +} + +function ConfigurationSelect({ + label, + options, + value, + onChange, +}: { + label: string + options: string[] + value: string + onChange: (value: string) => void +}) { + return ( +
+ + +
+ ) +} + +function ResourceRows({ + values, + onChange, +}: { + values: Record + onChange: (label: string, value: string) => void +}) { + return ( +
+ {RESOURCE_ROWS.map((row) => ( + + onChange(row.label, value)} + /> + + ))} +
+ ) +} + +function LokiConfiguration({ + values, + onChange, +}: { + values: Record + onChange: (label: string, value: string) => void +}) { + const [retentionPeriod, setRetentionPeriod] = useState('12') + const [highAvailability, setHighAvailability] = useState(true) + + return ( +
+ + setRetentionPeriod(event.target.value)} + className="focus-visible:ring-brand-strong/30 h-10 w-full rounded border border-neutral bg-surface-neutral px-3 text-sm text-neutral outline-none transition-colors focus-visible:border-brand-strong focus-visible:ring-2" + /> + + + + + +
+ ) +} + +function TreeStatus({ status }: { status: ProfileTreeItem['status'] }) { + if (status === 'disabled') { + return + } + + if (status === 'dot') { + return + } + + return null +} + +type ClusterProfileFeatureProps = { + organizationId: string + activeComponentKey?: string + onActiveComponentChange?: (componentKey: string) => void +} + +export function ClusterProfileFeature({ + organizationId, + activeComponentKey: requestedComponentKey, + onActiveComponentChange, +}: ClusterProfileFeatureProps) { + const [search, setSearch] = useState('') + const [isEnabled, setIsEnabled] = useState(true) + const [values, setValues] = useState>({ + 'Resource profile': 'CHART_DEFAULT', + Storage: 'PVC', + }) + const { data: templates, isError, isLoading } = usePlatformTemplates({ organizationId }) + const profileTree = getProfileTree(templates?.[0]) + const requestedComponent = findProfileComponent(profileTree, requestedComponentKey) + const requestedLayer = findProfileLayer(profileTree, requestedComponentKey) + const defaultComponent = getDefaultProfileComponent(profileTree) + const activeLayer = + requestedLayer ?? + profileTree.find((item) => item.children?.some((child) => child.id === requestedComponent?.id)) ?? + profileTree.find((item) => item.children?.some((child) => child.id === defaultComponent?.id)) + const activeComponent = requestedComponent ?? activeLayer?.children?.[0] ?? defaultComponent + const profileTabs: ProfileTab[] = + activeLayer?.children?.map((component) => ({ + id: component.key, + label: component.label, + iconName: getComponentIconName(component.key), + })) ?? [] + + const normalizedSearch = search.trim().toLowerCase() + const visibleTree = normalizedSearch + ? profileTree.filter( + (item) => + item.label.toLowerCase().includes(normalizedSearch) || + item.children?.some((child) => child.label.toLowerCase().includes(normalizedSearch)) + ) + : profileTree + + const updateValue = (label: string, value: string) => { + setValues((currentValues) => ({ ...currentValues, [label]: value })) + } + + return ( +
+
+
+ +

Qovery infra engines prod static ip

+
+
+ + +
+
+ +
+ + +
+
+
+ + {activeLayer?.label ?? 'Log infra'} + +

+ {activeLayer?.label.toLowerCase() === 'log infra' + ? 'Collects logs from everything running on this cluster and makes them searchable in Qovery' + : 'Configure the components running on this cluster'} +

+
+
+ + +
+
+ +
+ {profileTabs.map((tab) => { + const isActive = tab.id === activeComponent?.key + return ( + + ) + })} +
+ +
+ {activeComponent?.key.toLowerCase() === 'loki' ? ( + + ) : ( + + )} +
+
+
+
+ ) +} diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-platform-templates/use-platform-templates.ts b/libs/domains/clusters/feature/src/lib/hooks/use-platform-templates/use-platform-templates.ts new file mode 100644 index 00000000000..df3fe1cc170 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/hooks/use-platform-templates/use-platform-templates.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query' +import { type PlatformCloudVendor, type PlatformClusterMode } from 'qovery-typescript-axios' +import { queries } from '@qovery/state/util-queries' + +interface UsePlatformTemplatesProps { + organizationId: string + clusterMode?: PlatformClusterMode + cloudProvider?: PlatformCloudVendor + enabled?: boolean +} + +export function usePlatformTemplates({ + organizationId, + clusterMode, + cloudProvider, + enabled = true, +}: UsePlatformTemplatesProps) { + return useQuery({ + ...queries.platformConfiguration.templates({ organizationId, clusterMode, cloudProvider }), + enabled: enabled && Boolean(organizationId), + }) +} diff --git a/libs/state/util-queries/src/lib/queries/queries.ts b/libs/state/util-queries/src/lib/queries/queries.ts index bc4a4eced70..6db64214083 100644 --- a/libs/state/util-queries/src/lib/queries/queries.ts +++ b/libs/state/util-queries/src/lib/queries/queries.ts @@ -1,6 +1,6 @@ import { type inferQueryKeyStore, mergeQueryKeys } from '@lukemorales/query-key-factory' import { cloudProviders } from '@qovery/domains/cloud-providers/data-access' -import { clusters } from '@qovery/domains/clusters/data-access' +import { clusters, platformConfiguration } from '@qovery/domains/clusters/data-access' import { customDomains } from '@qovery/domains/custom-domains/data-access' import { environments } from '@qovery/domains/environments/data-access' import { observability } from '@qovery/domains/observability/data-access' @@ -19,6 +19,7 @@ import { webflow } from '@qovery/shared/webflow/data-access' export const queries = mergeQueryKeys( cloudProviders, clusters, + platformConfiguration, environments, organizations, projects, From 7048e556973a94caeb0bba59bd745a89fb67cdbe Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 22 Sep 2026 17:16:14 +0200 Subject: [PATCH 03/21] fix(clusters): disable unavailable cluster profile controls - Remove unused cluster actions - Disable the log infrastructure toggle and update its test --- .../lib/cluster-profile/cluster-profile.spec.tsx | 5 +---- .../src/lib/cluster-profile/cluster-profile.tsx | 14 ++------------ 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx index 97620d092ef..9acf79c7003 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx @@ -72,10 +72,7 @@ describe('ClusterProfileFeature', () => { await userEvent.click(highAvailability) expect(highAvailability).not.toBeChecked() - const toggle = screen.getByRole('switch', { name: 'Enable log infrastructure' }) - expect(toggle).toBeChecked() - await userEvent.click(toggle) - expect(toggle).not.toBeChecked() + expect(screen.getByRole('switch', { name: 'Enable log infrastructure' })).toBeDisabled() }) it('uses the URL-selected component as the active sidebar item', async () => { diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx index bbf9cb87031..423492b6334 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx @@ -224,7 +224,6 @@ export function ClusterProfileFeature({ onActiveComponentChange, }: ClusterProfileFeatureProps) { const [search, setSearch] = useState('') - const [isEnabled, setIsEnabled] = useState(true) const [values, setValues] = useState>({ 'Resource profile': 'CHART_DEFAULT', Storage: 'PVC', @@ -267,9 +266,6 @@ export function ClusterProfileFeature({

Qovery infra engines prod static ip

- @@ -380,7 +376,7 @@ export function ClusterProfileFeature({
-
+
{activeLayer?.label ?? 'Log infra'} @@ -392,13 +388,7 @@ export function ClusterProfileFeature({

- +
@@ -425,10 +558,24 @@ export function ClusterProfileFeature({
- {activeComponent?.key.toLowerCase() === 'loki' ? ( - + {hasResolverError ? ( +
+ Configuration could not be checked. Refresh the page and try again. +
+ ) : isConfigurationLoading ? ( + ) : ( - + profileSections.map((section) => ( + + )) )}
diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-platform-templates/use-platform-templates.ts b/libs/domains/clusters/feature/src/lib/hooks/use-platform-templates/use-platform-templates.ts index df3fe1cc170..44606748ab5 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-platform-templates/use-platform-templates.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-platform-templates/use-platform-templates.ts @@ -7,6 +7,7 @@ interface UsePlatformTemplatesProps { clusterMode?: PlatformClusterMode cloudProvider?: PlatformCloudVendor enabled?: boolean + suspense?: boolean } export function usePlatformTemplates({ @@ -14,9 +15,11 @@ export function usePlatformTemplates({ clusterMode, cloudProvider, enabled = true, + suspense = false, }: UsePlatformTemplatesProps) { return useQuery({ ...queries.platformConfiguration.templates({ organizationId, clusterMode, cloudProvider }), enabled: enabled && Boolean(organizationId), + suspense, }) } diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-binding.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-binding.ts new file mode 100644 index 00000000000..4faed77643a --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-binding.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query' +import { queries } from '@qovery/state/util-queries' + +interface UsePlatformBindingProps { + organizationId: string + clusterId: string + enabled?: boolean + suspense?: boolean +} + +export function usePlatformBinding({ + organizationId, + clusterId, + enabled = true, + suspense = false, +}: UsePlatformBindingProps) { + return useQuery({ + ...queries.platformConfiguration.binding({ organizationId, clusterId }), + enabled: enabled && Boolean(organizationId) && Boolean(clusterId), + suspense, + }) +} diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-component-configuration.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-component-configuration.ts new file mode 100644 index 00000000000..00641a8fcc8 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-component-configuration.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' +import { type PlatformComponentConfigurationPreviewRequest } from 'qovery-typescript-axios' +import { queries } from '@qovery/state/util-queries' + +interface UsePlatformComponentConfigurationProps { + organizationId: string + clusterId: string + componentKey?: string + request: PlatformComponentConfigurationPreviewRequest + enabled?: boolean +} + +export function usePlatformComponentConfiguration({ + organizationId, + clusterId, + componentKey, + request, + enabled = true, +}: UsePlatformComponentConfigurationProps) { + return useQuery({ + ...queries.platformConfiguration.componentConfiguration({ + organizationId, + clusterId, + componentKey: componentKey ?? '', + request, + }), + enabled: enabled && Boolean(organizationId) && Boolean(clusterId) && Boolean(componentKey), + keepPreviousData: true, + }) +} diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-component-configurations.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-component-configurations.ts new file mode 100644 index 00000000000..5ea578bb2ea --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/hooks/use-platform-component-configurations.ts @@ -0,0 +1,32 @@ +import { useQueries } from '@tanstack/react-query' +import { type PlatformComponentConfigurationPreviewRequest } from 'qovery-typescript-axios' +import { queries } from '@qovery/state/util-queries' + +interface UsePlatformComponentConfigurationsProps { + organizationId: string + clusterId: string + requests: Record + enabled?: boolean +} + +export function usePlatformComponentConfigurations({ + organizationId, + clusterId, + requests, + enabled = true, +}: UsePlatformComponentConfigurationsProps) { + const componentKeys = Object.keys(requests) + + return useQueries({ + queries: componentKeys.map((componentKey) => ({ + ...queries.platformConfiguration.componentConfiguration({ + organizationId, + clusterId, + componentKey, + request: requests[componentKey], + }), + enabled: enabled && Boolean(organizationId) && Boolean(clusterId) && Boolean(componentKey), + keepPreviousData: true, + })), + }) +} diff --git a/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.ts b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.ts new file mode 100644 index 00000000000..e4cf0606484 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/platform-configuration/platform-configuration-utils.ts @@ -0,0 +1,211 @@ +import { + type CloudProviderEnum, + type CloudVendorEnum, + type ClusterPlatformBindingResponse, + type FieldSchemaConstraintsResponse, + type FieldSchemaResponse, + type KubernetesEnum, + type PlatformCloudVendor, + type PlatformClusterMode, + type PlatformComponentConfigurationResolutionResponse, + type PlatformComponentConfigurationViolationResponse, + type PlatformComponentInputRequirementResponse, + type PlatformTemplateSummaryResponse, +} from 'qovery-typescript-axios' +import { match } from 'ts-pattern' +import { type CatalogVariableField, type CatalogVariableValue, getCatalogVariableValue } from '@qovery/shared/util-js' + +export interface PlatformConfigurationDraft { + templateKey: string + templateVersion: string + layerSelections: Record + managedConfig: Record> + customerProvidedInputs: Record> +} + +export function toPlatformCloudVendor( + cloudProvider: CloudProviderEnum | CloudVendorEnum | undefined +): PlatformCloudVendor | undefined { + return match(cloudProvider) + .with(undefined, () => undefined) + .with('ON_PREMISE', () => 'UNKNOWN' as const) + .otherwise((provider) => provider) +} + +export function toPlatformClusterMode(kubernetes: KubernetesEnum | undefined): PlatformClusterMode | undefined { + return match(kubernetes) + .with('MANAGED', () => 'QOVERY_MANAGED' as const) + .with('SELF_MANAGED', () => 'CUSTOMER_MANAGED' as const) + .otherwise(() => undefined) +} + +export type PlatformScalarField = Extract + +export type PlatformFieldDescriptor = { + key: string + label: string + type: 'string' | 'number' | 'bool' + description?: string | null + sensitive: boolean + required: boolean + constraints: FieldSchemaConstraintsResponse + defaultValue?: string | null +} + +export function isPlatformScalarField(field: FieldSchemaResponse): field is PlatformScalarField { + return field.type === 'string' || field.type === 'number' || field.type === 'bool' +} + +export function getTemplateId(template: Pick) { + return `${template.key}@${template.version}` +} + +export function createPlatformConfigurationDraft( + template: PlatformTemplateSummaryResponse, + binding: ClusterPlatformBindingResponse | null | undefined +): PlatformConfigurationDraft { + const defaultLayerSelections = Object.fromEntries( + template.layers.flatMap((layer) => (layer.mandatory ? [] : [[layer.key, layer.enabledByDefault]])) + ) + const resolvedLayerSelections = Object.fromEntries( + template.layers.flatMap((layer) => { + if (layer.mandatory) return [] + + const status = binding?.layers.find((candidate) => candidate.key === layer.key)?.status + if (status === 'ENABLED') return [[layer.key, true]] + if (status === 'DISABLED') return [[layer.key, false]] + return [] + }) + ) + + if (binding && binding.templateKey === template.key && binding.templateVersion === template.version) { + return { + templateKey: binding.templateKey, + templateVersion: binding.templateVersion, + layerSelections: { ...defaultLayerSelections, ...binding.layerSelections, ...resolvedLayerSelections }, + managedConfig: binding.managedConfig, + customerProvidedInputs: binding.customerProvidedInputs, + } + } + + return { + templateKey: template.key, + templateVersion: template.version, + layerSelections: { ...defaultLayerSelections, ...resolvedLayerSelections }, + managedConfig: {}, + customerProvidedInputs: {}, + } +} + +export function findPlatformComponent(template: PlatformTemplateSummaryResponse, componentKey?: string) { + return template.layers.flatMap((layer) => layer.components).find((component) => component.key === componentKey) +} + +export function getCurrentPlatformConfigurationPreview( + preview: PlatformComponentConfigurationResolutionResponse | undefined, + componentKey: string | undefined, + isPreviewPending: boolean +) { + if (isPreviewPending || preview?.componentKey !== componentKey) return undefined + return preview +} + +export function toCatalogVariableField(field: PlatformFieldDescriptor): CatalogVariableField { + return { + key: field.key, + label: field.label, + type: field.type, + description: field.description ?? undefined, + required: field.required, + sensitive: field.sensitive, + defaultValue: field.defaultValue ?? undefined, + allowedValues: field.constraints.allowedValues ?? undefined, + pattern: field.constraints.pattern ?? undefined, + minLength: field.constraints.minLength ?? undefined, + maxLength: field.constraints.maxLength ?? undefined, + min: field.constraints.min ?? undefined, + max: field.constraints.max ?? undefined, + } +} + +export function applyPlatformConfigurationDefaults( + fields: FieldSchemaResponse[], + values: Record +): Record { + const defaultValues = Object.fromEntries( + fields.filter(isPlatformScalarField).flatMap((field) => { + const defaultValue = getCatalogVariableValue(field, undefined) + if (defaultValue === undefined) return [] + + return [[field.key, toPlatformConfigurationValue(field, defaultValue)]] + }) + ) + + return { ...defaultValues, ...values } +} + +export function toPlatformConfigurationValue( + field: Pick, + value: CatalogVariableValue +) { + if (field.type !== 'number') return value + if (typeof value !== 'string') return undefined + // Keep the empty string: it marks a field the user explicitly cleared, so + // applyPlatformConfigurationDefaults must not resurrect the schema default. + if (value.trim() === '') return '' + + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : value +} + +export function omitEmptyValues(values: Record): Record { + return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== '' && value !== undefined)) +} + +export function updateComponentValue( + valuesByComponent: Record>, + componentKey: string, + fieldKey: string, + value: T | undefined +) { + const componentValues = { ...valuesByComponent[componentKey] } + + if (value === undefined) { + delete componentValues[fieldKey] + } else { + componentValues[fieldKey] = value + } + + return { + ...valuesByComponent, + [componentKey]: componentValues, + } +} + +export function getFieldViolation( + violations: PlatformComponentConfigurationViolationResponse[], + fieldKey: string, + source?: 'clusterInputs' +) { + const expectedPath = source ? `${source}.${fieldKey}` : fieldKey + return violations.find((violation) => violation.fieldPath === expectedPath)?.message +} + +export function getUnmappedViolations( + violations: PlatformComponentConfigurationViolationResponse[], + fields: FieldSchemaResponse[], + requirements: PlatformComponentInputRequirementResponse[] +) { + const mappedPaths = new Set([ + ...fields.map((field) => field.key), + ...requirements.map((requirement) => `clusterInputs.${requirement.key}`), + ]) + return violations.filter((violation) => !mappedPaths.has(violation.fieldPath)) +} + +export function isPlatformConfigurationReady( + violations: PlatformComponentConfigurationViolationResponse[], + requirements: PlatformComponentInputRequirementResponse[] +) { + return violations.length === 0 && requirements.every((requirement) => requirement.status === 'READY') +} diff --git a/libs/shared/console-shared/src/index.ts b/libs/shared/console-shared/src/index.ts index c0f310b63d4..24e22bffc8b 100644 --- a/libs/shared/console-shared/src/index.ts +++ b/libs/shared/console-shared/src/index.ts @@ -1,5 +1,6 @@ // Please use util or domain libraries first, // then console-shared only as a last resort. +export * from './lib/catalog-variable-input/catalog-variable-input' export * from './lib/settings-heading/settings-heading' export * from './lib/live-elapsed-duration-cell/live-elapsed-duration-cell' export * from './lib/resource-tree-list/resource-tree-list' 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 new file mode 100644 index 00000000000..47c68b789fb --- /dev/null +++ b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx @@ -0,0 +1,94 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { CatalogVariableInput, type CatalogVariableInputProps } from './catalog-variable-input' + +const defaultProps: CatalogVariableInputProps = { + field: { + key: 'retention', + label: 'Retention', + type: 'number', + description: 'Retention period in weeks.', + }, + onChange: jest.fn(), + value: '12', +} + +describe('CatalogVariableInput', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('renders a number field with its description and validation error', () => { + renderWithProviders() + + expect(screen.getByRole('spinbutton', { name: 'Retention' })).toHaveValue(12) + expect(screen.getByText('Retention is invalid.')).toBeInTheDocument() + expect(screen.queryByText('Retention period in weeks.')).not.toBeInTheDocument() + }) + + it('renders sensitive values as passwords', () => { + renderWithProviders( + + ) + + expect(screen.getByLabelText('Token')).toHaveAttribute('type', 'password') + }) + + it('keeps the toggle presentation as the default for boolean fields', async () => { + const onChange = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('switch', { name: 'Enabled' })) + + expect(onChange).toHaveBeenCalledWith(true) + expect(screen.getByText('Enable this option.')).toBeInTheDocument() + }) + + it('renders a boolean control even when the field declares allowed values', async () => { + const onChange = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('switch', { name: 'Enabled' })) + + expect(onChange).toHaveBeenCalledWith(false) + }) + + it('supports a checkbox presentation for boolean fields', async () => { + const onChange = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('checkbox', { name: 'Enabled' })) + + expect(onChange).toHaveBeenCalledWith(true) + expect(screen.getByText('Enable this option.')).toBeInTheDocument() + }) +}) 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 new file mode 100644 index 00000000000..1793a2b5de0 --- /dev/null +++ b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.tsx @@ -0,0 +1,97 @@ +import { Checkbox, InputSelect, InputText, InputToggle } from '@qovery/shared/ui' +import { type CatalogVariableField, type CatalogVariableValue } from '@qovery/shared/util-js' + +export interface CatalogVariableInputProps { + autoFocus?: boolean + booleanControl?: 'checkbox' | 'toggle' + error?: string + field: CatalogVariableField + onChange: (value: CatalogVariableValue) => void + value: CatalogVariableValue | undefined +} + +export function CatalogVariableInput({ + autoFocus, + booleanControl = 'toggle', + error, + field, + onChange, + value, +}: CatalogVariableInputProps) { + // Bool fields take precedence over allowedValues: a bool field carrying allowed + // values must keep emitting booleans, not the allowed-value strings. + if (field.type === 'bool') { + if (booleanControl === 'toggle') { + return ( +
+ + {error ?

{error}

: null} +
+ ) + } + + return ( +
+
+ { + if (checked === 'indeterminate') return + onChange(checked) + }} + /> + +
+ {field.description ? ( +

{field.description}

+ ) : null} + {error ?

{error}

: null} +
+ ) + } + + if (field.allowedValues?.length) { + return ( + ({ label: allowedValue, value: allowedValue }))} + hint={field.description} + error={error} + autoFocus={autoFocus} + onChange={(value) => { + if (Array.isArray(value)) return + onChange(value) + }} + /> + ) + } + + return ( + onChange(event.currentTarget.value)} + /> + ) +} diff --git a/libs/shared/util-js/src/index.ts b/libs/shared/util-js/src/index.ts index bed190b8758..c5e8162ac1f 100644 --- a/libs/shared/util-js/src/index.ts +++ b/libs/shared/util-js/src/index.ts @@ -4,6 +4,7 @@ export * from './lib/container-registry-kind-to-icon' export * from './lib/convert-memory-size' export * from './lib/cost-to-human' export * from './lib/custom-tw-merge' +export * from './lib/catalog-variable-field' export * from './lib/environment-variable-file' export * from './lib/environment-variable-name' export * from './lib/format-metric' diff --git a/libs/shared/util-js/src/lib/catalog-variable-field.spec.ts b/libs/shared/util-js/src/lib/catalog-variable-field.spec.ts new file mode 100644 index 00000000000..d56f730cfe9 --- /dev/null +++ b/libs/shared/util-js/src/lib/catalog-variable-field.spec.ts @@ -0,0 +1,109 @@ +import { + formatCatalogKey, + getCatalogBooleanValue, + getCatalogFieldLengthValidationError, + getCatalogFieldNumberValidationError, + getCatalogFieldValidationError, + getCatalogStringValue, + getCatalogSummaryFieldValue, + getCatalogVariableValue, + isCatalogFieldValid, + isCatalogFieldValueFulfilled, + isCatalogFieldValueMatchingPattern, +} from './catalog-variable-field' + +describe('formatCatalogKey', () => { + it('formats snake_case and kebab-case keys into a readable label', () => { + expect(formatCatalogKey('database_name')).toBe('Database name') + expect(formatCatalogKey('log-storage')).toBe('Log storage') + expect(formatCatalogKey('CUSTOMER_MANAGED')).toBe('CUSTOMER MANAGED') + }) +}) + +describe('catalog value coercion', () => { + it('returns string and boolean values with safe fallbacks', () => { + expect(getCatalogStringValue('value')).toBe('value') + expect(getCatalogStringValue(true)).toBe('') + expect(getCatalogStringValue(undefined)).toBe('') + expect(getCatalogBooleanValue(true)).toBe(true) + expect(getCatalogBooleanValue('value')).toBe(false) + expect(getCatalogBooleanValue(undefined)).toBe(false) + }) + + it('resolves values against the field default without losing their type', () => { + expect(getCatalogVariableValue({ type: 'number', defaultValue: '12' }, undefined)).toBe('12') + expect(getCatalogVariableValue({ type: 'number', defaultValue: '12' }, 24)).toBe('24') + expect(getCatalogVariableValue({ type: 'bool', defaultValue: 'true' }, undefined)).toBe(true) + expect(getCatalogVariableValue({ type: 'bool' }, 'false')).toBe(false) + expect(getCatalogVariableValue({ type: 'bool' }, undefined)).toBeUndefined() + expect(getCatalogVariableValue({ type: 'string', defaultValue: null }, undefined)).toBeUndefined() + expect(getCatalogVariableValue({ type: 'string' }, { unexpected: true })).toBeUndefined() + }) +}) + +describe('catalog field validation', () => { + it('treats booleans and non-blank strings as fulfilled', () => { + expect(isCatalogFieldValueFulfilled(false)).toBe(true) + expect(isCatalogFieldValueFulfilled('value')).toBe(true) + expect(isCatalogFieldValueFulfilled(' ')).toBe(false) + expect(isCatalogFieldValueFulfilled(undefined)).toBe(false) + }) + + it('matches values against the field pattern and ignores broken patterns', () => { + expect(isCatalogFieldValueMatchingPattern({ pattern: '^[a-z]+$' }, 'value')).toBe(true) + expect(isCatalogFieldValueMatchingPattern({ pattern: '^[a-z]+$' }, 'Value')).toBe(false) + expect(isCatalogFieldValueMatchingPattern({ pattern: '(' }, 'value')).toBe(true) + expect(isCatalogFieldValueMatchingPattern({}, 'value')).toBe(true) + expect(isCatalogFieldValueMatchingPattern({ pattern: '^[a-z]+$' }, undefined)).toBe(true) + }) + + it('reports length violations with a dedicated message', () => { + expect(getCatalogFieldLengthValidationError({ minLength: 2, maxLength: 4 }, 'value')).toBe( + 'Value must be between 2 and 4 characters.' + ) + expect(getCatalogFieldLengthValidationError({ minLength: 8 }, 'value')).toBe('Value must be at least 8 characters.') + expect(getCatalogFieldLengthValidationError({ maxLength: 3 }, 'value')).toBe('Value must be at most 3 characters.') + expect(getCatalogFieldLengthValidationError({ minLength: 2, maxLength: 8 }, 'value')).toBeUndefined() + expect(getCatalogFieldLengthValidationError({ minLength: 2 }, undefined)).toBeUndefined() + }) + + it('reports numeric violations with a dedicated message', () => { + expect(getCatalogFieldNumberValidationError({ type: 'number', min: 20, max: 65536 }, '19')).toBe( + 'Value must be between 20 and 65536.' + ) + expect(getCatalogFieldNumberValidationError({ type: 'number', min: 20 }, '19')).toBe('Value must be at least 20.') + expect(getCatalogFieldNumberValidationError({ type: 'number', max: 65536 }, '65537')).toBe( + 'Value must be at most 65536.' + ) + expect(getCatalogFieldNumberValidationError({ type: 'number' }, 'not-a-number')).toBe('Value must be a number.') + expect(getCatalogFieldNumberValidationError({ type: 'number', min: 20, max: 65536 }, '20')).toBeUndefined() + expect(getCatalogFieldNumberValidationError({ type: 'string', min: 20 }, '19')).toBeUndefined() + }) + + it('combines length and pattern checks into one validation error', () => { + expect(getCatalogFieldValidationError({ type: 'string', maxLength: 3, pattern: '^[a-z]+$' }, 'value')).toBe( + 'Value must be at most 3 characters.' + ) + expect(getCatalogFieldValidationError({ type: 'string', pattern: '^[a-z]+$' }, 'Value')).toBe( + 'Value does not match the expected format.' + ) + expect(getCatalogFieldValidationError({ type: 'string', pattern: '^[a-z]+$' }, 'value')).toBeUndefined() + }) + + it('validates required state and constraints together', () => { + expect(isCatalogFieldValid({ type: 'string', required: true }, undefined)).toBe(false) + expect(isCatalogFieldValid({ type: 'string', required: true }, 'value')).toBe(true) + expect(isCatalogFieldValid({ type: 'string', required: false, pattern: '^[a-z]+$' }, 'Value')).toBe(false) + expect(isCatalogFieldValid({ type: 'string', required: false }, undefined)).toBe(true) + }) +}) + +describe('getCatalogSummaryFieldValue', () => { + it('renders booleans as enabled state and masks sensitive values', () => { + expect(getCatalogSummaryFieldValue({}, true)).toBe('Enabled') + expect(getCatalogSummaryFieldValue({}, false)).toBe('Disabled') + expect(getCatalogSummaryFieldValue({ sensitive: true }, 'secret')).toBe('••••••••') + expect(getCatalogSummaryFieldValue({ sensitive: true }, '')).toBe('') + expect(getCatalogSummaryFieldValue({ sensitive: false }, 'value')).toBe('value') + }) +}) diff --git a/libs/shared/util-js/src/lib/catalog-variable-field.ts b/libs/shared/util-js/src/lib/catalog-variable-field.ts new file mode 100644 index 00000000000..1e5605071b1 --- /dev/null +++ b/libs/shared/util-js/src/lib/catalog-variable-field.ts @@ -0,0 +1,139 @@ +export type CatalogVariableValue = string | boolean + +export type CatalogVariableFieldType = 'string' | 'number' | 'bool' + +export interface CatalogVariableField { + key: string + label: string + type: CatalogVariableFieldType + description?: string + required?: boolean + sensitive?: boolean + defaultValue?: string + allowedValues?: string[] + pattern?: string + minLength?: number + maxLength?: number + min?: number + max?: number +} + +export function formatCatalogKey(key: string) { + const label = key.replace(/[-_]/g, ' ') + return `${label.charAt(0).toUpperCase()}${label.slice(1)}` +} + +export function getCatalogStringValue(value: CatalogVariableValue | undefined) { + return typeof value === 'string' ? value : '' +} + +export function getCatalogBooleanValue(value: CatalogVariableValue | undefined) { + return typeof value === 'boolean' ? value : false +} + +export function getCatalogVariableValue( + field: Pick & { defaultValue?: string | null }, + value: unknown +): CatalogVariableValue | undefined { + const resolvedValue = value ?? field.defaultValue + if (field.type === 'bool') { + if (typeof resolvedValue === 'boolean') return resolvedValue + if (typeof resolvedValue === 'string') return resolvedValue === 'true' + return undefined + } + + if (typeof resolvedValue === 'string' || typeof resolvedValue === 'number') return String(resolvedValue) + return undefined +} + +export function isCatalogFieldValueFulfilled(value: CatalogVariableValue | undefined) { + if (typeof value === 'boolean') return true + return Boolean(value?.trim()) +} + +export function isCatalogFieldValueMatchingPattern( + field: Pick, + value: CatalogVariableValue | undefined +) { + if (typeof value !== 'string' || !value || !field.pattern) return true + + try { + return new RegExp(field.pattern).test(value) + } catch { + return true + } +} + +export function getCatalogFieldLengthValidationError( + field: Pick, + value: CatalogVariableValue | undefined +) { + if (typeof value !== 'string' || !value) return undefined + + const { minLength, maxLength } = field + const hasMinLength = typeof minLength === 'number' + const hasMaxLength = typeof maxLength === 'number' + + if (hasMinLength && hasMaxLength && (value.length < minLength || value.length > maxLength)) { + return `Value must be between ${minLength} and ${maxLength} characters.` + } + + if (hasMinLength && value.length < minLength) return `Value must be at least ${minLength} characters.` + if (hasMaxLength && value.length > maxLength) return `Value must be at most ${maxLength} characters.` + + return undefined +} + +export function getCatalogFieldNumberValidationError( + field: Pick, + value: CatalogVariableValue | undefined +) { + if (field.type !== 'number' || typeof value !== 'string' || !value.trim()) return undefined + + const numberValue = Number(value) + if (!Number.isFinite(numberValue)) return 'Value must be a number.' + + const { min, max } = field + const hasMin = typeof min === 'number' + const hasMax = typeof max === 'number' + + if (hasMin && hasMax && (numberValue < min || numberValue > max)) { + return `Value must be between ${min} and ${max}.` + } + + if (hasMin && numberValue < min) return `Value must be at least ${min}.` + if (hasMax && numberValue > max) return `Value must be at most ${max}.` + + return undefined +} + +export function getCatalogFieldValidationError( + field: Pick, + value: CatalogVariableValue | undefined +) { + const numberValidationError = getCatalogFieldNumberValidationError(field, value) + if (numberValidationError) return numberValidationError + + const lengthValidationError = getCatalogFieldLengthValidationError(field, value) + if (lengthValidationError) return lengthValidationError + + if (!isCatalogFieldValueMatchingPattern(field, value)) return 'Value does not match the expected format.' + return undefined +} + +export function isCatalogFieldValid( + field: Pick, + value: CatalogVariableValue | undefined +) { + if (field.required && !isCatalogFieldValueFulfilled(value)) return false + return !getCatalogFieldValidationError(field, value) +} + +export function getCatalogSummaryFieldValue( + field: Pick, + value: CatalogVariableValue | undefined +) { + if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled' + if (field.sensitive && value) return '••••••••' + return value +} From 491632f2f6ebf97c840a12a383b4759f32474bd8 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 23 Sep 2026 10:20:46 +0200 Subject: [PATCH 05/21] feat(clusters): add the cluster profile layer sidebar - Add searchable, selectable layer and component navigation with status indicators - Reuse the shared search input and expose its accessible label --- .../public/assets/sidebar-test/layer.svg | 10 + .../cluster-profile-sidebar.tsx | 226 ++++++++++++++++++ .../cluster-profile/cluster-profile.spec.tsx | 4 +- .../lib/cluster-profile/cluster-profile.tsx | 165 +++---------- .../inputs/input-search/input-search.tsx | 3 + 5 files changed, 279 insertions(+), 129 deletions(-) create mode 100644 apps/console/public/assets/sidebar-test/layer.svg create mode 100644 libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx diff --git a/apps/console/public/assets/sidebar-test/layer.svg b/apps/console/public/assets/sidebar-test/layer.svg new file mode 100644 index 00000000000..1c776882883 --- /dev/null +++ b/apps/console/public/assets/sidebar-test/layer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx new file mode 100644 index 00000000000..20f77e81864 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx @@ -0,0 +1,226 @@ +import { IconEnum } from '@qovery/shared/enums' +import { Icon, InputSearch, Tooltip } from '@qovery/shared/ui' + +type LayerStatus = 'disabled' | 'success' | 'warning' + +export interface ClusterProfileSidebarItem { + id: string + key: string + label: string +} + +export interface ClusterProfileSidebarLayer { + id: string + label: string + status: LayerStatus + items: readonly ClusterProfileSidebarItem[] +} + +interface LayerSectionRowProps { + section: ClusterProfileSidebarLayer + selectedSectionId?: string + selectedItemId?: string + onSelectSection: (sectionId: string) => void + onSelectItem: (itemId: string) => void +} + +function LayerIcon({ className = '' }: { className?: string }) { + return ( +