diff --git a/apps/console/src/routeTree.gen.ts b/apps/console/src/routeTree.gen.ts index 3ad1beaacba..6a7e6a19f53 100644 --- a/apps/console/src/routeTree.gen.ts +++ b/apps/console/src/routeTree.gen.ts @@ -70,10 +70,12 @@ import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdCloudSh import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsRouteRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/settings/route' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/route' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/route' +import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/settings/index' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/deployment-rules/index' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/index' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/index' +import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/index' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/index' import { Route as AuthenticatedOrganizationOrganizationIdSettingsRolesEditRoleIdRouteImport } from './routes/_authenticated/organization/$organizationId/settings/roles/edit/$roleId' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsGeneralRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/settings/general' @@ -96,6 +98,7 @@ import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSetting import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons' +import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/index' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployments' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdAutomationRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/automation' @@ -648,6 +651,14 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute = + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteImport.update( + { + id: '/cluster/$clusterId/profile', + path: '/cluster/$clusterId/profile', + getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsIndexRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsIndexRouteImport.update( { @@ -683,6 +694,15 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute = + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRouteImport.update( + { + id: '/', + path: '/', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRoute = AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRouteImport.update( { @@ -879,6 +899,15 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute = + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRouteImport.update( + { + id: '/$componentKey', + path: '/$componentKey', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRouteImport.update( { @@ -1974,6 +2003,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/$clusterId': typeof AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute '/organization/$organizationId/alerts/': typeof AuthenticatedOrganizationOrganizationIdAlertsIndexRoute '/organization/$organizationId/settings/': typeof AuthenticatedOrganizationOrganizationIdSettingsIndexRoute + '/organization/$organizationId/cluster/$clusterId/profile': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteWithChildren '/organization/$organizationId/cluster/$clusterId/settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteWithChildren '/organization/$organizationId/cluster/create/$slug': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteWithChildren '/organization/$organizationId/project/$projectId/settings': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsRouteRouteWithChildren @@ -1989,6 +2019,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/organization/$organizationId/settings/agents/': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute '/organization/$organizationId/settings/roles': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute + '/organization/$organizationId/cluster/$clusterId/profile/$componentKey': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute '/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute @@ -2011,6 +2042,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId/settings/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsGeneralRoute '/organization/$organizationId/settings/roles/edit/$roleId': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesEditRoleIdRoute '/organization/$organizationId/cluster/$clusterId/deployments': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRoute + '/organization/$organizationId/cluster/$clusterId/profile/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute '/organization/$organizationId/cluster/$clusterId/settings/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute '/organization/$organizationId/cluster/create/$slug/': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRoute '/organization/$organizationId/project/$projectId/deployment-rules': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesIndexRoute @@ -2189,6 +2221,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/organization/$organizationId/settings/agents': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute '/organization/$organizationId/settings/roles': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute + '/organization/$organizationId/cluster/$clusterId/profile/$componentKey': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute '/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute @@ -2211,6 +2244,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId/settings/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsGeneralRoute '/organization/$organizationId/settings/roles/edit/$roleId': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesEditRoleIdRoute '/organization/$organizationId/cluster/$clusterId/deployments': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRoute + '/organization/$organizationId/cluster/$clusterId/profile': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute '/organization/$organizationId/cluster/$clusterId/settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute '/organization/$organizationId/cluster/create/$slug': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRoute '/organization/$organizationId/project/$projectId/deployment-rules': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesIndexRoute @@ -2369,6 +2403,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/$clusterId/': typeof AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute '/_authenticated/organization/$organizationId/alerts/': typeof AuthenticatedOrganizationOrganizationIdAlertsIndexRoute '/_authenticated/organization/$organizationId/settings/': typeof AuthenticatedOrganizationOrganizationIdSettingsIndexRoute + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteWithChildren '/_authenticated/organization/$organizationId/cluster/$clusterId/settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteWithChildren '/_authenticated/organization/$organizationId/cluster/create/$slug': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteWithChildren '/_authenticated/organization/$organizationId/project/$projectId/settings': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsRouteRouteWithChildren @@ -2384,6 +2419,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/_authenticated/organization/$organizationId/settings/agents/': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute '/_authenticated/organization/$organizationId/settings/roles/': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute @@ -2406,6 +2442,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/settings/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsGeneralRoute '/_authenticated/organization/$organizationId/settings/roles/edit/$roleId': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesEditRoleIdRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRoute + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRoute '/_authenticated/organization/$organizationId/project/$projectId/deployment-rules/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesIndexRoute @@ -2579,6 +2616,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/$clusterId' | '/organization/$organizationId/alerts/' | '/organization/$organizationId/settings/' + | '/organization/$organizationId/cluster/$clusterId/profile' | '/organization/$organizationId/cluster/$clusterId/settings' | '/organization/$organizationId/cluster/create/$slug' | '/organization/$organizationId/project/$projectId/settings' @@ -2594,6 +2632,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId' | '/organization/$organizationId/settings/agents/' | '/organization/$organizationId/settings/roles' + | '/organization/$organizationId/cluster/$clusterId/profile/$componentKey' | '/organization/$organizationId/cluster/$clusterId/settings/addons' | '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/organization/$organizationId/cluster/$clusterId/settings/credentials' @@ -2616,6 +2655,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/settings/general' | '/organization/$organizationId/settings/roles/edit/$roleId' | '/organization/$organizationId/cluster/$clusterId/deployments' + | '/organization/$organizationId/cluster/$clusterId/profile/' | '/organization/$organizationId/cluster/$clusterId/settings/' | '/organization/$organizationId/cluster/create/$slug/' | '/organization/$organizationId/project/$projectId/deployment-rules' @@ -2794,6 +2834,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId' | '/organization/$organizationId/settings/agents' | '/organization/$organizationId/settings/roles' + | '/organization/$organizationId/cluster/$clusterId/profile/$componentKey' | '/organization/$organizationId/cluster/$clusterId/settings/addons' | '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/organization/$organizationId/cluster/$clusterId/settings/credentials' @@ -2816,6 +2857,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/settings/general' | '/organization/$organizationId/settings/roles/edit/$roleId' | '/organization/$organizationId/cluster/$clusterId/deployments' + | '/organization/$organizationId/cluster/$clusterId/profile' | '/organization/$organizationId/cluster/$clusterId/settings' | '/organization/$organizationId/cluster/create/$slug' | '/organization/$organizationId/project/$projectId/deployment-rules' @@ -2973,6 +3015,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/$clusterId/' | '/_authenticated/organization/$organizationId/alerts/' | '/_authenticated/organization/$organizationId/settings/' + | '/_authenticated/organization/$organizationId/cluster/$clusterId/profile' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings' | '/_authenticated/organization/$organizationId/cluster/create/$slug' | '/_authenticated/organization/$organizationId/project/$projectId/settings' @@ -2988,6 +3031,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/' | '/_authenticated/organization/$organizationId/settings/agents/' | '/_authenticated/organization/$organizationId/settings/roles/' + | '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials' @@ -3010,6 +3054,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/settings/general' | '/_authenticated/organization/$organizationId/settings/roles/edit/$roleId' | '/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/' + | '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/' | '/_authenticated/organization/$organizationId/cluster/create/$slug/' | '/_authenticated/organization/$organizationId/project/$projectId/deployment-rules/' @@ -3573,6 +3618,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteImport 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 AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/settings/': { id: '/_authenticated/organization/$organizationId/project/$projectId/settings/' path: '/' @@ -3601,6 +3653,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute } + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/': { + id: '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/' + path: '/' + fullPath: '/organization/$organizationId/cluster/$clusterId/profile/' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute + } '/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/': { id: '/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/' path: '/cluster/$clusterId/deployments' @@ -3755,6 +3814,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute } + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey': { + id: '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey' + path: '/$componentKey' + fullPath: '/organization/$organizationId/cluster/$clusterId/profile/$componentKey' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/' path: '/project/$projectId/environment/$environmentId' @@ -4718,6 +4784,24 @@ const AuthenticatedOrganizationOrganizationIdSettingsRouteRouteWithChildren = AuthenticatedOrganizationOrganizationIdSettingsRouteRouteChildren, ) +interface AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteChildren { + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute +} + +const AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteChildren: AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteChildren = + { + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute: + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute, + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute: + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute, + } + +const AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteWithChildren = + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute._addFileChildren( + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteChildren, + ) + interface AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteChildren { AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute @@ -5285,6 +5369,7 @@ interface AuthenticatedOrganizationOrganizationIdRouteRouteChildren { AuthenticatedOrganizationOrganizationIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdIndexRoute AuthenticatedOrganizationOrganizationIdClusterNewRoute: typeof AuthenticatedOrganizationOrganizationIdClusterNewRoute AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsRouteRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsRouteRouteWithChildren @@ -5351,6 +5436,8 @@ const AuthenticatedOrganizationOrganizationIdRouteRouteChildren: AuthenticatedOr AuthenticatedOrganizationOrganizationIdClusterNewRoute, AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute: AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute, + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute: + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteWithChildren, AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute: AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteWithChildren, AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute: diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey.tsx new file mode 100644 index 00000000000..7559880c93b --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey.tsx @@ -0,0 +1,48 @@ +import { Navigate, createFileRoute, useParams } from '@tanstack/react-router' +import { ClusterProfileFeature, resolveProfileSelection, useClusterProfileTree } from '@qovery/domains/clusters/feature' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const { componentKey } = Route.useParams() + const { search } = Route.useSearch() + const navigate = Route.useNavigate() + const { profileTree, isLoading } = useClusterProfileTree({ organizationId, clusterId }) + // Disabled layers and components with nothing to configure cannot be opened: the URL follows what is displayed. + const resolvedComponentKey = resolveProfileSelection(profileTree, componentKey).component?.key + + if (!isLoading && resolvedComponentKey && resolvedComponentKey !== componentKey) { + return ( + previous} + replace + /> + ) + } + + return ( + + navigate({ + to: '/organization/$organizationId/cluster/$clusterId/profile/$componentKey', + params: (previous) => ({ ...previous, componentKey: nextComponentKey }), + search: (previous) => previous, + }) + } + onSearchChange={(nextSearch) => + navigate({ search: (previous) => ({ ...previous, search: nextSearch || undefined }), replace: true }) + } + /> + ) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/index.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/index.tsx new file mode 100644 index 00000000000..6b16d2f8a04 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/index.tsx @@ -0,0 +1,35 @@ +import { Navigate, createFileRoute, useParams } from '@tanstack/react-router' +import { ClusterProfileFeature, resolveProfileSelection, useClusterProfileTree } from '@qovery/domains/clusters/feature' +import { LoaderSpinner } from '@qovery/shared/ui' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/$clusterId/profile/')({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const { profileTree, isLoading, isError } = useClusterProfileTree({ organizationId, clusterId }) + const componentKey = resolveProfileSelection(profileTree).component?.key + + if (isLoading) { + return ( +
+ +
+ ) + } + + // Without a component to open, the feature shows the error or empty state. + if (isError || !componentKey) { + return + } + + return ( + previous} + replace + /> + ) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route.tsx new file mode 100644 index 00000000000..87296f8db54 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route.tsx @@ -0,0 +1,39 @@ +import { Navigate, Outlet, createFileRoute, useParams } from '@tanstack/react-router' +import { z } from 'zod' +import { useCluster, useIsOperatorAttached } from '@qovery/domains/clusters/feature' +import { LoaderSpinner } from '@qovery/shared/ui' +import { useDocumentTitle } from '@qovery/shared/util-hooks' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/$clusterId/profile')({ + component: RouteComponent, + validateSearch: z.object({ search: z.string().optional() }), +}) + +function RouteComponent() { + useDocumentTitle('Cluster - Profile') + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const { data: cluster, isLoading: isClusterLoading } = useCluster({ organizationId, clusterId }) + // Only Engine v2 clusters, attached to the Qovery Operator, have a profile. + const isOperatorAttached = useIsOperatorAttached(cluster) + + // Redirecting before the flags, the cluster and its Operator status are loaded would turn away users who have access. + if (isClusterLoading || (cluster && isOperatorAttached === undefined)) { + return ( +
+ +
+ ) + } + + if (!isOperatorAttached && organizationId && clusterId) { + return ( + + ) + } + + return +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/new.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/new.tsx index e955a38d1ce..31789bf2153 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/new.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/new.tsx @@ -1,5 +1,6 @@ import { createFileRoute, useParams } from '@tanstack/react-router' -import { ClusterNew } from '@qovery/domains/clusters/feature' +import { useFeatureFlagEnabled } from 'posthog-js/react' +import { ClusterAdd, ClusterNew } from '@qovery/domains/clusters/feature' import { Heading, Icon, Link, Section } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -9,7 +10,8 @@ export const Route = createFileRoute('/_authenticated/organization/$organization function RouteComponent() { const { organizationId = '' } = useParams({ strict: false }) - useDocumentTitle('Create new cluster') + const isEngineV2Enabled = Boolean(useFeatureFlagEnabled('engine-v2-platform-configuration')) + useDocumentTitle(isEngineV2Enabled ? 'Add cluster' : 'Create new cluster') return (
@@ -20,12 +22,12 @@ function RouteComponent() { className="mb-2 text-xs" > - Back to clusters + {isEngineV2Enabled ? 'Back to cluster list' : 'Back to clusters'}
- Install cluster + {isEngineV2Enabled ? 'Add cluster' : 'Install cluster'}
- + {isEngineV2Enabled ? : }
) } diff --git a/apps/console/src/routes/_authenticated/organization/route.tsx b/apps/console/src/routes/_authenticated/organization/route.tsx index a7d05cc0ce5..96c264313c7 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 BlueprintDetailsResponse, type Cluster } from 'qovery-typescript-axios' import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { useClusters } from '@qovery/domains/clusters/feature' +import { useClusters, useIsOperatorAttached } from '@qovery/domains/clusters/feature' import { useEnvironment } from '@qovery/domains/environments/feature' import { useProject } from '@qovery/domains/projects/feature' import { @@ -118,6 +118,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', @@ -393,6 +399,8 @@ function useNavigationContext(): NavigationContext | null { }) const hasAlerting = clusters.some((cluster) => cluster.metrics_parameters?.configuration?.alerting?.enabled) const currentCluster = clusters.find((cluster) => cluster.id === environment?.cluster_id) + // Only Engine v2 clusters, attached to the Qovery Operator, have a profile. + const isProfileAvailable = Boolean(useIsOperatorAttached(clusters.find((cluster) => cluster.id === params.clusterId))) const blueprintId = service && isBlueprintService(service) && isTerraform(service) ? service.blueprint_id : '' const { data: blueprint } = useBlueprint({ blueprintId, @@ -423,9 +431,11 @@ function useNavigationContext(): NavigationContext | null { ? getServiceTabs(service, currentCluster, isAgenticWorkflowEnabled, blueprint) : context.type === 'organization' ? context.tabs.filter((tab) => hasAlerting || tab.id !== 'alerts') - : context.type === 'environment' - ? context.tabs.filter((tab) => isAgenticWorkflowEnabled || tab.id !== 'automation') - : context.tabs + : context.type === 'cluster' + ? context.tabs.filter((tab) => isProfileAvailable || tab.id !== 'profile') + : context.type === 'environment' + ? context.tabs.filter((tab) => isAgenticWorkflowEnabled || tab.id !== 'automation') + : context.tabs return { type: context.type, @@ -546,6 +556,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/apps/console/vite.config.ts b/apps/console/vite.config.ts index c19f5e87ce8..9541d8e35ec 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -44,6 +44,7 @@ export default defineConfig(({ mode }) => { tanstackRouter({ target: 'react', autoCodeSplitting: true, + routeFileIgnorePattern: '\\.(spec|test)\\.tsx?$', }), viteStaticCopy({ targets: [ 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/domains-clusters-data-access.ts b/libs/domains/clusters/data-access/src/lib/domains-clusters-data-access.ts index e2e1e85d328..d639e790b66 100644 --- a/libs/domains/clusters/data-access/src/lib/domains-clusters-data-access.ts +++ b/libs/domains/clusters/data-access/src/lib/domains-clusters-data-access.ts @@ -9,17 +9,22 @@ import { type ClusterDeleteMode, ClusterDeploymentHistoryApi, type ClusterDnsProviderRequest, + ClusterOperatorApi, + type ClusterOperatorUpdateRequest, type ClusterRequest, type ClusterRoutingTableRequest, ClustersApi, KubernetesEnum, OrganizationMainCallsApi, SecretManagerAccessApi, + type SelfManagedClusterRequest, } from 'qovery-typescript-axios' import { type ClusterMetricsDto, type ClusterStatusDto } from 'qovery-ws-typescript-axios' +import { isHttpStatus } from '@qovery/shared/utils' const clusterApi = new ClustersApi() const clusterDeploymentHistoryApi = new ClusterDeploymentHistoryApi() +const clusterOperatorApi = new ClusterOperatorApi() const argoCdApi = new ArgoCDApi() const secretManagerApi = new SecretManagerAccessApi() const organizationApi = new OrganizationMainCallsApi() @@ -50,6 +55,26 @@ export const clusters = createQueryKeys('clusters', { return response.data }, }), + operatorStatus: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({ + queryKey: [organizationId, clusterId], + async queryFn() { + try { + const response = await clusterOperatorApi.getClusterOperatorStatus(organizationId, clusterId) + return response.data + } catch (error) { + // No Operator state: the cluster is not enrolled in the Engine v2 Operator path. + if (isHttpStatus(error, 404)) return null + throw error + } + }, + }), + operatorBootstrap: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({ + queryKey: [organizationId, clusterId], + async queryFn() { + const response = await clusterOperatorApi.getClusterOperatorBootstrap(organizationId, clusterId) + return response.data + }, + }), eksAnywhereCommits: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({ queryKey: [organizationId, clusterId], async queryFn() { @@ -203,6 +228,33 @@ export const clusters = createQueryKeys('clusters', { }) export const mutations = { + async updateClusterOperator({ + organizationId, + clusterId, + chartVersion, + imageVersion, + }: { + organizationId: string + clusterId: string + chartVersion: ClusterOperatorUpdateRequest['chart_version'] + imageVersion?: ClusterOperatorUpdateRequest['image_version'] + }) { + const response = await clusterOperatorApi.updateClusterOperator(organizationId, clusterId, { + chart_version: chartVersion, + image_version: imageVersion, + }) + return response.data + }, + async createSelfManagedCluster({ + organizationId, + clusterRequest, + }: { + organizationId: string + clusterRequest: SelfManagedClusterRequest + }) { + const response = await clusterApi.createSelfManagedCluster(organizationId, clusterRequest) + return response.data + }, async createCluster({ organizationId, clusterRequest }: { organizationId: string; clusterRequest: ClusterRequest }) { const response = await clusterApi.createCluster(organizationId, clusterRequest) return response.data 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..72bd8473781 --- /dev/null +++ b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts @@ -0,0 +1,105 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory' +import { + type ClusterPlatformConfigurationRequest, + type PlatformCloudVendor, + type PlatformClusterMode, + type PlatformComponentConfigurationPreviewRequest, + PlatformConfigurationApi, +} from 'qovery-typescript-axios' +import { isHttpStatus } from '@qovery/shared/utils' + +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 + }, + }), + // Resolves a component draft against a template release, before the cluster exists. + templateComponentConfiguration: ({ + organizationId, + templateKey, + templateVersion, + componentKey, + clusterMode, + cloudProvider, + request, + }: { + organizationId: string + templateKey: string + templateVersion: string + componentKey: string + clusterMode: PlatformClusterMode + cloudProvider: PlatformCloudVendor + request: PlatformComponentConfigurationPreviewRequest + }) => ({ + queryKey: [organizationId, templateKey, templateVersion, componentKey, clusterMode, cloudProvider, request], + async queryFn() { + const response = await platformConfigurationApi.resolvePlatformTemplateComponentConfiguration( + organizationId, + templateKey, + templateVersion, + componentKey, + clusterMode, + cloudProvider, + request + ) + return response.data + }, + }), + configuration: ({ clusterId }: { clusterId: string }) => ({ + queryKey: [clusterId], + async queryFn() { + try { + const response = await platformConfigurationApi.getClusterPlatformConfiguration(clusterId) + return response.data + } catch (error) { + if (isHttpStatus(error, 404)) return null + throw error + } + }, + }), + componentConfiguration: ({ + clusterId, + componentKey, + request, + }: { + clusterId: string + componentKey: string + request: PlatformComponentConfigurationPreviewRequest + }) => ({ + queryKey: [clusterId, componentKey, request], + async queryFn() { + const response = await platformConfigurationApi.resolveClusterPlatformComponentConfiguration( + clusterId, + componentKey, + request + ) + return response.data + }, + }), +}) + +export const platformConfigurationMutations = { + async updateConfiguration({ + clusterId, + configurationRequest, + }: { + clusterId: string + configurationRequest: ClusterPlatformConfigurationRequest + }) { + const response = await platformConfigurationApi.updateClusterPlatformConfiguration(clusterId, configurationRequest) + return response.data + }, +} diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 30fdf89ed64..b4e0c7de319 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -7,6 +7,9 @@ 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-profile/profile-tree' +export * from './lib/cluster-profile/use-cluster-profile-tree' 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' @@ -59,6 +62,10 @@ export * from './lib/hooks/use-cluster-status/use-cluster-status' export * from './lib/hooks/use-services-cluster/use-services-cluster' 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-platform-configuration/use-platform-configuration' +export * from './lib/hooks/use-platform-component-configuration/use-platform-component-configuration' +export * from './lib/hooks/use-platform-component-configurations/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' @@ -91,3 +98,10 @@ export * from './lib/utils/derive-global-spot-enabled' export * from './lib/utils/map-creation-spot-to-node-pools' export * from './lib/utils/normalize-karpenter-spot' export * from './lib/cluster-resources-settings/cluster-resources-settings' +export * from './lib/cluster-add/cluster-add' +export * from './lib/hooks/use-create-self-managed-cluster/use-create-self-managed-cluster' +export * from './lib/hooks/use-cluster-operator-status/use-cluster-operator-status' +export * from './lib/hooks/use-update-platform-configuration/use-update-platform-configuration' +export * from './lib/hooks/use-cluster-operator-bootstrap/use-cluster-operator-bootstrap' +export * from './lib/hooks/use-is-operator-attached/use-is-operator-attached' +export * from './lib/hooks/use-update-cluster-operator/use-update-cluster-operator' diff --git a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx index 0d34841026c..57652a02284 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx @@ -8,8 +8,12 @@ import { import type { ReactNode } from 'react' import { clusterFactoryMock } from '@qovery/shared/factories' import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { useIsOperatorAttached } from '../hooks/use-is-operator-attached/use-is-operator-attached' import { ClusterActions } from './cluster-actions' +jest.mock('../hooks/use-is-operator-attached/use-is-operator-attached') +const mockUseIsOperatorAttached = useIsOperatorAttached as jest.Mock + const mockCluster = clusterFactoryMock(1)[0] const mockOpenModal = jest.fn() const mockOpenModalConfirmation = jest.fn() @@ -54,6 +58,7 @@ jest.mock('@qovery/shared/util-hooks', () => ({ describe('ClusterActions', () => { beforeEach(() => { jest.clearAllMocks() + mockUseIsOperatorAttached.mockReturnValue(false) mockCluster.deployment_status = ClusterDeploymentStatusEnum.UP_TO_DATE mockClusterStatus = { cluster_id: mockCluster.id, @@ -310,4 +315,65 @@ describe('ClusterActions', () => { expect(screen.getByRole('button', { name: 'Deployments' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Logs' })).not.toBeInTheDocument() }) + + describe('clusters with an attached Operator', () => { + const selfManagedCluster = { + ...mockCluster, + kubernetes: KubernetesEnum.SELF_MANAGED, + cloud_provider: 'AWS' as const, + } + + it('keeps the installation guide for self-managed clusters without an attached Operator', () => { + const { container } = renderWithProviders( + + ) + + // The installation guide button is only named by its tooltip. + expect(container.querySelector('.fa-circle-info')).toBeInTheDocument() + expect(screen.queryByLabelText(/manage deployment/i)).not.toBeInTheDocument() + }) + + it('offers to deploy a cluster with an attached Operator, without stopping it', async () => { + mockUseIsOperatorAttached.mockReturnValue(true) + const { userEvent } = renderWithProviders( + , + { container: document.body } + ) + + expect(document.body.querySelector('.fa-circle-info')).not.toBeInTheDocument() + await userEvent.click(screen.getByLabelText(/manage deployment/i)) + + expect(screen.getByRole('menuitem', { name: 'Deploy' })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /stop/i })).not.toBeInTheDocument() + }) + + it('offers to redeploy a deployed cluster with an attached Operator, without upgrading Kubernetes', async () => { + mockUseIsOperatorAttached.mockReturnValue(true) + const { userEvent } = renderWithProviders( + , + { container: document.body } + ) + + await userEvent.click(screen.getByLabelText(/manage deployment/i)) + + expect(screen.getByRole('menuitem', { name: 'Update' })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /upgrade k8s/i })).not.toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /stop/i })).not.toBeInTheDocument() + }) + + it('shows the deployments card action for clusters with an attached Operator', () => { + mockUseIsOperatorAttached.mockReturnValue(true) + renderWithProviders( + + ) + + expect(screen.getByRole('button', { name: 'Deployments' })).toBeInTheDocument() + }) + }) }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx index 7b43dce6519..2216439e960 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx @@ -25,6 +25,7 @@ import { useClusterRunningStatus } from '../hooks/use-cluster-running-status/use import { useDeployCluster } from '../hooks/use-deploy-cluster/use-deploy-cluster' import { useDownloadKubeconfig } from '../hooks/use-download-kubeconfig/use-download-kubeconfig' import { useEksAnywhereClusterJwt } from '../hooks/use-eks-anywhere-cluster-jwt/use-eks-anywhere-cluster-jwt' +import { useIsOperatorAttached } from '../hooks/use-is-operator-attached/use-is-operator-attached' import { useStopCluster } from '../hooks/use-stop-cluster/use-stop-cluster' import { useUpdateEksAnywhereCommit } from '../hooks/use-update-eks-anywhere-commit/use-update-eks-anywhere-commit' import { useUpgradeCluster } from '../hooks/use-upgrade-cluster/use-upgrade-cluster' @@ -36,10 +37,13 @@ function MenuManageDeployment({ cluster, clusterStatus, variant = 'default', + isOperatorAttached = false, }: { cluster: Cluster clusterStatus: ClusterStatus variant?: ActionToolbarVariant + // The cluster is attached to the Qovery Operator: Qovery only deploys its platform profile, it cannot stop or upgrade it. + isOperatorAttached?: boolean }) { const { openModalConfirmation } = useModalConfirmation() const { openModal, closeModal } = useModal() @@ -63,7 +67,8 @@ function MenuManageDeployment({ clusterStatus.next_k8s_available_version && clusterStatus.next_k8s_available_version !== null && clusterStatus.status === 'DEPLOYED' && - cluster.kubernetes !== 'PARTIALLY_MANAGED' + cluster.kubernetes !== 'PARTIALLY_MANAGED' && + !isOperatorAttached const clusterNeedUpdate = cluster.deployment_status !== 'UP_TO_DATE' && clusterStatus.status !== 'STOPPED' const isEksAnywhereCluster = cluster.kubernetes === 'PARTIALLY_MANAGED' const hasEksAnywhereGitRepository = Boolean( @@ -202,7 +207,8 @@ function MenuManageDeployment({ {tooltipClusterNeedUpdate} ), - cluster.cloud_provider !== 'GCP' && + !isOperatorAttached && + cluster.cloud_provider !== 'GCP' && cluster.cloud_provider !== 'AZURE' && isStopAvailable(clusterStatus.status) && cluster.kubernetes !== 'PARTIALLY_MANAGED' && ( @@ -422,6 +428,7 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: organizationId: cluster.organization.id, clusterId: cluster.id, }) + const isOperatorAttached = useIsOperatorAttached(cluster) const searchParams = useMemo(() => { // @ts-expect-error TODO needs to be fixed @@ -471,6 +478,12 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: }, [searchParams, location.search, location.pathname, cluster.kubernetes, closeModal, openInstallationGuideModal]) const primaryActionButton = match(cluster) + .when( + () => isOperatorAttached, + () => ( + + ) + ) .with({ cloud_provider: P.not('ON_PREMISE'), kubernetes: 'SELF_MANAGED' }, () => ( + + + ), +})) + +type BlockerOptions = { disabled: boolean; shouldBlockFn: () => boolean; enableBeforeUnload: boolean } +const getBlocker = () => mockUseBlocker.mock.calls.at(-1)?.[0] as BlockerOptions + +describe('ClusterAdd', () => { + afterEach(() => jest.restoreAllMocks()) + + async function openCreationFlow(userEvent: ReturnType['userEvent']) { + await userEvent.click(screen.getByRole('button', { name: 'AWS' })) + expect(await screen.findByText('Creation flow')).toBeInTheDocument() + } + + it('asks for EKS Anywhere access when it is not enabled', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: /Amazon Web Services EKS Anywhere\s*Request access/ })) + + expect(mockShowPylonForm).toHaveBeenCalledWith('request-access-eks-anywhere') + expect(posthog.capture).toHaveBeenCalledWith('select-cluster', { + selectedCloudProvider: 'AWS', + selectedInstallationType: 'partially-managed', + }) + }) + + it('names each provider card once for assistive technologies', () => { + const { container } = renderWithProviders() + + expect(screen.getByRole('button', { name: 'AWS' })).toBeInTheDocument() + expect(screen.queryAllByRole('img')).toHaveLength(0) + expect(container.querySelectorAll('svg:not([aria-hidden="true"])')).toHaveLength(0) + }) + + it('lets the creation flow be dismissed before the cluster is created', async () => { + const { userEvent } = renderWithProviders() + + await openCreationFlow(userEvent) + expect(posthog.capture).toHaveBeenCalledWith('select-cluster', { + selectedCloudProvider: 'AWS', + selectedInstallationType: 'self-managed', + }) + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + await userEvent.keyboard('{Escape}') + + await waitFor(() => expect(screen.queryByText('Creation flow')).not.toBeInTheDocument()) + }) + + it('cannot be dismissed on the install step, only closed by the flow once the Operator connects', async () => { + const { userEvent } = renderWithProviders() + + await openCreationFlow(userEvent) + await userEvent.click(screen.getByRole('button', { name: 'Reach install step' })) + await userEvent.keyboard('{Escape}') + + expect(screen.getByText('Creation flow')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Operator connected' })) + + await waitFor(() => expect(screen.queryByText('Creation flow')).not.toBeInTheDocument()) + }) + + describe('leaving the install step', () => { + it('does not guard the page before the cluster is created', async () => { + const { userEvent } = renderWithProviders() + + expect(getBlocker().disabled).toBe(true) + + await openCreationFlow(userEvent) + + expect(getBlocker().disabled).toBe(true) + }) + + it('asks for confirmation before leaving, and enables the before-unload warning', async () => { + const confirm = jest.spyOn(window, 'confirm') + const { userEvent } = renderWithProviders() + + await openCreationFlow(userEvent) + await userEvent.click(screen.getByRole('button', { name: 'Reach install step' })) + + expect(getBlocker()).toMatchObject({ disabled: false, enableBeforeUnload: true }) + confirm.mockReturnValueOnce(false) + expect(getBlocker().shouldBlockFn()).toBe(true) + confirm.mockReturnValueOnce(true) + expect(getBlocker().shouldBlockFn()).toBe(false) + }) + + it('lets the flow navigate away once the Operator connects', async () => { + const confirm = jest.spyOn(window, 'confirm') + const { userEvent } = renderWithProviders() + + await openCreationFlow(userEvent) + await userEvent.click(screen.getByRole('button', { name: 'Reach install step' })) + await userEvent.click(screen.getByRole('button', { name: 'Operator connected' })) + + expect(getBlocker().shouldBlockFn()).toBe(false) + expect(getBlocker().disabled).toBe(true) + expect(confirm).not.toHaveBeenCalled() + }) + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.tsx b/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.tsx new file mode 100644 index 00000000000..d37d5cdbcd9 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.tsx @@ -0,0 +1,280 @@ +import { Link, useBlocker, useParams } from '@tanstack/react-router' +import Azure from 'devicon/icons/azure/azure-original.svg' +import DigitalOcean from 'devicon/icons/digitalocean/digitalocean-original.svg' +import GCP from 'devicon/icons/googlecloud/googlecloud-original.svg' +import Kubernetes from 'devicon/icons/kubernetes/kubernetes-original.svg' +import posthog from 'posthog-js' +import { useFeatureFlagEnabled } from 'posthog-js/react' +import { type ReactElement, type ReactNode, cloneElement, useRef, useState } from 'react' +import { Callout, Heading, Icon, Modal, Section, useModal } from '@qovery/shared/ui' +import { useSupportChat } from '@qovery/shared/util-hooks' +import { twMerge } from '@qovery/shared/util-js' +import { ClusterInstallationGuideModal } from '../cluster-installation-guide-modal/cluster-installation-guide-modal' +import { useClusterCreationRestriction } from '../hooks/use-cluster-creation-restriction/use-cluster-creation-restriction' +import { + SELF_MANAGED_CLUSTER_CREATION_MODAL_WIDTH, + SelfManagedClusterCreationFlow, + type SelfManagedClusterCreationStep, +} from '../self-managed-cluster-creation/self-managed-cluster-creation-flow' + +const LEAVE_INSTALL_STEP_MESSAGE = + 'The Operator installation instructions will be lost if you leave this page. Leave anyway?' + +type ProviderIconSource = string | ReactElement<{ className?: string; 'aria-hidden'?: boolean }> + +type ProviderCardProps = { + title: string + icon: ProviderIconSource + disabled?: boolean + actionLabel?: string + analytics: { selectedCloudProvider: string; selectedInstallationType: string } +} & ({ slug: string } | { onClick: () => void }) + +// Decorative: the card title already names the provider. +function ProviderIcon({ icon }: { icon: ProviderIconSource }) { + return typeof icon === 'string' ? ( + + ) : ( + cloneElement(icon, { className: 'size-5 select-none', 'aria-hidden': true }) + ) +} + +function ProviderCard({ title, icon, disabled = false, actionLabel, analytics, ...props }: ProviderCardProps) { + const { organizationId = '' } = useParams({ strict: false }) + const className = twMerge( + 'flex h-[52px] items-center gap-2 rounded-md border border-neutral bg-surface-neutral px-4 text-left text-sm font-medium text-neutral transition-colors', + disabled ? 'cursor-not-allowed bg-surface-neutral-component text-neutral-subtle' : 'hover:border-brand-strong', + // Not available to the organization yet: it opens an access request, not the creation flow. + actionLabel && 'border-dashed text-neutral-subtle' + ) + const content = ( + <> + + {title} + {actionLabel ? ( + {actionLabel} + ) : ( + + )} + + ) + const capture = () => posthog.capture('select-cluster', analytics) + + if ('slug' in props && !disabled) { + return ( + + {content} + + ) + } + + return ( + + ) +} + +function ProviderSection({ + title, + description, + children, +}: { + title: string + description: string + children: ReactNode +}) { + return ( +
+
+ {title} +

{description}

+
+
{children}
+
+ ) +} + +export function ClusterAdd() { + const { organizationId = '' } = useParams({ strict: false }) + const { openModal, closeModal } = useModal() + const { showPylonForm } = useSupportChat() + const isEksAnywhereEnabled = useFeatureFlagEnabled('eks-anywhere') + const { isClusterCreationRestricted: disabled } = useClusterCreationRestriction({ organizationId }) + const [isCreationFlowOpen, setIsCreationFlowOpen] = useState(false) + const [creationStep, setCreationStep] = useState('general') + + // The flow closes itself before navigating once the Operator connects: that navigation must not be blocked. + const isFlowClosingRef = useRef(false) + // Once the cluster exists, leaving the page would lose the Operator installation instructions. + const isInstalling = isCreationFlowOpen && creationStep === 'install' + + useBlocker({ + disabled: !isInstalling, + shouldBlockFn: () => !isFlowClosingRef.current && !window.confirm(LEAVE_INSTALL_STEP_MESSAGE), + enableBeforeUnload: isInstalling, + }) + + const openInstallationGuideModal = ({ isDemo = false }: { isDemo?: boolean } = {}) => + openModal({ + options: { width: 500 }, + content: , + }) + + const openCreationFlow = () => { + isFlowClosingRef.current = false + setCreationStep('general') + setIsCreationFlowOpen(true) + } + + const managed = (selectedCloudProvider: string) => ({ + selectedCloudProvider, + selectedInstallationType: 'managed', + }) + const selfManaged = (selectedCloudProvider: string) => ({ + selectedCloudProvider, + selectedInstallationType: 'self-managed', + }) + + return ( +
+ {disabled && ( + + + + + + Cluster creation is restricted + + Your organization has a billing restriction that prevents cluster creation. Please contact support to + resolve this issue. + + + + )} + + + } + slug="aws" + disabled={disabled} + analytics={managed('AWS')} + /> + {isEksAnywhereEnabled ? ( + } + slug="aws-eks-anywhere" + disabled={disabled} + analytics={{ selectedCloudProvider: 'AWS', selectedInstallationType: 'partially-managed' }} + /> + ) : ( + } + actionLabel="Request access" + onClick={() => showPylonForm('request-access-eks-anywhere')} + analytics={{ selectedCloudProvider: 'AWS', selectedInstallationType: 'partially-managed' }} + /> + )} + + } + slug="scw" + disabled={disabled} + analytics={managed('SCW')} + /> + + + + + openInstallationGuideModal({ isDemo: true })} + analytics={{ selectedCloudProvider: 'OTHER', selectedInstallationType: 'demo' }} + /> + } + onClick={openCreationFlow} + disabled={disabled} + analytics={selfManaged('AWS')} + /> + {[ + { title: 'GCP', icon: GCP, provider: 'GCP' }, + { title: 'Microsoft Azure', icon: Azure, provider: 'AZURE' }, + { title: 'OVH Cloud', icon: , provider: 'OVH_CLOUD' }, + { title: 'Digital Ocean', icon: DigitalOcean, provider: 'DIGITAL_OCEAN' }, + { title: 'CIVO', icon: , provider: 'CIVO' }, + { title: 'Oracle Cloud Infrastructure', icon: , provider: 'ORACLE_CLOUD' }, + { title: 'Hetzner', icon: , provider: 'HETZNER' }, + { title: 'IBM Cloud', icon: , provider: 'IBM_CLOUD' }, + ].map(({ title, icon, provider }) => ( + openInstallationGuideModal()} + disabled={disabled} + analytics={selfManaged(provider)} + /> + ))} + + + + {/* The dialog unmounts its content once closed, so each opening starts a fresh flow. */} + { + isFlowClosingRef.current = true + setIsCreationFlowOpen(false) + }} + /> + +
+ ) +} + +export default ClusterAdd diff --git a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.spec.tsx new file mode 100644 index 00000000000..a99d790af0d --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.spec.tsx @@ -0,0 +1,35 @@ +import { renderWithProviders } from '@qovery/shared/util-tests' +import { ClusterAvatar } from './cluster-avatar' + +jest.mock('@qovery/shared/ui', () => { + const React = jest.requireActual('react') + const ui = jest.requireActual('@qovery/shared/ui') + return { + ...ui, + Icon: (props: { name?: string; iconName?: string }) => + React.createElement('span', { 'data-icon-name': props.name ?? props.iconName }), + } +}) + +describe('ClusterAvatar', () => { + it.each([ + ['AWS', 'AWS'], + ['GCP', 'GCP'], + ['ON_PREMISE', 'KUBERNETES'], + ['OVH', 'OVH_CLOUD'], + ['ORACLE', 'ORACLE_CLOUD'], + ['IBM', 'IBM_CLOUD'], + ] as const)('shows the %s provider icon', (cloudProvider, iconName) => { + const { container } = renderWithProviders() + + expect(container.querySelector(`[data-icon-name="${iconName}"]`)).toBeInTheDocument() + }) + + it('shows a demo icon for demo clusters', () => { + const { container } = renderWithProviders( + + ) + + expect(container.querySelector('[data-icon-name="laptop-code"]')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx index 10ed95a8863..045ae72bd7c 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx @@ -1,10 +1,11 @@ -import { type CloudProviderEnum, type Cluster, type ClusterOverviewResponse } from 'qovery-typescript-axios' +import { type CloudVendorEnum, type Cluster, type ClusterOverviewResponse } from 'qovery-typescript-axios' import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react' import { match } from 'ts-pattern' +import { IconEnum } from '@qovery/shared/enums' import { Avatar, Icon } from '@qovery/shared/ui' export interface ClusterAvatarProps extends Omit, 'fallback'> { - cloudProvider?: CloudProviderEnum + cloudProvider?: CloudVendorEnum cluster?: Cluster | ClusterOverviewResponse } @@ -18,6 +19,9 @@ export const ClusterAvatar = forwardRef, ClusterAvatar )) .with({ localCloudProvider: 'ON_PREMISE' }, () => ) + .with({ localCloudProvider: 'OVH' }, () => ) + .with({ localCloudProvider: 'ORACLE' }, () => ) + .with({ localCloudProvider: 'IBM' }, () => ) .otherwise(() => ) return diff --git a/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.spec.tsx new file mode 100644 index 00000000000..25563eeb131 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.spec.tsx @@ -0,0 +1,63 @@ +import { type ClusterOperatorStatusResponse } from 'qovery-typescript-axios' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { useUpdateClusterOperator } from '../hooks/use-update-cluster-operator/use-update-cluster-operator' +import { ClusterOperatorStatus } from './cluster-operator-status' + +jest.mock('@tanstack/react-router', () => ({ + ...jest.requireActual('@tanstack/react-router'), + useParams: () => ({ organizationId: 'organization-id', clusterId: 'cluster-id' }), +})) +jest.mock('../hooks/use-update-cluster-operator/use-update-cluster-operator') + +const mockUseUpdateClusterOperator = useUpdateClusterOperator as jest.Mock +const mockUpdateOperator = jest.fn() + +const operatorStatus = { + status: 'OUTDATED_CHART', + operator_connected: true, + desired_chart_version: '1.2.0', + desired_image_version: '1.2.0', +} as ClusterOperatorStatusResponse + +describe('ClusterOperatorStatus', () => { + beforeEach(() => { + mockUpdateOperator.mockReset() + mockUseUpdateClusterOperator.mockReturnValue({ mutate: mockUpdateOperator, isLoading: false }) + }) + + it('updates the Operator to the target versions', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Update Operator' })) + + expect(mockUpdateOperator).toHaveBeenCalledWith({ + organizationId: 'organization-id', + clusterId: 'cluster-id', + chartVersion: '1.2.0', + imageVersion: '1.2.0', + }) + }) + + it('does not start another update while one is in progress', async () => { + mockUseUpdateClusterOperator.mockReturnValue({ mutate: mockUpdateOperator, isLoading: true }) + const { userEvent } = renderWithProviders() + + const button = screen.getByRole('button', { name: 'Update Operator' }) + expect(button).toBeDisabled() + await userEvent.click(button) + + expect(mockUpdateOperator).not.toHaveBeenCalled() + }) + + it('hides the update action while the Operator is not connected', () => { + renderWithProviders() + + expect(screen.queryByRole('button', { name: 'Update Operator' })).not.toBeInTheDocument() + }) + + it('disables the update action without a target chart version', () => { + renderWithProviders() + + expect(screen.getByRole('button', { name: 'Update Operator' })).toBeDisabled() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.tsx b/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.tsx new file mode 100644 index 00000000000..5e8599de14f --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.tsx @@ -0,0 +1,147 @@ +import { type IconName } from '@fortawesome/fontawesome-common-types' +import { useParams } from '@tanstack/react-router' +import { type ClusterOperatorFleetStatus, type ClusterOperatorStatusResponse } from 'qovery-typescript-axios' +import { match } from 'ts-pattern' +import { Badge, Button, StatusChip } from '@qovery/shared/ui' +import { timeAgo } from '@qovery/shared/util-dates' +import { useUpdateClusterOperator } from '../hooks/use-update-cluster-operator/use-update-cluster-operator' + +export function getClusterOperatorStatusDisplay(status: ClusterOperatorFleetStatus) { + return match(status) + .with('CURRENT', () => ({ + label: 'Versions current', + color: 'green' as const, + chipStatus: 'DEPLOYED' as const, + icon: { name: 'circle-check' as IconName, className: 'text-positive' }, + description: 'The Operator is connected and runs the selected image and Helm chart versions.', + })) + .with('DISCONNECTED', () => ({ + label: 'Disconnected', + color: 'red' as const, + chipStatus: 'ERROR' as const, + icon: { name: 'circle-xmark' as IconName, className: 'text-negative' }, + description: 'No recent heartbeat was received from the Operator.', + })) + .with('OUTDATED_IMAGE', () => ({ + label: 'Image update available', + color: 'yellow' as const, + chipStatus: 'WARNING' as const, + icon: { name: 'circle-exclamation' as IconName, className: 'text-warning' }, + description: 'The installed Operator image differs from the selected version.', + })) + .with('OUTDATED_CHART', () => ({ + label: 'Chart update available', + color: 'yellow' as const, + chipStatus: 'WARNING' as const, + icon: { name: 'circle-exclamation' as IconName, className: 'text-warning' }, + description: 'The installed Helm chart differs from the selected version.', + })) + .with('OUTDATED_IMAGE_AND_CHART', () => ({ + label: 'Update available', + color: 'yellow' as const, + chipStatus: 'WARNING' as const, + icon: { name: 'circle-exclamation' as IconName, className: 'text-warning' }, + description: 'The installed Operator image and Helm chart differ from the selected versions.', + })) + .with('TARGET_UNKNOWN', () => ({ + label: 'Target unknown', + color: 'yellow' as const, + chipStatus: 'WARNING' as const, + icon: { name: 'circle-exclamation' as IconName, className: 'text-warning' }, + description: 'Qovery cannot determine the image or Helm chart version selected for this cluster.', + })) + .with('REPORTED_VERSION_UNKNOWN', () => ({ + label: 'Version unknown', + color: 'yellow' as const, + chipStatus: 'WARNING' as const, + icon: { name: 'circle-exclamation' as IconName, className: 'text-warning' }, + description: 'The Operator is connected but does not report enough version information.', + })) + .with('NOT_ATTACHED', () => ({ + label: 'Not attached', + color: 'neutral' as const, + chipStatus: 'UNKNOWN' as const, + icon: { name: 'circle-minus' as IconName, className: 'text-neutral-subtle' }, + description: 'This cluster is not attached to the Qovery Operator execution path.', + })) + .exhaustive() +} + +function Version({ installed, target }: { installed?: string | null; target?: string | null }) { + return ( + + {installed ?? 'Not reported'} + Target: {target ?? 'Unknown'} + + ) +} + +export interface ClusterOperatorStatusProps { + operatorStatus: ClusterOperatorStatusResponse +} + +export function ClusterOperatorStatus({ operatorStatus }: ClusterOperatorStatusProps) { + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const { mutate: updateOperator, isLoading: isUpdating } = useUpdateClusterOperator() + const display = getClusterOperatorStatusDisplay(operatorStatus.status) + + return ( +
+
+
+

Qovery Operator

+

{display.description}

+
+
+ + + {display.label} + + {operatorStatus.operator_connected && ( + + )} +
+
+ +
+
+
Last heartbeat
+
+ {operatorStatus.last_heartbeat ? `${timeAgo(new Date(operatorStatus.last_heartbeat))} ago` : 'Never'} +
+
+
+
Operator image
+
+ +
+
+
+
Helm chart
+
+ +
+
+
+
+ ) +} + +export default ClusterOperatorStatus 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..22569ed15f8 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-context.tsx @@ -0,0 +1,267 @@ +import { useBlocker, useParams } from '@tanstack/react-router' +import equal from 'fast-deep-equal' +import { + type ClusterPlatformConfigurationRequest, + type ClusterPlatformConfigurationResponse, + type PlatformTemplateSummaryResponse, +} from 'qovery-typescript-axios' +import { type PropsWithChildren, createContext, useContext, useState } from 'react' +import { type SchemaFieldValue, getSchemaFieldValue } from '@qovery/shared/schema-fields/feature' +import { toast } from '@qovery/shared/ui' +import { useDeployCluster } from '../hooks/use-deploy-cluster/use-deploy-cluster' +import { useUpdatePlatformConfiguration } from '../hooks/use-update-platform-configuration/use-update-platform-configuration' +import { + type PlatformFieldDescriptor, + type PlatformScalarField, + applyPlatformConfigurationDefaults, + isPlatformScalarField, + omitEmptyValues, + updateComponentValue, +} from '../utils/platform-configuration-utils' +import { type ProfileTreeItem } from './profile-tree' +import { useClusterProfileTree } from './use-cluster-profile-tree' + +const PROFILE_ROUTE_ID = '/_authenticated/organization/$organizationId/cluster/$clusterId/profile' +const LEAVE_UNSAVED_CHANGES_MESSAGE = 'You have unsaved changes on this profile. Leave the page and discard them?' + +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: Pick, value: unknown) { + return getSchemaFieldValue(field, value) ?? (field.type === 'bool' ? false : '') +} + +// Local edits differing from the saved (or default) value; edits reverted by hand do not count. +function getChangedProfileValues( + profileTree: ProfileTreeItem[], + configuration: ClusterPlatformConfigurationResponse | null | undefined, + profileValues: ProfileValues +): ProfileValues { + const fieldsByComponent = new Map( + profileTree.flatMap((layer) => layer.children).map((component) => [component.key, component.fields]) + ) + return Object.fromEntries( + Object.entries(profileValues).flatMap(([componentKey, values]) => { + const fields = fieldsByComponent.get(componentKey) ?? [] + const savedValues = applyPlatformConfigurationDefaults( + fields, + configuration?.platform.managedConfig?.[componentKey] ?? {} + ) + const changedValues = Object.fromEntries( + Object.entries(values).filter(([fieldKey, value]) => { + const field = fields.find(({ key }) => key === fieldKey) + return field && isPlatformScalarField(field) + ? getDisplayedScalarValue(field, value) !== getDisplayedScalarValue(field, savedValues[fieldKey]) + : !equal(value, savedValues[fieldKey]) + }) + ) + return Object.keys(changedValues).length ? [[componentKey, changedValues]] : [] + }) + ) +} + +function countProfileChanges( + profileTree: ProfileTreeItem[], + configuration: ClusterPlatformConfigurationResponse | null | undefined, + profileValues: ProfileValues, + clusterInputs: ClusterInputValues +) { + const profileChanges = Object.values(getChangedProfileValues(profileTree, configuration, profileValues)).flatMap( + (values) => Object.keys(values) + ) + const clusterInputChanges = Object.entries(clusterInputs).flatMap(([componentKey, values]) => + Object.entries(values).filter( + ([inputKey, value]) => value !== (configuration?.clusterInputs[componentKey]?.[inputKey] ?? '') + ) + ) + + return profileChanges.length + clusterInputChanges.length +} + +function omitSubmittedValues>>(currentValues: T, submittedValues: T) { + return Object.fromEntries( + Object.entries(currentValues).flatMap(([componentKey, values]) => { + const remainingValues = Object.fromEntries( + Object.entries(values).filter(([key, value]) => !equal(value, submittedValues[componentKey]?.[key])) + ) + return Object.keys(remainingValues).length ? [[componentKey, remainingValues]] : [] + }) + ) as T +} + +// PUT replaces the whole configuration: unedited components and inputs are sent back as saved, and edits reverted +// by hand are left out so the field keeps following the schema default. +function getConfigurationRequest( + template: PlatformTemplateSummaryResponse, + profileTree: ProfileTreeItem[], + configuration: ClusterPlatformConfigurationResponse | null | undefined, + profileValues: ProfileValues, + clusterInputs: ClusterInputValues +): ClusterPlatformConfigurationRequest { + const savedManagedConfig = configuration?.platform.managedConfig + const managedConfig = { ...savedManagedConfig } + for (const [componentKey, values] of Object.entries( + getChangedProfileValues(profileTree, configuration, profileValues) + )) { + managedConfig[componentKey] = omitEmptyValues({ ...savedManagedConfig?.[componentKey], ...values }) + } + const nextClusterInputs = { ...configuration?.clusterInputs } + for (const [componentKey, values] of Object.entries(clusterInputs)) { + if (!Object.keys(values).length) continue + nextClusterInputs[componentKey] = { ...configuration?.clusterInputs[componentKey], ...values } + } + + return { + platform: { + templateKey: configuration?.platform.templateKey ?? template.key, + templateVersion: configuration?.platform.templateVersion ?? template.version, + layerSelections: configuration?.platform.layerSelections, + managedConfig, + }, + clusterInputs: nextClusterInputs, + } +} + +interface ClusterProfileContextValue { + organizationId: string + clusterId: string + templates?: PlatformTemplateSummaryResponse[] + configuration?: ClusterPlatformConfigurationResponse | 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: SchemaFieldValue) => 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: updatePlatformConfiguration, isLoading: isSavingConfiguration } = + useUpdatePlatformConfiguration() + const { mutateAsync: deployCluster } = useDeployCluster() + const { templates, configuration, selectedTemplate, profileTree, isLoading, isError } = useClusterProfileTree({ + organizationId, + clusterId, + }) + + const updateProfileConfig = (componentKey: string, fieldKey: string, value: unknown) => { + setProfileValues((currentValues) => updateComponentValue(currentValues, componentKey, fieldKey, value)) + } + + const updateClusterInput = (componentKey: string, field: PlatformFieldDescriptor, value: SchemaFieldValue) => { + // The input shows its default while unsaved: setting it back to what is displayed is no edit. + const nextValue = String(value) + const isDisplayedValue = + getDisplayedScalarValue(field, nextValue) === + getDisplayedScalarValue(field, configuration?.clusterInputs[componentKey]?.[field.key]) + setClusterInputs((currentValues) => + updateComponentValue(currentValues, componentKey, field.key, isDisplayedValue ? undefined : nextValue) + ) + } + + const changeCount = countProfileChanges(profileTree, configuration, profileValues, clusterInputs) + const hasChanges = changeCount > 0 + + useBlocker({ + disabled: !hasChanges, + // Edits are kept while switching components of this profile: only leaving it would drop them. + shouldBlockFn: ({ next }) => + !( + next.routeId.startsWith(PROFILE_ROUTE_ID) && + 'clusterId' in next.params && + next.params.clusterId === clusterId + ) && !window.confirm(LEAVE_UNSAVED_CHANGES_MESSAGE), + enableBeforeUnload: hasChanges, + }) + + const resetChanges = () => { + setProfileValues({}) + setClusterInputs({}) + setFormKey((key) => key + 1) + } + + const saveConfiguration = async () => { + if (!selectedTemplate) return + await updatePlatformConfiguration({ + clusterId, + configurationRequest: getConfigurationRequest( + selectedTemplate, + profileTree, + configuration, + profileValues, + clusterInputs + ), + }) + // The saved configuration now carries the submitted edits, so their local copies can go. + setProfileValues((currentValues) => omitSubmittedValues(currentValues, profileValues)) + setClusterInputs((currentValues) => omitSubmittedValues(currentValues, clusterInputs)) + } + + const saveChanges = async () => { + try { + await saveConfiguration() + 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 saveConfiguration() + await deployCluster({ organizationId, clusterId }) + } catch { + // Errors are notified by the mutations. + } finally { + setIsSaveAndDeployPending(false) + } + } + + const value: ClusterProfileContextValue = { + organizationId, + clusterId, + templates, + configuration, + profileTree, + isLoading, + isError, + profileValues, + clusterInputs, + formKey, + changeCount, + isSaving: isSavingConfiguration && !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..463971686a3 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx @@ -0,0 +1,219 @@ +import { type ReactNode, useState } from 'react' +import { IconEnum } from '@qovery/shared/enums' +import { Icon, InputSearch, Tooltip } from '@qovery/shared/ui' + +type LayerStatus = 'disabled' | 'success' + +export const NOT_CONFIGURABLE_COMPONENT_REASON = 'No configuration needed for this component' +export const NOT_CONFIGURABLE_LAYER_REASON = 'No configuration needed for this layer yet' + +export interface ClusterProfileSidebarItem { + id: string + key: string + label: string + configurable: boolean +} + +export interface ClusterProfileSidebarLayer { + id: string + label: string + status: LayerStatus + disabledReason?: string + configurable: boolean + items: readonly ClusterProfileSidebarItem[] +} + +interface LayerSectionRowProps { + section: ClusterProfileSidebarLayer + selectedSectionId?: string + selectedItemId?: string + onSelectSection: (sectionId: string) => void + onSelectItem: (itemId: string) => void +} + +function LayerIcon() { + return