From 6e3f087061ba1cbec08921b80072448548546a8e Mon Sep 17 00:00:00 2001 From: Craig O'Donnell Date: Thu, 25 Jan 2024 18:11:03 +0000 Subject: [PATCH 1/5] embedded cluster upgrade modal --- Makefile | 2 +- pkg/api/handlers/types/types.go | 5 + pkg/handlers/app.go | 33 +++++++ pkg/store/kotsstore/version_store.go | 32 +++++++ pkg/store/mock/mock.go | 81 ++++++++++++----- pkg/store/store_interface.go | 2 + web/src/Root.tsx | 36 ++++++-- web/src/components/apps/AppDetailPage.tsx | 31 +++++-- .../clusters/EmbeddedClusterUpgrading.tsx | 91 +++++++++++++++++++ web/src/types/index.ts | 2 + web/src/utilities/utilities.js | 31 +++++++ web/src/utilities/utilities.test.js | 87 ++++++++++++++++++ 12 files changed, 390 insertions(+), 43 deletions(-) create mode 100644 web/src/components/clusters/EmbeddedClusterUpgrading.tsx diff --git a/Makefile b/Makefile index 0691b02e57..6a3f942a48 100644 --- a/Makefile +++ b/Makefile @@ -68,7 +68,7 @@ gosec: .PHONY: mock mock: - go get github.com/golang/mock/mockgen@v1.6.0 + go install github.com/golang/mock/mockgen@v1.6.0 mockgen -source=pkg/store/store_interface.go -destination=pkg/store/mock/mock.go mockgen -source=pkg/handlers/interface.go -destination=pkg/handlers/mock/mock.go mockgen -source=pkg/operator/client/client_interface.go -destination=pkg/operator/client/mock/mock.go diff --git a/pkg/api/handlers/types/types.go b/pkg/api/handlers/types/types.go index eba2058c70..b97e2b559c 100644 --- a/pkg/api/handlers/types/types.go +++ b/pkg/api/handlers/types/types.go @@ -91,6 +91,11 @@ type ResponseGitOps struct { type ResponseCluster struct { ID string `json:"id"` Slug string `json:"slug"` + // RequiresUpgrade represents whether the embedded cluster config for the current app + // version is different from the currently deployed embedded cluster config + RequiresUpgrade bool `json:"requiresUpgrade"` + // State represents the current state of the most recently deployed embedded cluster config + State string `json:"state,omitempty"` } type GetPendingAppResponse struct { diff --git a/pkg/handlers/app.go b/pkg/handlers/app.go index b240fe9548..f16f99dd5a 100644 --- a/pkg/handlers/app.go +++ b/pkg/handlers/app.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "fmt" "net/http" @@ -13,8 +14,10 @@ import ( downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" "github.com/replicatedhq/kots/pkg/api/handlers/types" apptypes "github.com/replicatedhq/kots/pkg/app/types" + "github.com/replicatedhq/kots/pkg/embeddedcluster" "github.com/replicatedhq/kots/pkg/gitops" "github.com/replicatedhq/kots/pkg/helm" + "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/operator" @@ -314,6 +317,36 @@ func responseAppFromApp(a *apptypes.App) (*types.ResponseApp, error) { Slug: d.ClusterSlug, } + clientset, err := k8sutil.GetClientset() + if err != nil { + return nil, errors.Wrap(err, "failed to get clientset") + } + + isEmbeddedCluster, err := embeddedcluster.IsEmbeddedCluster(clientset) + if err != nil { + return nil, errors.Wrap(err, "failed to check if cluster is embedded") + } + + if isEmbeddedCluster { + embeddedClusterConfig, err := store.GetStore().GetEmbeddedClusterConfigForVersion(a.ID, a.CurrentSequence) + if err != nil { + return nil, errors.Wrap(err, "failed to get embedded cluster config") + } + + if embeddedClusterConfig != nil { + cluster.RequiresUpgrade, err = embeddedcluster.RequiresUpgrade(context.TODO(), embeddedClusterConfig.Spec) + if err != nil { + return nil, errors.Wrap(err, "failed to check if cluster requires upgrade") + } + + embeddedClusterInstallation, err := embeddedcluster.GetCurrentInstallation(context.TODO()) + if err != nil { + return nil, errors.Wrap(err, "failed to get current installation") + } + cluster.State = string(embeddedClusterInstallation.Status.State) + } + } + responseDownstream := types.ResponseDownstream{ Name: d.Name, Links: links, diff --git a/pkg/store/kotsstore/version_store.go b/pkg/store/kotsstore/version_store.go index 3350fab3bb..31917bbab8 100644 --- a/pkg/store/kotsstore/version_store.go +++ b/pkg/store/kotsstore/version_store.go @@ -14,6 +14,7 @@ import ( "github.com/blang/semver" "github.com/mholt/archiver/v3" "github.com/pkg/errors" + embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-operator/api/v1beta1" downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" versiontypes "github.com/replicatedhq/kots/pkg/api/version/types" apptypes "github.com/replicatedhq/kots/pkg/app/types" @@ -199,6 +200,37 @@ func (s *KOTSStore) GetTargetKotsVersionForVersion(appID string, sequence int64) return kotsAppSpec.Spec.TargetKotsVersion, nil } +func (s *KOTSStore) GetEmbeddedClusterConfigForVersion(appID string, sequence int64) (*embeddedclusterv1beta1.Config, error) { + db := persistence.MustGetDBSession() + query := `select embeddedcluster_config from app_version where app_id = ? and sequence = ?` + rows, err := db.QueryOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{appID, sequence}, + }) + if err != nil { + return nil, fmt.Errorf("failed to query: %v: %v", err, rows.Err) + } + if !rows.Next() { + return nil, nil + } + + var embeddedClusterSpecStr gorqlite.NullString + if err := rows.Scan(&embeddedClusterSpecStr); err != nil { + return nil, errors.Wrap(err, "failed to scan") + } + + if embeddedClusterSpecStr.String == "" { + return nil, nil + } + + embeddedClusterConfig, err := kotsutil.LoadEmbeddedClusterConfigFromBytes([]byte(embeddedClusterSpecStr.String)) + if err != nil { + return nil, errors.Wrap(err, "failed to load embedded cluster config from contents") + } + + return embeddedClusterConfig, nil +} + // CreateAppVersion takes an unarchived app, makes an archive and then uploads it // to s3 with the appID and sequence specified func (s *KOTSStore) CreateAppVersionArchive(appID string, sequence int64, archivePath string) error { diff --git a/pkg/store/mock/mock.go b/pkg/store/mock/mock.go index a7479f0a75..92fdcf3057 100644 --- a/pkg/store/mock/mock.go +++ b/pkg/store/mock/mock.go @@ -10,6 +10,7 @@ import ( time "time" gomock "github.com/golang/mock/gomock" + v1beta1 "github.com/replicatedhq/embedded-cluster-operator/api/v1beta1" types "github.com/replicatedhq/kots/pkg/airgap/types" types0 "github.com/replicatedhq/kots/pkg/api/downstream/types" types1 "github.com/replicatedhq/kots/pkg/api/version/types" @@ -26,7 +27,7 @@ import ( types12 "github.com/replicatedhq/kots/pkg/supportbundle/types" types13 "github.com/replicatedhq/kots/pkg/upstream/types" types14 "github.com/replicatedhq/kots/pkg/user/types" - v1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + v1beta10 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" redact "github.com/replicatedhq/troubleshoot/pkg/redact" ) @@ -198,7 +199,7 @@ func (mr *MockStoreMockRecorder) CreateNewCluster(userID, isAllUsers, title, tok } // CreatePendingDownloadAppVersion mocks base method. -func (m *MockStore) CreatePendingDownloadAppVersion(appID string, update types13.Update, kotsApplication *v1beta1.Application, license *v1beta1.License) (int64, error) { +func (m *MockStore) CreatePendingDownloadAppVersion(appID string, update types13.Update, kotsApplication *v1beta10.Application, license *v1beta10.License) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreatePendingDownloadAppVersion", appID, update, kotsApplication, license) ret0, _ := ret[0].(int64) @@ -413,10 +414,10 @@ func (mr *MockStoreMockRecorder) GetAirgapInstallStatus(appID interface{}) *gomo } // GetAllAppLicenses mocks base method. -func (m *MockStore) GetAllAppLicenses() ([]*v1beta1.License, error) { +func (m *MockStore) GetAllAppLicenses() ([]*v1beta10.License, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAllAppLicenses") - ret0, _ := ret[0].([]*v1beta1.License) + ret0, _ := ret[0].([]*v1beta10.License) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -757,6 +758,21 @@ func (mr *MockStoreMockRecorder) GetEmbeddedClusterAuthToken() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterAuthToken", reflect.TypeOf((*MockStore)(nil).GetEmbeddedClusterAuthToken)) } +// GetEmbeddedClusterConfigForVersion mocks base method. +func (m *MockStore) GetEmbeddedClusterConfigForVersion(appID string, sequence int64) (*v1beta1.Config, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEmbeddedClusterConfigForVersion", appID, sequence) + ret0, _ := ret[0].(*v1beta1.Config) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEmbeddedClusterConfigForVersion indicates an expected call of GetEmbeddedClusterConfigForVersion. +func (mr *MockStoreMockRecorder) GetEmbeddedClusterConfigForVersion(appID, sequence interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterConfigForVersion", reflect.TypeOf((*MockStore)(nil).GetEmbeddedClusterConfigForVersion), appID, sequence) +} + // GetEmbeddedClusterInstallCommandRoles mocks base method. func (m *MockStore) GetEmbeddedClusterInstallCommandRoles(token string) ([]string, error) { m.ctrl.T.Helper() @@ -782,9 +798,9 @@ func (m *MockStore) GetEmbeddedClusterState() (string, error) { } // GetEmbeddedClusterState indicates an expected call of GetEmbeddedClusterState. -func (mr *MockStoreMockRecorder) GetEmbeddedClusterState(appID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetEmbeddedClusterState() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterState", reflect.TypeOf((*MockStore)(nil).GetEmbeddedClusterState), appID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterState", reflect.TypeOf((*MockStore)(nil).GetEmbeddedClusterState)) } // GetIgnoreRBACErrors mocks base method. @@ -880,10 +896,10 @@ func (mr *MockStoreMockRecorder) GetLatestDeployableDownstreamVersion(appID, clu } // GetLatestLicenseForApp mocks base method. -func (m *MockStore) GetLatestLicenseForApp(appID string) (*v1beta1.License, error) { +func (m *MockStore) GetLatestLicenseForApp(appID string) (*v1beta10.License, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLatestLicenseForApp", appID) - ret0, _ := ret[0].(*v1beta1.License) + ret0, _ := ret[0].(*v1beta10.License) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -895,10 +911,10 @@ func (mr *MockStoreMockRecorder) GetLatestLicenseForApp(appID interface{}) *gomo } // GetLicenseForAppVersion mocks base method. -func (m *MockStore) GetLicenseForAppVersion(appID string, sequence int64) (*v1beta1.License, error) { +func (m *MockStore) GetLicenseForAppVersion(appID string, sequence int64) (*v1beta10.License, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLicenseForAppVersion", appID, sequence) - ret0, _ := ret[0].(*v1beta1.License) + ret0, _ := ret[0].(*v1beta10.License) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1857,7 +1873,7 @@ func (mr *MockStoreMockRecorder) SetUpdateCheckerSpec(appID, updateCheckerSpec i } // UpdateAppLicense mocks base method. -func (m *MockStore) UpdateAppLicense(appID string, sequence int64, archiveDir string, newLicense *v1beta1.License, originalLicenseData string, channelChanged, failOnVersionCreate bool, gitops types4.DownstreamGitOps, renderer types9.Renderer) (int64, error) { +func (m *MockStore) UpdateAppLicense(appID string, sequence int64, archiveDir string, newLicense *v1beta10.License, originalLicenseData string, channelChanged, failOnVersionCreate bool, gitops types4.DownstreamGitOps, renderer types9.Renderer) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateAppLicense", appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, gitops, renderer) ret0, _ := ret[0].(int64) @@ -1900,7 +1916,7 @@ func (mr *MockStoreMockRecorder) UpdateAppVersion(appID, sequence, baseSequence, } // UpdateAppVersionInstallationSpec mocks base method. -func (m *MockStore) UpdateAppVersionInstallationSpec(appID string, sequence int64, spec v1beta1.Installation) error { +func (m *MockStore) UpdateAppVersionInstallationSpec(appID string, sequence int64, spec v1beta10.Installation) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateAppVersionInstallationSpec", appID, sequence, spec) ret0, _ := ret[0].(error) @@ -3662,7 +3678,7 @@ func (mr *MockVersionStoreMockRecorder) CreateAppVersionArchive(appID, sequence, } // CreatePendingDownloadAppVersion mocks base method. -func (m *MockVersionStore) CreatePendingDownloadAppVersion(appID string, update types13.Update, kotsApplication *v1beta1.Application, license *v1beta1.License) (int64, error) { +func (m *MockVersionStore) CreatePendingDownloadAppVersion(appID string, update types13.Update, kotsApplication *v1beta10.Application, license *v1beta10.License) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreatePendingDownloadAppVersion", appID, update, kotsApplication, license) ret0, _ := ret[0].(int64) @@ -3751,6 +3767,21 @@ func (mr *MockVersionStoreMockRecorder) GetCurrentUpdateCursor(appID, channelID return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentUpdateCursor", reflect.TypeOf((*MockVersionStore)(nil).GetCurrentUpdateCursor), appID, channelID) } +// GetEmbeddedClusterConfigForVersion mocks base method. +func (m *MockVersionStore) GetEmbeddedClusterConfigForVersion(appID string, sequence int64) (*v1beta1.Config, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEmbeddedClusterConfigForVersion", appID, sequence) + ret0, _ := ret[0].(*v1beta1.Config) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEmbeddedClusterConfigForVersion indicates an expected call of GetEmbeddedClusterConfigForVersion. +func (mr *MockVersionStoreMockRecorder) GetEmbeddedClusterConfigForVersion(appID, sequence interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterConfigForVersion", reflect.TypeOf((*MockVersionStore)(nil).GetEmbeddedClusterConfigForVersion), appID, sequence) +} + // GetLatestAppSequence mocks base method. func (m *MockVersionStore) GetLatestAppSequence(appID string, downloadedOnly bool) (int64, error) { m.ctrl.T.Helper() @@ -3871,7 +3902,7 @@ func (mr *MockVersionStoreMockRecorder) UpdateAppVersion(appID, sequence, baseSe } // UpdateAppVersionInstallationSpec mocks base method. -func (m *MockVersionStore) UpdateAppVersionInstallationSpec(appID string, sequence int64, spec v1beta1.Installation) error { +func (m *MockVersionStore) UpdateAppVersionInstallationSpec(appID string, sequence int64, spec v1beta10.Installation) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateAppVersionInstallationSpec", appID, sequence, spec) ret0, _ := ret[0].(error) @@ -3922,10 +3953,10 @@ func (m *MockLicenseStore) EXPECT() *MockLicenseStoreMockRecorder { } // GetAllAppLicenses mocks base method. -func (m *MockLicenseStore) GetAllAppLicenses() ([]*v1beta1.License, error) { +func (m *MockLicenseStore) GetAllAppLicenses() ([]*v1beta10.License, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAllAppLicenses") - ret0, _ := ret[0].([]*v1beta1.License) + ret0, _ := ret[0].([]*v1beta10.License) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -3937,10 +3968,10 @@ func (mr *MockLicenseStoreMockRecorder) GetAllAppLicenses() *gomock.Call { } // GetLatestLicenseForApp mocks base method. -func (m *MockLicenseStore) GetLatestLicenseForApp(appID string) (*v1beta1.License, error) { +func (m *MockLicenseStore) GetLatestLicenseForApp(appID string) (*v1beta10.License, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLatestLicenseForApp", appID) - ret0, _ := ret[0].(*v1beta1.License) + ret0, _ := ret[0].(*v1beta10.License) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -3952,10 +3983,10 @@ func (mr *MockLicenseStoreMockRecorder) GetLatestLicenseForApp(appID interface{} } // GetLicenseForAppVersion mocks base method. -func (m *MockLicenseStore) GetLicenseForAppVersion(appID string, sequence int64) (*v1beta1.License, error) { +func (m *MockLicenseStore) GetLicenseForAppVersion(appID string, sequence int64) (*v1beta10.License, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLicenseForAppVersion", appID, sequence) - ret0, _ := ret[0].(*v1beta1.License) + ret0, _ := ret[0].(*v1beta10.License) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -3967,7 +3998,7 @@ func (mr *MockLicenseStoreMockRecorder) GetLicenseForAppVersion(appID, sequence } // UpdateAppLicense mocks base method. -func (m *MockLicenseStore) UpdateAppLicense(appID string, sequence int64, archiveDir string, newLicense *v1beta1.License, originalLicenseData string, channelChanged, failOnVersionCreate bool, gitops types4.DownstreamGitOps, renderer types9.Renderer) (int64, error) { +func (m *MockLicenseStore) UpdateAppLicense(appID string, sequence int64, archiveDir string, newLicense *v1beta10.License, originalLicenseData string, channelChanged, failOnVersionCreate bool, gitops types4.DownstreamGitOps, renderer types9.Renderer) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateAppLicense", appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, gitops, renderer) ret0, _ := ret[0].(int64) @@ -4316,18 +4347,18 @@ func (mr *MockEmbeddedStoreMockRecorder) GetEmbeddedClusterAuthToken() *gomock.C } // GetEmbeddedClusterState mocks base method. -func (m *MockEmbeddedStore) GetEmbeddedClusterState(appID string) (string, error) { +func (m *MockEmbeddedStore) GetEmbeddedClusterState() (string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetEmbeddedClusterState", appID) + ret := m.ctrl.Call(m, "GetEmbeddedClusterState") ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // GetEmbeddedClusterState indicates an expected call of GetEmbeddedClusterState. -func (mr *MockEmbeddedStoreMockRecorder) GetEmbeddedClusterState(appID interface{}) *gomock.Call { +func (mr *MockEmbeddedStoreMockRecorder) GetEmbeddedClusterState() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterState", reflect.TypeOf((*MockEmbeddedStore)(nil).GetEmbeddedClusterState), appID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterState", reflect.TypeOf((*MockEmbeddedStore)(nil).GetEmbeddedClusterState)) } // SetEmbeddedClusterAuthToken mocks base method. diff --git a/pkg/store/store_interface.go b/pkg/store/store_interface.go index 529cb98b47..2b39321105 100644 --- a/pkg/store/store_interface.go +++ b/pkg/store/store_interface.go @@ -4,6 +4,7 @@ import ( "context" "time" + embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-operator/api/v1beta1" airgaptypes "github.com/replicatedhq/kots/pkg/airgap/types" downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" versiontypes "github.com/replicatedhq/kots/pkg/api/version/types" @@ -199,6 +200,7 @@ type VersionStore interface { GetNextAppSequence(appID string) (int64, error) GetCurrentUpdateCursor(appID string, channelID string) (string, error) HasStrictPreflights(appID string, sequence int64) (bool, error) + GetEmbeddedClusterConfigForVersion(appID string, sequence int64) (*embeddedclusterv1beta1.Config, error) } type LicenseStore interface { diff --git a/web/src/Root.tsx b/web/src/Root.tsx index 4144a5d2b0..e98834f54a 100644 --- a/web/src/Root.tsx +++ b/web/src/Root.tsx @@ -59,6 +59,7 @@ import SnapshotRestore from "@components/snapshots/SnapshotRestore"; import AppSnapshots from "@components/snapshots/AppSnapshots"; import AppSnapshotRestore from "@components/snapshots/AppSnapshotRestore"; import EmbeddedClusterViewNode from "@components/apps/EmbeddedClusterViewNode"; +import EmbeddedClusterUpgrading from "@components/clusters/EmbeddedClusterUpgrading"; // react-query client const queryClient = new QueryClient(); @@ -93,6 +94,7 @@ type State = { appSlugFromMetadata: string | null; adminConsoleMetadata: Metadata | null; connectionTerminated: boolean; + showClusterUpgradeModal: boolean; errLoggingOut: string; featureFlags: object; fetchingMetadata: boolean; @@ -119,6 +121,7 @@ const Root = () => { appNameSpace: null, adminConsoleMetadata: null, connectionTerminated: false, + showClusterUpgradeModal: false, errLoggingOut: "", featureFlags: {}, isHelmManaged: false, @@ -704,6 +707,11 @@ const Root = () => { isEmbeddedCluster={Boolean( state.adminConsoleMetadata?.isEmbeddedCluster )} + setShowClusterUpgradeModal={(showClusterUpgradeModal: boolean) => { + setState({ + showClusterUpgradeModal: showClusterUpgradeModal, + }); + }} /> } /> @@ -722,6 +730,11 @@ const Root = () => { isEmbeddedCluster={Boolean( state.adminConsoleMetadata?.isEmbeddedCluster )} + setShowClusterUpgradeModal={(showUpgradeModal: boolean) => { + setState({ + showClusterUpgradeModal: showUpgradeModal, + }); + }} /> } > @@ -851,13 +864,22 @@ const Root = () => { ariaHideApp={false} className="ConnectionTerminated--wrapper Modal DefaultSize" > - - setState({ connectionTerminated: status }) - } - /> + {!state.showClusterUpgradeModal && ( + + setState({ connectionTerminated: status }) + } + /> + )} + {state.showClusterUpgradeModal && ( + + setState({ connectionTerminated: status }) + } + /> + )} ); diff --git a/web/src/components/apps/AppDetailPage.tsx b/web/src/components/apps/AppDetailPage.tsx index 9e9cbf90cd..694f4c5bf3 100644 --- a/web/src/components/apps/AppDetailPage.tsx +++ b/web/src/components/apps/AppDetailPage.tsx @@ -7,7 +7,7 @@ import { HelmChartSidebarItem, KotsSidebarItem, } from "@src/components/watches/WatchSidebarItem"; -import { isAwaitingResults } from "../../utilities/utilities"; +import { Utilities, isAwaitingResults } from "../../utilities/utilities"; import SubNavBar from "@src/components/shared/SubNavBar"; import SidebarLayout from "../layout/SidebarLayout/SidebarLayout"; @@ -33,6 +33,7 @@ type Props = { refetchAppMetadata: () => void; snapshotInProgressApps: string[]; isEmbeddedCluster: boolean; + setShowClusterUpgradeModal: (showClusterUpgradeModal: boolean) => void; }; type State = { @@ -112,6 +113,8 @@ function AppDetailPage(props: Props) { loadingApp: true, }); } else { + const shouldShowUpgradeModal = Utilities.showClusterUpgradeModal(appsList); + props.setShowClusterUpgradeModal(shouldShowUpgradeModal); if (!appsIsError) { if (appsList?.length === 0 || !params.slug) { redirectToFirstAppOrInstall(); @@ -129,13 +132,20 @@ function AppDetailPage(props: Props) { }); } } else { + let gettingAppErrMsg = + appsError instanceof Error + ? appsError.message + : "Unexpected error when fetching apps"; + let displayErrorModal = true; + if (shouldShowUpgradeModal) { + // don't show apps error modal if cluster is upgrading + gettingAppErrMsg = ""; + displayErrorModal = false; + } setState({ loadingApp: false, - gettingAppErrMsg: - appsError instanceof Error - ? appsError.message - : "Unexpected error when fetching apps", - displayErrorModal: true, + gettingAppErrMsg: gettingAppErrMsg, + displayErrorModal: displayErrorModal, }); } } @@ -353,15 +363,16 @@ function AppDetailPage(props: Props) { ); - if (appIsFetching && !selectedApp) { + if (appIsFetching && !selectedApp && !Utilities.showClusterUpgradeModal(appsList)) { return centeredLoader; } - // poll version status if it's awaiting results + // poll version status if it's awaiting results or if the cluster is upgrading const downstream = selectedApp?.downstream; if ( - downstream?.currentVersion && - isAwaitingResults([downstream.currentVersion]) + (downstream?.currentVersion && + isAwaitingResults([downstream.currentVersion])) || + Utilities.showClusterUpgradeModal(appsList) ) { if (appsRefetchInterval === false) { setAppsRefetchInterval(2000); diff --git a/web/src/components/clusters/EmbeddedClusterUpgrading.tsx b/web/src/components/clusters/EmbeddedClusterUpgrading.tsx new file mode 100644 index 0000000000..be52ffbec9 --- /dev/null +++ b/web/src/components/clusters/EmbeddedClusterUpgrading.tsx @@ -0,0 +1,91 @@ +import { useEffect, useReducer } from "react"; +import fetch from "../../utilities/fetchWithTimeout"; +import { Utilities } from "../../utilities/utilities"; +import Loader from "@components/shared/Loader"; + +interface Props { + setTerminatedState: (terminated: boolean) => void; +} + +interface State { + seconds: number; + reconnectAttempts: number; +} + +const EmbeddedClusterUpgrading = (props: Props) => { + const [state, setState] = useReducer( + (currentState: State, newState: Partial) => ({ + ...currentState, + ...newState, + }), + { + seconds: 1, + reconnectAttempts: 1, + } + ); + + let countdown: (seconds: number) => void; + let ping: () => Promise; + + countdown = (seconds: number) => { + setState({ seconds }); + if (seconds === 0) { + setState({ + reconnectAttempts: state.reconnectAttempts + 1, + }); + ping(); + } else { + const nextCount = seconds - 1; + setTimeout(() => { + countdown(nextCount); + }, 1000); + } + }; + + ping = async () => { + const { reconnectAttempts } = state; + await fetch( + `${process.env.API_ENDPOINT}/ping`, + { + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }, + 10000 + ) + .then(async (res) => { + if (res.status === 401) { + Utilities.logoutUser(); + return; + } + props.setTerminatedState(false); + }) + .catch(() => { + props.setTerminatedState(true); + const seconds = reconnectAttempts > 10 ? 10 : reconnectAttempts; + countdown(seconds); + }); + }; + + useEffect(() => { + ping(); + }, []); + + return ( +
+
+ +
+

+ Cluster update in progress +

+

+ The API cannot be reached because the cluster is updating. Stay on this + page to automatically reconnect when the update is complete. +

+
+ ); +}; + +export default EmbeddedClusterUpgrading; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 50fcb26f4e..e48510820e 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -76,6 +76,8 @@ export type AppStatusState = type Cluster = { id: number; slug: string; + state?: string; + requiresUpgrade?: boolean; }; export type Credentials = { diff --git a/web/src/utilities/utilities.js b/web/src/utilities/utilities.js index 70ea2ba0f8..821b216a9b 100644 --- a/web/src/utilities/utilities.js +++ b/web/src/utilities/utilities.js @@ -636,6 +636,37 @@ export const Utilities = { } }, + isClusterUpgrading(state) { + const normalizedState = this.clusterState(state); + return ( + normalizedState === "Upgrading" || normalizedState === "Upgrading addons" + ); + }, + + showClusterUpgradeModal(apps) { + if (!apps || apps.length === 0) { + return false; + } + + // embedded cluster can only have one app + const app = apps[0]; + + const triedToDeploy = + app.downstream?.currentVersion?.status === "deploying" || + app.downstream?.currentVersion?.status === "deployed" || + app.downstream?.currentVersion?.status === "failed"; + if (!triedToDeploy) { + return false; + } + + // show the upgrade modal if the user has tried to deploy the current version + // and the cluster will upgrade or is already upgrading + return ( + app.downstream?.cluster?.requiresUpgrade || + Utilities.isClusterUpgrading(app.downstream?.cluster?.state) + ); + }, + // Converts string to titlecase i.e. 'hello' -> 'Hello' // @returns {String} toTitleCase(word) { diff --git a/web/src/utilities/utilities.test.js b/web/src/utilities/utilities.test.js index 238dbb574b..058ef8a406 100644 --- a/web/src/utilities/utilities.test.js +++ b/web/src/utilities/utilities.test.js @@ -14,4 +14,91 @@ describe("Utilities", () => { expect(Utilities.checkIsDateExpired(timestamp)).toBe(false); }); }); + + describe("showClusterUpgradeModal", () => { + it("should return false if apps is null or empty", () => { + expect(Utilities.showClusterUpgradeModal(null)).toBe(false); + expect(Utilities.showClusterUpgradeModal([])).toBe(false); + }); + + it("should return false if the user has not tried to deploy the current version", () => { + const apps = [ + { + downstream: { + currentVersion: { + status: "pending", + }, + }, + }, + ]; + expect(Utilities.showClusterUpgradeModal(apps)).toBe(false); + }); + + it("should return false if the user has tried to deploy the current version, but a cluster upgrade is not required", () => { + const apps = [ + { + downstream: { + currentVersion: { + status: "deployed", + }, + cluster: { + requiresUpgrade: false, + }, + }, + }, + ]; + expect(Utilities.showClusterUpgradeModal(apps)).toBe(false); + }); + + it("should return false if the user has tried to deploy the current version and a cluster upgrade is already completed", () => { + const apps = [ + { + downstream: { + currentVersion: { + status: "deployed", + }, + cluster: { + requiresUpgrade: false, + state: "Installed", + }, + }, + }, + ]; + expect(Utilities.showClusterUpgradeModal(apps)).toBe(false); + }); + + it("should return true if the user has tried to deploy the current version and a cluster upgrade is required", () => { + const apps = [ + { + downstream: { + currentVersion: { + status: "deployed", + }, + cluster: { + requiresUpgrade: true, + state: "Installed", + }, + }, + }, + ]; + expect(Utilities.showClusterUpgradeModal(apps)).toBe(true); + }); + + it("should return true if the user has tried to deploy the current version and a cluster upgrade is in progress", () => { + const apps = [ + { + downstream: { + currentVersion: { + status: "deployed", + }, + cluster: { + requiresUpgrade: true, + state: "Installing", + }, + }, + }, + ]; + expect(Utilities.showClusterUpgradeModal(apps)).toBe(true); + }); + }); }); From 7dcb328a370c6bf80ef65e6c4dcb52a865b125b2 Mon Sep 17 00:00:00 2001 From: Craig O'Donnell Date: Thu, 25 Jan 2024 18:20:38 +0000 Subject: [PATCH 2/5] run prettier --- web/src/Root.tsx | 4 +++- web/src/components/apps/AppDetailPage.tsx | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/web/src/Root.tsx b/web/src/Root.tsx index e98834f54a..c1c5485645 100644 --- a/web/src/Root.tsx +++ b/web/src/Root.tsx @@ -707,7 +707,9 @@ const Root = () => { isEmbeddedCluster={Boolean( state.adminConsoleMetadata?.isEmbeddedCluster )} - setShowClusterUpgradeModal={(showClusterUpgradeModal: boolean) => { + setShowClusterUpgradeModal={( + showClusterUpgradeModal: boolean + ) => { setState({ showClusterUpgradeModal: showClusterUpgradeModal, }); diff --git a/web/src/components/apps/AppDetailPage.tsx b/web/src/components/apps/AppDetailPage.tsx index 694f4c5bf3..d2338d32ba 100644 --- a/web/src/components/apps/AppDetailPage.tsx +++ b/web/src/components/apps/AppDetailPage.tsx @@ -113,7 +113,8 @@ function AppDetailPage(props: Props) { loadingApp: true, }); } else { - const shouldShowUpgradeModal = Utilities.showClusterUpgradeModal(appsList); + const shouldShowUpgradeModal = + Utilities.showClusterUpgradeModal(appsList); props.setShowClusterUpgradeModal(shouldShowUpgradeModal); if (!appsIsError) { if (appsList?.length === 0 || !params.slug) { @@ -363,7 +364,11 @@ function AppDetailPage(props: Props) { ); - if (appIsFetching && !selectedApp && !Utilities.showClusterUpgradeModal(appsList)) { + if ( + appIsFetching && + !selectedApp && + !Utilities.showClusterUpgradeModal(appsList) + ) { return centeredLoader; } From 655d6f8d13b0f1a6e714ff5bea282e44b94dfedb Mon Sep 17 00:00:00 2001 From: Craig O'Donnell Date: Thu, 25 Jan 2024 20:58:37 +0000 Subject: [PATCH 3/5] simplify embedded cluster upgrading component --- .../clusters/EmbeddedClusterUpgrading.tsx | 53 +++---------------- 1 file changed, 8 insertions(+), 45 deletions(-) diff --git a/web/src/components/clusters/EmbeddedClusterUpgrading.tsx b/web/src/components/clusters/EmbeddedClusterUpgrading.tsx index be52ffbec9..3471206d1d 100644 --- a/web/src/components/clusters/EmbeddedClusterUpgrading.tsx +++ b/web/src/components/clusters/EmbeddedClusterUpgrading.tsx @@ -1,5 +1,4 @@ -import { useEffect, useReducer } from "react"; -import fetch from "../../utilities/fetchWithTimeout"; +import { useEffect } from "react"; import { Utilities } from "../../utilities/utilities"; import Loader from "@components/shared/Loader"; @@ -7,43 +6,8 @@ interface Props { setTerminatedState: (terminated: boolean) => void; } -interface State { - seconds: number; - reconnectAttempts: number; -} - const EmbeddedClusterUpgrading = (props: Props) => { - const [state, setState] = useReducer( - (currentState: State, newState: Partial) => ({ - ...currentState, - ...newState, - }), - { - seconds: 1, - reconnectAttempts: 1, - } - ); - - let countdown: (seconds: number) => void; - let ping: () => Promise; - - countdown = (seconds: number) => { - setState({ seconds }); - if (seconds === 0) { - setState({ - reconnectAttempts: state.reconnectAttempts + 1, - }); - ping(); - } else { - const nextCount = seconds - 1; - setTimeout(() => { - countdown(nextCount); - }, 1000); - } - }; - - ping = async () => { - const { reconnectAttempts } = state; + const ping = async () => { await fetch( `${process.env.API_ENDPOINT}/ping`, { @@ -51,9 +15,7 @@ const EmbeddedClusterUpgrading = (props: Props) => { "Content-Type": "application/json", }, credentials: "include", - }, - 10000 - ) + }) .then(async (res) => { if (res.status === 401) { Utilities.logoutUser(); @@ -63,13 +25,14 @@ const EmbeddedClusterUpgrading = (props: Props) => { }) .catch(() => { props.setTerminatedState(true); - const seconds = reconnectAttempts > 10 ? 10 : reconnectAttempts; - countdown(seconds); }); }; useEffect(() => { - ping(); + const interval = setInterval(() => { + ping(); + }, 10000); + return () => clearInterval(interval); }, []); return ( @@ -88,4 +51,4 @@ const EmbeddedClusterUpgrading = (props: Props) => { ); }; -export default EmbeddedClusterUpgrading; +export default EmbeddedClusterUpgrading; \ No newline at end of file From ab2ae95f543b6f281d61e9666d970dbfdc77c64c Mon Sep 17 00:00:00 2001 From: Craig O'Donnell Date: Thu, 25 Jan 2024 21:47:12 +0000 Subject: [PATCH 4/5] rename function --- web/src/Root.tsx | 18 +++++++++--------- web/src/components/apps/AppDetailPage.tsx | 10 +++++----- web/src/utilities/utilities.js | 2 +- web/src/utilities/utilities.test.js | 16 ++++++++-------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/web/src/Root.tsx b/web/src/Root.tsx index c1c5485645..28d1909019 100644 --- a/web/src/Root.tsx +++ b/web/src/Root.tsx @@ -94,7 +94,7 @@ type State = { appSlugFromMetadata: string | null; adminConsoleMetadata: Metadata | null; connectionTerminated: boolean; - showClusterUpgradeModal: boolean; + shouldShowClusterUpgradeModal: boolean; errLoggingOut: string; featureFlags: object; fetchingMetadata: boolean; @@ -121,7 +121,7 @@ const Root = () => { appNameSpace: null, adminConsoleMetadata: null, connectionTerminated: false, - showClusterUpgradeModal: false, + shouldShowClusterUpgradeModal: false, errLoggingOut: "", featureFlags: {}, isHelmManaged: false, @@ -707,11 +707,11 @@ const Root = () => { isEmbeddedCluster={Boolean( state.adminConsoleMetadata?.isEmbeddedCluster )} - setShowClusterUpgradeModal={( - showClusterUpgradeModal: boolean + setShouldShowClusterUpgradeModal={( + shouldShowClusterUpgradeModal: boolean ) => { setState({ - showClusterUpgradeModal: showClusterUpgradeModal, + shouldShowClusterUpgradeModal: shouldShowClusterUpgradeModal, }); }} /> @@ -732,9 +732,9 @@ const Root = () => { isEmbeddedCluster={Boolean( state.adminConsoleMetadata?.isEmbeddedCluster )} - setShowClusterUpgradeModal={(showUpgradeModal: boolean) => { + setShouldShowClusterUpgradeModal={(showUpgradeModal: boolean) => { setState({ - showClusterUpgradeModal: showUpgradeModal, + shouldShowClusterUpgradeModal: showUpgradeModal, }); }} /> @@ -866,7 +866,7 @@ const Root = () => { ariaHideApp={false} className="ConnectionTerminated--wrapper Modal DefaultSize" > - {!state.showClusterUpgradeModal && ( + {!state.shouldShowClusterUpgradeModal && ( { } /> )} - {state.showClusterUpgradeModal && ( + {state.shouldShowClusterUpgradeModal && ( setState({ connectionTerminated: status }) diff --git a/web/src/components/apps/AppDetailPage.tsx b/web/src/components/apps/AppDetailPage.tsx index d2338d32ba..a85fc5a724 100644 --- a/web/src/components/apps/AppDetailPage.tsx +++ b/web/src/components/apps/AppDetailPage.tsx @@ -33,7 +33,7 @@ type Props = { refetchAppMetadata: () => void; snapshotInProgressApps: string[]; isEmbeddedCluster: boolean; - setShowClusterUpgradeModal: (showClusterUpgradeModal: boolean) => void; + setShouldShowClusterUpgradeModal: (shouldShowClusterUpgradeModal: boolean) => void; }; type State = { @@ -114,8 +114,8 @@ function AppDetailPage(props: Props) { }); } else { const shouldShowUpgradeModal = - Utilities.showClusterUpgradeModal(appsList); - props.setShowClusterUpgradeModal(shouldShowUpgradeModal); + Utilities.shouldShowClusterUpgradeModal(appsList); + props.setShouldShowClusterUpgradeModal(shouldShowUpgradeModal); if (!appsIsError) { if (appsList?.length === 0 || !params.slug) { redirectToFirstAppOrInstall(); @@ -367,7 +367,7 @@ function AppDetailPage(props: Props) { if ( appIsFetching && !selectedApp && - !Utilities.showClusterUpgradeModal(appsList) + !Utilities.shouldShowClusterUpgradeModal(appsList) ) { return centeredLoader; } @@ -377,7 +377,7 @@ function AppDetailPage(props: Props) { if ( (downstream?.currentVersion && isAwaitingResults([downstream.currentVersion])) || - Utilities.showClusterUpgradeModal(appsList) + Utilities.shouldShowClusterUpgradeModal(appsList) ) { if (appsRefetchInterval === false) { setAppsRefetchInterval(2000); diff --git a/web/src/utilities/utilities.js b/web/src/utilities/utilities.js index 821b216a9b..c17415556e 100644 --- a/web/src/utilities/utilities.js +++ b/web/src/utilities/utilities.js @@ -643,7 +643,7 @@ export const Utilities = { ); }, - showClusterUpgradeModal(apps) { + shouldShowClusterUpgradeModal(apps) { if (!apps || apps.length === 0) { return false; } diff --git a/web/src/utilities/utilities.test.js b/web/src/utilities/utilities.test.js index 058ef8a406..fbb5d90736 100644 --- a/web/src/utilities/utilities.test.js +++ b/web/src/utilities/utilities.test.js @@ -15,10 +15,10 @@ describe("Utilities", () => { }); }); - describe("showClusterUpgradeModal", () => { + describe("shouldShowClusterUpgradeModal", () => { it("should return false if apps is null or empty", () => { - expect(Utilities.showClusterUpgradeModal(null)).toBe(false); - expect(Utilities.showClusterUpgradeModal([])).toBe(false); + expect(Utilities.shouldShowClusterUpgradeModal(null)).toBe(false); + expect(Utilities.shouldShowClusterUpgradeModal([])).toBe(false); }); it("should return false if the user has not tried to deploy the current version", () => { @@ -31,7 +31,7 @@ describe("Utilities", () => { }, }, ]; - expect(Utilities.showClusterUpgradeModal(apps)).toBe(false); + expect(Utilities.shouldShowClusterUpgradeModal(apps)).toBe(false); }); it("should return false if the user has tried to deploy the current version, but a cluster upgrade is not required", () => { @@ -47,7 +47,7 @@ describe("Utilities", () => { }, }, ]; - expect(Utilities.showClusterUpgradeModal(apps)).toBe(false); + expect(Utilities.shouldShowClusterUpgradeModal(apps)).toBe(false); }); it("should return false if the user has tried to deploy the current version and a cluster upgrade is already completed", () => { @@ -64,7 +64,7 @@ describe("Utilities", () => { }, }, ]; - expect(Utilities.showClusterUpgradeModal(apps)).toBe(false); + expect(Utilities.shouldShowClusterUpgradeModal(apps)).toBe(false); }); it("should return true if the user has tried to deploy the current version and a cluster upgrade is required", () => { @@ -81,7 +81,7 @@ describe("Utilities", () => { }, }, ]; - expect(Utilities.showClusterUpgradeModal(apps)).toBe(true); + expect(Utilities.shouldShowClusterUpgradeModal(apps)).toBe(true); }); it("should return true if the user has tried to deploy the current version and a cluster upgrade is in progress", () => { @@ -98,7 +98,7 @@ describe("Utilities", () => { }, }, ]; - expect(Utilities.showClusterUpgradeModal(apps)).toBe(true); + expect(Utilities.shouldShowClusterUpgradeModal(apps)).toBe(true); }); }); }); From 55db08a441a50f701ab5b30d11bc11320c960aed Mon Sep 17 00:00:00 2001 From: Craig O'Donnell Date: Thu, 25 Jan 2024 21:50:53 +0000 Subject: [PATCH 5/5] run prettier again --- web/src/Root.tsx | 7 +++++-- web/src/components/apps/AppDetailPage.tsx | 4 +++- .../clusters/EmbeddedClusterUpgrading.tsx | 16 +++++++--------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/web/src/Root.tsx b/web/src/Root.tsx index 28d1909019..35110e0db9 100644 --- a/web/src/Root.tsx +++ b/web/src/Root.tsx @@ -711,7 +711,8 @@ const Root = () => { shouldShowClusterUpgradeModal: boolean ) => { setState({ - shouldShowClusterUpgradeModal: shouldShowClusterUpgradeModal, + shouldShowClusterUpgradeModal: + shouldShowClusterUpgradeModal, }); }} /> @@ -732,7 +733,9 @@ const Root = () => { isEmbeddedCluster={Boolean( state.adminConsoleMetadata?.isEmbeddedCluster )} - setShouldShowClusterUpgradeModal={(showUpgradeModal: boolean) => { + setShouldShowClusterUpgradeModal={( + showUpgradeModal: boolean + ) => { setState({ shouldShowClusterUpgradeModal: showUpgradeModal, }); diff --git a/web/src/components/apps/AppDetailPage.tsx b/web/src/components/apps/AppDetailPage.tsx index a85fc5a724..6135256c2b 100644 --- a/web/src/components/apps/AppDetailPage.tsx +++ b/web/src/components/apps/AppDetailPage.tsx @@ -33,7 +33,9 @@ type Props = { refetchAppMetadata: () => void; snapshotInProgressApps: string[]; isEmbeddedCluster: boolean; - setShouldShowClusterUpgradeModal: (shouldShowClusterUpgradeModal: boolean) => void; + setShouldShowClusterUpgradeModal: ( + shouldShowClusterUpgradeModal: boolean + ) => void; }; type State = { diff --git a/web/src/components/clusters/EmbeddedClusterUpgrading.tsx b/web/src/components/clusters/EmbeddedClusterUpgrading.tsx index 3471206d1d..82e355c229 100644 --- a/web/src/components/clusters/EmbeddedClusterUpgrading.tsx +++ b/web/src/components/clusters/EmbeddedClusterUpgrading.tsx @@ -8,14 +8,12 @@ interface Props { const EmbeddedClusterUpgrading = (props: Props) => { const ping = async () => { - await fetch( - `${process.env.API_ENDPOINT}/ping`, - { - headers: { - "Content-Type": "application/json", - }, - credentials: "include", - }) + await fetch(`${process.env.API_ENDPOINT}/ping`, { + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }) .then(async (res) => { if (res.status === 401) { Utilities.logoutUser(); @@ -51,4 +49,4 @@ const EmbeddedClusterUpgrading = (props: Props) => { ); }; -export default EmbeddedClusterUpgrading; \ No newline at end of file +export default EmbeddedClusterUpgrading;