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/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..81a2975a5e2 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile.tsx @@ -0,0 +1,44 @@ +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(), + search: z.string().optional(), + }), +}) + +function RouteComponent() { + useDocumentTitle('Cluster - Profile') + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const { component, search } = Route.useSearch() + const navigate = Route.useNavigate() + const isProfileEnabled = Boolean(useFeatureFlagEnabled(ENGINE_V2_PLATFORM_CONFIGURATION_FEATURE_FLAG)) + + if (!isProfileEnabled && organizationId && clusterId) { + return ( + + ) + } + + return ( + + navigate({ search: (previous) => ({ ...previous, component: nextComponent }) }) + } + onSearchChange={(nextSearch) => + navigate({ search: (previous) => ({ ...previous, search: nextSearch || undefined }), replace: true }) + } + /> + ) +} diff --git a/apps/console/src/routes/_authenticated/organization/route.tsx b/apps/console/src/routes/_authenticated/organization/route.tsx index 9e421016edf..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' @@ -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', @@ -335,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, @@ -374,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, @@ -472,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..3a75ad8d8f5 100644 --- a/libs/domains/clusters/data-access/src/index.ts +++ b/libs/domains/clusters/data-access/src/index.ts @@ -1,4 +1,6 @@ export * from './lib/domains-clusters-data-access' +export * from './lib/http/is-http-status' +export * from './lib/platform-configuration/platform-configuration' export { isGcpCluster } from './lib/cluster-checks/is-gcp-cluster' export { isAwsCluster } from './lib/cluster-checks/is-aws-cluster' export { excludeSecretManagerAccess } from './lib/secret-manager/exclude-secret-manager-access' diff --git a/libs/domains/clusters/data-access/src/lib/http/is-http-status.ts b/libs/domains/clusters/data-access/src/lib/http/is-http-status.ts new file mode 100644 index 00000000000..8aa634a76a6 --- /dev/null +++ b/libs/domains/clusters/data-access/src/lib/http/is-http-status.ts @@ -0,0 +1,14 @@ +import { isAxiosError } from 'axios' + +export function isHttpStatus(error: unknown, status: number) { + if (isAxiosError(error)) return error.response?.status === status + return Boolean( + error && + typeof error === 'object' && + 'response' in error && + error.response && + typeof error.response === 'object' && + 'status' in error.response && + error.response.status === status + ) +} 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..826dedb9bc4 --- /dev/null +++ b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts @@ -0,0 +1,82 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory' +import { + type ClusterPlatformBindingRequest, + type PlatformCloudVendor, + type PlatformClusterMode, + type PlatformComponentConfigurationPreviewRequest, + PlatformConfigurationApi, +} from 'qovery-typescript-axios' +import { isHttpStatus } from '../http/is-http-status' + +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 + }, + }), + binding: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({ + queryKey: [organizationId, clusterId], + async queryFn() { + try { + const response = await platformConfigurationApi.getClusterPlatformBinding(organizationId, clusterId) + return response.data + } catch (error) { + if (isHttpStatus(error, 404)) return null + throw error + } + }, + }), + componentConfiguration: ({ + organizationId, + clusterId, + componentKey, + request, + }: { + organizationId: string + clusterId: string + componentKey: string + request: PlatformComponentConfigurationPreviewRequest + }) => ({ + queryKey: [organizationId, clusterId, componentKey, request], + async queryFn() { + const response = await platformConfigurationApi.resolvePlatformComponentConfiguration( + organizationId, + clusterId, + componentKey, + request + ) + return response.data + }, + }), +}) + +export const platformConfigurationMutations = { + async updateBinding({ + organizationId, + clusterId, + bindingRequest, + }: { + organizationId: string + clusterId: string + bindingRequest: ClusterPlatformBindingRequest + }) { + const response = await platformConfigurationApi.updateClusterPlatformBinding( + organizationId, + clusterId, + bindingRequest + ) + return response.data + }, +} diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 129cf64613a..64648e5b77f 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,10 @@ 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/platform-configuration/hooks/use-platform-binding' +export * from './lib/platform-configuration/hooks/use-platform-component-configuration' +export * from './lib/platform-configuration/hooks/use-platform-component-configurations' 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-context.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-context.tsx new file mode 100644 index 00000000000..914818e4f8f --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-context.tsx @@ -0,0 +1,234 @@ +import { useParams } from '@tanstack/react-router' +import equal from 'fast-deep-equal' +import { + type Cluster, + type ClusterPlatformBindingRequest, + type ClusterPlatformBindingResponse, + type PlatformTemplateSummaryResponse, +} from 'qovery-typescript-axios' +import { type PropsWithChildren, createContext, useContext, useMemo, useState } from 'react' +import { toast } from '@qovery/shared/ui' +import { type CatalogVariableValue, getCatalogVariableValue } from '@qovery/shared/util-js' +import { useCluster } from '../hooks/use-cluster/use-cluster' +import { useDeployCluster } from '../hooks/use-deploy-cluster/use-deploy-cluster' +import { usePlatformTemplates } from '../hooks/use-platform-templates/use-platform-templates' +import { usePlatformBinding } from '../platform-configuration/hooks/use-platform-binding' +import { useUpdatePlatformBinding } from '../platform-configuration/hooks/use-update-platform-binding' +import { + type PlatformFieldDescriptor, + type PlatformScalarField, + applyPlatformConfigurationDefaults, + isPlatformScalarField, + omitEmptyValues, + toPlatformCloudVendor, + toPlatformClusterMode, + updateComponentValue, +} from '../platform-configuration/platform-configuration-utils' +import { type ProfileTreeItem, getProfileTree } from './profile-tree' + +export type ProfileValues = Record> +export type ClusterInputValues = Record> + +// Scalars are compared as the inputs display them: an unset toggle shows as off, an unset text as empty. +function getDisplayedScalarValue(field: PlatformScalarField, value: unknown) { + return getCatalogVariableValue(field, value) ?? (field.type === 'bool' ? false : '') +} + +// Local edits differing from the saved (or default) value; edits reverted by hand do not count. +function countProfileChanges( + profileTree: ProfileTreeItem[], + binding: ClusterPlatformBindingResponse | null | undefined, + profileValues: ProfileValues, + clusterInputs: ClusterInputValues +) { + const fieldsByComponent = new Map( + profileTree.flatMap((layer) => layer.children).map((component) => [component.key, component.fields]) + ) + const profileChanges = Object.entries(profileValues).flatMap(([componentKey, values]) => { + const fields = fieldsByComponent.get(componentKey) ?? [] + const savedValues = applyPlatformConfigurationDefaults(fields, binding?.managedConfig?.[componentKey] ?? {}) + return Object.entries(values).filter(([fieldKey, value]) => { + const field = fields.find(({ key }) => key === fieldKey) + return field && isPlatformScalarField(field) + ? getDisplayedScalarValue(field, value) !== getDisplayedScalarValue(field, savedValues[fieldKey]) + : !equal(value, savedValues[fieldKey]) + }) + }) + const clusterInputChanges = Object.entries(clusterInputs).flatMap(([componentKey, values]) => + Object.entries(values).filter( + ([inputKey, value]) => value !== (binding?.customerProvidedInputs?.[componentKey]?.[inputKey] ?? '') + ) + ) + + return profileChanges.length + clusterInputChanges.length +} + +function getBindingRequest( + template: PlatformTemplateSummaryResponse, + binding: ClusterPlatformBindingResponse | null | undefined, + profileValues: ProfileValues, + clusterInputs: ClusterInputValues +): ClusterPlatformBindingRequest { + const managedConfig = { ...binding?.managedConfig } + for (const [componentKey, values] of Object.entries(profileValues)) { + managedConfig[componentKey] = omitEmptyValues({ ...binding?.managedConfig?.[componentKey], ...values }) + } + const customerProvidedInputs = { ...binding?.customerProvidedInputs } + for (const [componentKey, values] of Object.entries(clusterInputs)) { + customerProvidedInputs[componentKey] = { ...binding?.customerProvidedInputs?.[componentKey], ...values } + } + + return { + templateKey: binding?.templateKey ?? template.key, + templateVersion: binding?.templateVersion ?? template.version, + layerSelections: binding?.layerSelections, + managedConfig, + customerProvidedInputs, + } +} + +interface ClusterProfileContextValue { + organizationId: string + clusterId: string + cluster?: Cluster + templates?: PlatformTemplateSummaryResponse[] + binding?: ClusterPlatformBindingResponse | null + profileTree: ProfileTreeItem[] + isLoading: boolean + isError: boolean + // Unsaved edits, keyed by component then field. + profileValues: ProfileValues + clusterInputs: ClusterInputValues + // Changes on reset: some inputs are uncontrolled, remounting the form shows the saved values again. + formKey: number + changeCount: number + isSaving: boolean + isDeploying: boolean + updateProfileConfig: (componentKey: string, fieldKey: string, value: unknown) => void + updateClusterInput: (componentKey: string, field: PlatformFieldDescriptor, value: CatalogVariableValue) => void + resetChanges: () => void + saveChanges: () => Promise + saveAndDeployChanges: () => Promise +} + +const ClusterProfileContext = createContext(undefined) + +export function ClusterProfileProvider({ children }: PropsWithChildren) { + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const [profileValues, setProfileValues] = useState({}) + const [clusterInputs, setClusterInputs] = useState({}) + const [formKey, setFormKey] = useState(0) + const [isSaveAndDeployPending, setIsSaveAndDeployPending] = useState(false) + const { mutateAsync: updatePlatformBinding, isLoading: isSavingBinding } = useUpdatePlatformBinding() + const { mutateAsync: deployCluster } = useDeployCluster() + const { + data: cluster, + isError: isClusterError, + isLoading: isClusterLoading, + } = useCluster({ + organizationId, + clusterId, + }) + const clusterMode = toPlatformClusterMode(cluster?.kubernetes) + const cloudProvider = toPlatformCloudVendor(cluster?.cloud_provider) + const { + data: templates, + isError: isTemplateError, + isLoading: isTemplateLoading, + } = usePlatformTemplates({ + organizationId, + clusterMode, + cloudProvider, + enabled: Boolean(clusterMode && cloudProvider), + }) + const { + data: binding, + isError: isBindingError, + isLoading: isBindingLoading, + } = usePlatformBinding({ organizationId, clusterId }) + const selectedTemplate = useMemo( + () => + templates?.find( + (template) => template.key === binding?.templateKey && template.version === binding?.templateVersion + ) ?? templates?.[0], + [binding?.templateKey, binding?.templateVersion, templates] + ) + const profileTree = useMemo(() => getProfileTree(selectedTemplate), [selectedTemplate]) + + const updateProfileConfig = (componentKey: string, fieldKey: string, value: unknown) => { + setProfileValues((currentValues) => updateComponentValue(currentValues, componentKey, fieldKey, value)) + } + + const updateClusterInput = (componentKey: string, field: PlatformFieldDescriptor, value: CatalogVariableValue) => { + setClusterInputs((currentValues) => updateComponentValue(currentValues, componentKey, field.key, String(value))) + } + + const resetChanges = () => { + setProfileValues({}) + setClusterInputs({}) + setFormKey((key) => key + 1) + } + + const saveBinding = async () => { + if (!selectedTemplate) return + await updatePlatformBinding({ + organizationId, + clusterId, + bindingRequest: getBindingRequest(selectedTemplate, binding, profileValues, clusterInputs), + }) + // The saved binding now carries the edits, so the local copies can go. + setProfileValues({}) + setClusterInputs({}) + } + + const saveChanges = async () => { + try { + await saveBinding() + toast('success', 'Profile saved', 'Deploy the cluster to apply the changes.') + } catch { + // Errors are notified by the mutation. + } + } + + const saveAndDeployChanges = async () => { + setIsSaveAndDeployPending(true) + try { + await saveBinding() + await deployCluster({ organizationId, clusterId }) + } catch { + // Errors are notified by the mutations. + } finally { + setIsSaveAndDeployPending(false) + } + } + + const value: ClusterProfileContextValue = { + organizationId, + clusterId, + cluster, + templates, + binding, + profileTree, + isLoading: isClusterLoading || isTemplateLoading || isBindingLoading, + isError: isClusterError || isTemplateError || isBindingError, + profileValues, + clusterInputs, + formKey, + changeCount: countProfileChanges(profileTree, binding, profileValues, clusterInputs), + isSaving: isSavingBinding && !isSaveAndDeployPending, + isDeploying: isSaveAndDeployPending, + updateProfileConfig, + updateClusterInput, + resetChanges, + saveChanges, + saveAndDeployChanges, + } + + return {children} +} + +export function useClusterProfileContext() { + const context = useContext(ClusterProfileContext) + if (!context) throw new Error('useClusterProfileContext must be used within a ClusterProfileProvider') + return context +} 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..11c00ee646b --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx @@ -0,0 +1,202 @@ +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 ( +