From 60387264710fa1eb120592143c4e6929241438b9 Mon Sep 17 00:00:00 2001 From: JGAntunes Date: Wed, 8 Oct 2025 14:39:14 +0100 Subject: [PATCH 01/10] feat(web): perserve installation progress --- api/client/client_test.go | 2 +- api/docs/docs.go | 2 +- api/docs/swagger.json | 2 +- api/docs/swagger.yaml | 2 +- api/internal/handlers/utils/utils_test.go | 14 ++-- api/types/errors.go | 2 +- web/src/App.tsx | 41 +++++----- web/src/components/wizard/InstallWizard.tsx | 5 +- .../wizard/config/ConfigurationStep.tsx | 69 +++++++---------- .../wizard/installation/InstallationStep.tsx | 32 ++++++-- .../installation/InstallationTimeline.tsx | 4 +- .../phases/AppInstallationPhase.tsx | 4 +- .../installation/phases/AppPreflightCheck.tsx | 14 +--- .../installation/phases/AppPreflightPhase.tsx | 4 +- .../phases/KubernetesInstallationPhase.tsx | 36 ++++++++- .../phases/LinuxInstallationPhase.tsx | 63 ++++++++++----- .../phases/LinuxPreflightCheck.tsx | 17 ++-- .../phases/LinuxPreflightPhase.tsx | 4 +- .../wizard/setup/KubernetesSetupStep.tsx | 19 +---- .../wizard/setup/LinuxSetupStep.tsx | 68 ++++++++-------- .../wizard/tests/ConfigurationStep.test.tsx | 10 +-- .../contexts/InstallationProgressContext.tsx | 25 ++++++ .../InstallationProgressProvider.tsx | 77 +++++++++++++++++++ web/src/types/index.ts | 16 ++++ web/src/utils/api-error.ts | 34 ++++++++ web/src/utils/auth.ts | 53 ++++++++++--- 26 files changed, 428 insertions(+), 191 deletions(-) create mode 100644 web/src/contexts/InstallationProgressContext.tsx create mode 100644 web/src/providers/InstallationProgressProvider.tsx create mode 100644 web/src/utils/api-error.ts diff --git a/api/client/client_test.go b/api/client/client_test.go index f8d7f39869..f037738914 100644 --- a/api/client/client_test.go +++ b/api/client/client_test.go @@ -566,7 +566,7 @@ func TestErrorFromResponse(t *testing.T) { // Create a response with an error resp := &http.Response{ StatusCode: http.StatusBadRequest, - Body: io.NopCloser(bytes.NewBufferString(`{"status_code": 400, "message": "Bad Request"}`)), + Body: io.NopCloser(bytes.NewBufferString(`{"statusCode": 400, "message": "Bad Request"}`)), } err := errorFromResponse(resp) diff --git a/api/docs/docs.go b/api/docs/docs.go index 2d9a41407b..c17658eb82 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"type":"object"},"types.AppConfigValue":{"properties":{"data":{"type":"string"},"dataPlaintext":{"type":"string"},"default":{"type":"string"},"filename":{"type":"string"},"repeatableItem":{"type":"string"},"value":{"type":"string"},"valuePlaintext":{"type":"string"}},"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.AppInstall":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AppUpgrade":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.KubernetesInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"values":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}},"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"type":"object"},"types.LinuxInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.LinuxInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"values":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}},"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"strict":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.UpgradeAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.UpgradeAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, + "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"statusCode":{"type":"integer"}},"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"type":"object"},"types.AppConfigValue":{"properties":{"data":{"type":"string"},"dataPlaintext":{"type":"string"},"default":{"type":"string"},"filename":{"type":"string"},"repeatableItem":{"type":"string"},"value":{"type":"string"},"valuePlaintext":{"type":"string"}},"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.AppInstall":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AppUpgrade":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.KubernetesInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"values":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}},"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"type":"object"},"types.LinuxInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.LinuxInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"values":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}},"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"strict":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.UpgradeAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.UpgradeAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, "info": {"contact":{"email":"support@replicated.com","name":"API Support","url":"https://github.com/replicatedhq/embedded-cluster/issues"},"description":"{{escape .Description}}","license":{"name":"Apache 2.0","url":"http://www.apache.org/licenses/LICENSE-2.0.html"},"termsOfService":"http://swagger.io/terms/","title":"{{.Title}}","version":"{{.Version}}"}, "externalDocs": {"description":"OpenAPI","url":"https://swagger.io/resources/open-api/"}, "paths": {"/auth/login":{"post":{"description":"Authenticate a user","operationId":"postAuthLogin","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthRequest"}}},"description":"Auth Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthResponse"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"}},"summary":"Authenticate a user","tags":["auth"]}},"/console/available-network-interfaces":{"get":{"description":"List available network interfaces","operationId":"getConsoleListAvailableNetworkInterfaces","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.GetListAvailableNetworkInterfacesResponse"}}},"description":"OK"}},"summary":"List available network interfaces","tags":["console"]}},"/health":{"get":{"description":"get the health of the API","operationId":"getHealth","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/github_com_replicatedhq_embedded-cluster_api_types.Health"}}},"description":"OK"}},"summary":"Get the health of the API","tags":["health"]}},"/kubernetes/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postKubernetesInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["kubernetes-install"]}},"/kubernetes/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getKubernetesInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postKubernetesInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getKubernetesInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["kubernetes-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchKubernetesInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["kubernetes-install"]}},"/kubernetes/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postKubernetesInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["kubernetes-install"]}},"/kubernetes/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getKubernetesInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["kubernetes-install"]}},"/kubernetes/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postKubernetesInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["kubernetes-install"]}},"/kubernetes/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getKubernetesInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["kubernetes-install"]}},"/kubernetes/install/installation/config":{"get":{"description":"get the Kubernetes installation config","operationId":"getKubernetesInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfigResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the Kubernetes installation config","tags":["kubernetes-install"]}},"/kubernetes/install/installation/configure":{"post":{"description":"configure the Kubernetes installation for install","operationId":"postKubernetesInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Configure the Kubernetes installation for install","tags":["kubernetes-install"]}},"/kubernetes/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getKubernetesInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["kubernetes-install"]}},"/kubernetes/upgrade/app-preflights/run":{"post":{"description":"Run upgrade app preflight checks using current app configuration","operationId":"postKubernetesUpgradeRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run upgrade app preflight checks","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for upgrade","operationId":"getKubernetesUpgradeAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/config/template":{"post":{"description":"Template the app configuration with values for upgrade","operationId":"postKubernetesUpgradeAppConfigTemplate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template app config for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/config/values":{"get":{"description":"Get the current app config values for upgrade","operationId":"getKubernetesUpgradeAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values for upgrade","tags":["kubernetes-upgrade"]},"patch":{"description":"Set the app config values with partial updates for upgrade","operationId":"patchKubernetesUpgradeAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/status":{"get":{"description":"Get the current status of app upgrade","operationId":"getKubernetesUpgradeAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app upgrade status","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/upgrade":{"post":{"description":"Upgrade the app using current configuration","operationId":"postKubernetesUpgradeApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppRequest"}}},"description":"Upgrade App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Upgrade the app","tags":["kubernetes-upgrade"]}},"/linux/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postLinuxInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["linux-install"]}},"/linux/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getLinuxInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["linux-install"]}},"/linux/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postLinuxInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["linux-install"]}},"/linux/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getLinuxInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["linux-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchLinuxInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["linux-install"]}},"/linux/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postLinuxInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["linux-install"]}},"/linux/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getLinuxInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["linux-install"]}},"/linux/install/host-preflights/run":{"post":{"description":"Run install host preflight checks using installation config and client-provided data","operationId":"postLinuxInstallRunHostPreflights","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PostInstallRunHostPreflightsRequest"}}},"description":"Post Install Run Host Preflights Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Run install host preflight checks","tags":["linux-install"]}},"/linux/install/host-preflights/status":{"get":{"description":"Get the current status and results of host preflight checks for install","operationId":"getLinuxInstallHostPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get host preflight status for install","tags":["linux-install"]}},"/linux/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postLinuxInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInfraSetupRequest"}}},"description":"Infra Setup Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["linux-install"]}},"/linux/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getLinuxInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["linux-install"]}},"/linux/install/installation/config":{"get":{"description":"get the installation config","operationId":"getLinuxInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfigResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the installation config","tags":["linux-install"]}},"/linux/install/installation/configure":{"post":{"description":"configure the installation for install","operationId":"postLinuxInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Configure the installation for install","tags":["linux-install"]}},"/linux/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getLinuxInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["linux-install"]}},"/linux/upgrade/app-preflights/run":{"post":{"description":"Run upgrade app preflight checks using current app configuration","operationId":"postLinuxUpgradeRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run upgrade app preflight checks","tags":["linux-upgrade"]}},"/linux/upgrade/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for upgrade","operationId":"getLinuxUpgradeAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/config/template":{"post":{"description":"Template the app configuration with values for upgrade","operationId":"postLinuxUpgradeAppConfigTemplate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template app config for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/config/values":{"get":{"description":"Get the current app config values for upgrade","operationId":"getLinuxUpgradeAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values for upgrade","tags":["linux-upgrade"]},"patch":{"description":"Set the app config values with partial updates for upgrade","operationId":"patchLinuxUpgradeAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/status":{"get":{"description":"Get the current status of app upgrade","operationId":"getLinuxUpgradeAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app upgrade status","tags":["linux-upgrade"]}},"/linux/upgrade/app/upgrade":{"post":{"description":"Upgrade the app using current configuration","operationId":"postLinuxUpgradeApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppRequest"}}},"description":"Upgrade App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Upgrade the app","tags":["linux-upgrade"]}}}, diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 1d2d6f34bf..4244948e7f 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1,5 +1,5 @@ { - "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"type":"object"},"types.AppConfigValue":{"properties":{"data":{"type":"string"},"dataPlaintext":{"type":"string"},"default":{"type":"string"},"filename":{"type":"string"},"repeatableItem":{"type":"string"},"value":{"type":"string"},"valuePlaintext":{"type":"string"}},"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.AppInstall":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AppUpgrade":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.KubernetesInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"values":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}},"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"type":"object"},"types.LinuxInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.LinuxInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"values":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}},"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"strict":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.UpgradeAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.UpgradeAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, + "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"statusCode":{"type":"integer"}},"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"type":"object"},"types.AppConfigValue":{"properties":{"data":{"type":"string"},"dataPlaintext":{"type":"string"},"default":{"type":"string"},"filename":{"type":"string"},"repeatableItem":{"type":"string"},"value":{"type":"string"},"valuePlaintext":{"type":"string"}},"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.AppInstall":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AppUpgrade":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.KubernetesInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"values":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}},"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"type":"object"},"types.LinuxInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.LinuxInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"values":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}},"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"strict":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.UpgradeAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.UpgradeAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, "info": {"contact":{"email":"support@replicated.com","name":"API Support","url":"https://github.com/replicatedhq/embedded-cluster/issues"},"description":"This is the API for the Embedded Cluster project.","license":{"name":"Apache 2.0","url":"http://www.apache.org/licenses/LICENSE-2.0.html"},"termsOfService":"http://swagger.io/terms/","title":"Embedded Cluster API","version":"0.1"}, "externalDocs": {"description":"OpenAPI","url":"https://swagger.io/resources/open-api/"}, "paths": {"/auth/login":{"post":{"description":"Authenticate a user","operationId":"postAuthLogin","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthRequest"}}},"description":"Auth Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthResponse"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"}},"summary":"Authenticate a user","tags":["auth"]}},"/console/available-network-interfaces":{"get":{"description":"List available network interfaces","operationId":"getConsoleListAvailableNetworkInterfaces","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.GetListAvailableNetworkInterfacesResponse"}}},"description":"OK"}},"summary":"List available network interfaces","tags":["console"]}},"/health":{"get":{"description":"get the health of the API","operationId":"getHealth","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/github_com_replicatedhq_embedded-cluster_api_types.Health"}}},"description":"OK"}},"summary":"Get the health of the API","tags":["health"]}},"/kubernetes/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postKubernetesInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["kubernetes-install"]}},"/kubernetes/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getKubernetesInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postKubernetesInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getKubernetesInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["kubernetes-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchKubernetesInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["kubernetes-install"]}},"/kubernetes/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postKubernetesInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["kubernetes-install"]}},"/kubernetes/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getKubernetesInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["kubernetes-install"]}},"/kubernetes/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postKubernetesInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["kubernetes-install"]}},"/kubernetes/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getKubernetesInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["kubernetes-install"]}},"/kubernetes/install/installation/config":{"get":{"description":"get the Kubernetes installation config","operationId":"getKubernetesInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfigResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the Kubernetes installation config","tags":["kubernetes-install"]}},"/kubernetes/install/installation/configure":{"post":{"description":"configure the Kubernetes installation for install","operationId":"postKubernetesInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Configure the Kubernetes installation for install","tags":["kubernetes-install"]}},"/kubernetes/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getKubernetesInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["kubernetes-install"]}},"/kubernetes/upgrade/app-preflights/run":{"post":{"description":"Run upgrade app preflight checks using current app configuration","operationId":"postKubernetesUpgradeRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run upgrade app preflight checks","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for upgrade","operationId":"getKubernetesUpgradeAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/config/template":{"post":{"description":"Template the app configuration with values for upgrade","operationId":"postKubernetesUpgradeAppConfigTemplate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template app config for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/config/values":{"get":{"description":"Get the current app config values for upgrade","operationId":"getKubernetesUpgradeAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values for upgrade","tags":["kubernetes-upgrade"]},"patch":{"description":"Set the app config values with partial updates for upgrade","operationId":"patchKubernetesUpgradeAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/status":{"get":{"description":"Get the current status of app upgrade","operationId":"getKubernetesUpgradeAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app upgrade status","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/upgrade":{"post":{"description":"Upgrade the app using current configuration","operationId":"postKubernetesUpgradeApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppRequest"}}},"description":"Upgrade App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Upgrade the app","tags":["kubernetes-upgrade"]}},"/linux/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postLinuxInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["linux-install"]}},"/linux/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getLinuxInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["linux-install"]}},"/linux/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postLinuxInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["linux-install"]}},"/linux/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getLinuxInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["linux-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchLinuxInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["linux-install"]}},"/linux/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postLinuxInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["linux-install"]}},"/linux/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getLinuxInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["linux-install"]}},"/linux/install/host-preflights/run":{"post":{"description":"Run install host preflight checks using installation config and client-provided data","operationId":"postLinuxInstallRunHostPreflights","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PostInstallRunHostPreflightsRequest"}}},"description":"Post Install Run Host Preflights Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Run install host preflight checks","tags":["linux-install"]}},"/linux/install/host-preflights/status":{"get":{"description":"Get the current status and results of host preflight checks for install","operationId":"getLinuxInstallHostPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get host preflight status for install","tags":["linux-install"]}},"/linux/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postLinuxInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInfraSetupRequest"}}},"description":"Infra Setup Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["linux-install"]}},"/linux/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getLinuxInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["linux-install"]}},"/linux/install/installation/config":{"get":{"description":"get the installation config","operationId":"getLinuxInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfigResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the installation config","tags":["linux-install"]}},"/linux/install/installation/configure":{"post":{"description":"configure the installation for install","operationId":"postLinuxInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Configure the installation for install","tags":["linux-install"]}},"/linux/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getLinuxInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["linux-install"]}},"/linux/upgrade/app-preflights/run":{"post":{"description":"Run upgrade app preflight checks using current app configuration","operationId":"postLinuxUpgradeRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run upgrade app preflight checks","tags":["linux-upgrade"]}},"/linux/upgrade/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for upgrade","operationId":"getLinuxUpgradeAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/config/template":{"post":{"description":"Template the app configuration with values for upgrade","operationId":"postLinuxUpgradeAppConfigTemplate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template app config for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/config/values":{"get":{"description":"Get the current app config values for upgrade","operationId":"getLinuxUpgradeAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values for upgrade","tags":["linux-upgrade"]},"patch":{"description":"Set the app config values with partial updates for upgrade","operationId":"patchLinuxUpgradeAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/status":{"get":{"description":"Get the current status of app upgrade","operationId":"getLinuxUpgradeAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app upgrade status","tags":["linux-upgrade"]}},"/linux/upgrade/app/upgrade":{"post":{"description":"Upgrade the app using current configuration","operationId":"postLinuxUpgradeApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppRequest"}}},"description":"Upgrade App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Upgrade the app","tags":["linux-upgrade"]}}}, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index f8980eb2c3..511f4bd900 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -18,7 +18,7 @@ components: type: string message: type: string - status_code: + statusCode: type: integer type: object types.AppConfig: diff --git a/api/internal/handlers/utils/utils_test.go b/api/internal/handlers/utils/utils_test.go index 3d6e26ca2c..0d42939d1b 100644 --- a/api/internal/handlers/utils/utils_test.go +++ b/api/internal/handlers/utils/utils_test.go @@ -26,8 +26,8 @@ func TestAPI_jsonError(t *testing.T) { }, wantCode: http.StatusInternalServerError, wantJSON: map[string]any{ - "status_code": float64(http.StatusInternalServerError), - "message": "invalid request", + "statusCode": float64(http.StatusInternalServerError), + "message": "invalid request", }, }, { @@ -39,9 +39,9 @@ func TestAPI_jsonError(t *testing.T) { }, wantCode: http.StatusBadRequest, wantJSON: map[string]any{ - "status_code": float64(http.StatusBadRequest), - "message": "validation error", - "field": "username", + "statusCode": float64(http.StatusBadRequest), + "message": "validation error", + "field": "username", }, }, { @@ -62,8 +62,8 @@ func TestAPI_jsonError(t *testing.T) { }, wantCode: http.StatusBadRequest, wantJSON: map[string]any{ - "status_code": float64(http.StatusBadRequest), - "message": "multiple validation errors", + "statusCode": float64(http.StatusBadRequest), + "message": "multiple validation errors", "errors": []any{ map[string]any{ "message": "field1 is required", diff --git a/api/types/errors.go b/api/types/errors.go index 031cd8f214..d1e1c6eda7 100644 --- a/api/types/errors.go +++ b/api/types/errors.go @@ -9,7 +9,7 @@ import ( ) type APIError struct { - StatusCode int `json:"status_code,omitempty"` + StatusCode int `json:"statusCode,omitempty"` Message string `json:"message"` Field string `json:"field,omitempty"` Errors []*APIError `json:"errors,omitempty"` diff --git a/web/src/App.tsx b/web/src/App.tsx index b7a181de06..338734f741 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { SettingsProvider } from "./providers/SettingsProvider"; import { WizardProvider } from "./providers/WizardProvider"; import { InitialStateProvider } from "./providers/InitialStateProvider"; import { AuthProvider } from "./providers/AuthProvider"; +import { InstallationProgressProvider } from "./providers/InstallationProgressProvider"; import ConnectionMonitor from "./components/common/ConnectionMonitor"; import InstallWizard from "./components/wizard/InstallWizard"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -17,25 +18,27 @@ function App() { - - -
- - - - - - } - /> - } /> - - -
-
-
+ + + +
+ + + + + + } + /> + } /> + + +
+
+
+
diff --git a/web/src/components/wizard/InstallWizard.tsx b/web/src/components/wizard/InstallWizard.tsx index 84a5edb7a8..114484cac9 100644 --- a/web/src/components/wizard/InstallWizard.tsx +++ b/web/src/components/wizard/InstallWizard.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React from "react"; import StepNavigation from "./StepNavigation"; import WelcomeStep from "./WelcomeStep"; import ConfigurationStep from "./config/ConfigurationStep"; @@ -11,9 +11,10 @@ import KubernetesCompletionStep from "./completion/KubernetesCompletionStep"; import { WizardStep } from "../../types"; import { AppIcon } from "../common/Logo"; import { useWizard } from "../../contexts/WizardModeContext"; +import { useInstallationProgress } from "../../contexts/InstallationProgressContext"; const InstallWizard: React.FC = () => { - const [currentStep, setCurrentStep] = useState("welcome"); + const { wizardStep: currentStep, setWizardStep: setCurrentStep } = useInstallationProgress(); const { text, target, mode } = useWizard(); let steps: WizardStep[] = [] diff --git a/web/src/components/wizard/config/ConfigurationStep.tsx b/web/src/components/wizard/config/ConfigurationStep.tsx index 51517357f8..aadd801fa3 100644 --- a/web/src/components/wizard/config/ConfigurationStep.tsx +++ b/web/src/components/wizard/config/ConfigurationStep.tsx @@ -14,20 +14,17 @@ import { useWizard } from '../../../contexts/WizardModeContext'; import { useAuth } from '../../../contexts/AuthContext'; import { useSettings } from '../../../contexts/SettingsContext'; import { ChevronRight, Loader2 } from 'lucide-react'; -import { handleUnauthorized } from '../../../utils/auth'; import { useDebouncedFetch } from '../../../utils/debouncedFetch'; import { AppConfig, AppConfigGroup, AppConfigItem, AppConfigValues } from '../../../types'; import { getApiBase } from '../../../utils/api-base'; +import { ApiError } from '../../../utils/api-error'; +import { handleUnauthorized } from '../../../utils/auth'; interface ConfigurationStepProps { onNext: () => void; } -interface ConfigError extends Error { - errors?: { field: string; message: string }[]; -} - const ConfigurationStep: React.FC = ({ onNext }) => { const { text, target, mode } = useWizard(); const { token } = useAuth(); @@ -38,18 +35,18 @@ const ConfigurationStep: React.FC = ({ onNext }) => { const [generalError, setGeneralError] = useState(null); const [isLoading, setIsLoading] = useState(true); const { debouncedFetch } = useDebouncedFetch({ debounceMs: 250 }); - + const [itemErrors, setItemErrors] = useState>({}); const [itemToFocus, setItemToFocus] = useState(null); - + // Holds refs to each item by name for focusing const itemRefs = useRef>({}); - + // Helper function to assign refs dynamically const setRef = (name: string) => (el: HTMLElement | null) => { itemRefs.current[name] = el; }; - + const themeColor = settings.themeColor; const templateConfig = useCallback(async (values: AppConfigValues) => { @@ -72,17 +69,15 @@ const ConfigurationStep: React.FC = ({ onNext }) => { } if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (response.status === 401) { - handleUnauthorized(errorData); - throw new Error('Session expired. Please log in again.'); - } - throw new Error(errorData.message || 'Failed to template configuration'); + const apiErr = await ApiError.fromResponse(response, 'Failed to template configuration') + handleUnauthorized(apiErr) + throw apiErr } const config = await response.json(); setAppConfig(config); } catch (error) { + console.log(error) setGeneralError(error instanceof Error ? error.message : String(error)); } }, [target, mode, token, debouncedFetch]); @@ -102,7 +97,7 @@ const ConfigurationStep: React.FC = ({ onNext }) => { if (!appConfig?.groups || Object.keys(itemErrors).length === 0) { return null; } - + // Iterate through groups and items in DOM order to find first item with error for (const group of appConfig.groups) { for (const item of group.items) { @@ -111,15 +106,15 @@ const ConfigurationStep: React.FC = ({ onNext }) => { } } } - + return null; }; // Helper function to find which group/tab contains a specific item const findGroupForItem = (itemName: string): AppConfigGroup | null => { if (!appConfig?.groups) return null; - - return appConfig.groups.find(group => + + return appConfig.groups.find(group => group.items.some(item => item.name === itemName) ) || null; }; @@ -127,38 +122,38 @@ const ConfigurationStep: React.FC = ({ onNext }) => { // Helper function to focus on an item with tab switching support const focusItemWithTabSupport = (item: AppConfigItem): void => { const targetGroup = findGroupForItem(item.name); - + if (!targetGroup) { console.warn(`Could not find group for item: ${item.name}`); return; } - + // Switch to the correct tab if item is in a different tab if (targetGroup.name !== activeTab) { setActiveTab(targetGroup.name); } - + // Set the item to focus - useEffect will handle the actual focusing setItemToFocus(item); }; // Helper function to parse server validation errors from API response - const parseServerErrors = (error: ConfigError): Record => { + const parseServerErrors = (error: ApiError): Record => { const itemErrors: Record = {}; - + // Check if error has structured item errors - if (error.errors) { - error.errors.forEach((itemError) => { + if (error.fieldErrors) { + error.fieldErrors.forEach((itemError) => { // Pass through server error message directly - no client-side enhancement itemErrors[itemError.field] = itemError.message; }); } - + return itemErrors; }; // Mutation to save config values - const { mutate: submitConfigValues } = useMutation({ + const { mutate: submitConfigValues } = useMutation({ mutationFn: async () => { const apiBase = getApiBase(target, mode); const response = await fetch(`${apiBase}/app/config/values`, { @@ -171,15 +166,7 @@ const ConfigurationStep: React.FC = ({ onNext }) => { }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (response.status === 401) { - handleUnauthorized(errorData); - throw new Error('Session expired. Please log in again.'); - } - // Re-throw with full error data for parsing in onError - const error = new Error(errorData.message || 'Failed to save configuration') as ConfigError; - error.errors = errorData.errors; - throw error; + throw await ApiError.fromResponse(response, "Failed to save configuration") } }, onSuccess: () => { @@ -190,10 +177,10 @@ const ConfigurationStep: React.FC = ({ onNext }) => { // Proceed to next step (preflights for both install and upgrade modes) onNext(); }, - onError: (error: ConfigError) => { + onError: (error: ApiError) => { const parsedItemErrors = parseServerErrors(error); setItemErrors(parsedItemErrors); - setGeneralError(error?.message || 'Failed to save configuration'); + setGeneralError(error?.details || error?.message || 'Failed to save configuration'); // Focus on the first item with validation error const firstErrorItem = findFirstItemWithError(parsedItemErrors); @@ -216,11 +203,11 @@ const ConfigurationStep: React.FC = ({ onNext }) => { // Use refs to get the focusable element directly let itemElement: HTMLElement | null = null; - + // For all inputs including radio, use the main item ref // Radio component forwards ref to the first option automatically itemElement = itemRefs.current[itemToFocus.name]; - + if (itemElement) { itemElement.focus(); // Scroll the element into view to ensure it's visible diff --git a/web/src/components/wizard/installation/InstallationStep.tsx b/web/src/components/wizard/installation/InstallationStep.tsx index 1796c50caa..2b179ed507 100644 --- a/web/src/components/wizard/installation/InstallationStep.tsx +++ b/web/src/components/wizard/installation/InstallationStep.tsx @@ -3,9 +3,10 @@ import Card from '../../common/Card'; import Button from '../../common/Button'; import { useWizard } from '../../../contexts/WizardModeContext'; import { useSettings } from '../../../contexts/SettingsContext'; -import { State } from '../../../types'; +import { useInstallationProgress } from '../../../contexts/InstallationProgressContext'; +import { State, InstallationPhaseId as InstallationPhase } from '../../../types'; import { ChevronLeft, ChevronRight } from 'lucide-react'; -import InstallationTimeline, { InstallationPhaseId as InstallationPhase, PhaseStatus } from './InstallationTimeline'; +import InstallationTimeline, { PhaseStatus } from './InstallationTimeline'; import LinuxPreflightPhase from './phases/LinuxPreflightPhase'; import AppPreflightPhase from './phases/AppPreflightPhase'; import LinuxInstallationPhase from './phases/LinuxInstallationPhase'; @@ -21,6 +22,7 @@ interface InstallationStepProps { const InstallationStep: React.FC = ({ onNext, onBack }) => { const { target, text } = useWizard(); const { settings } = useSettings(); + const { installationPhase: storedPhase, setInstallationPhase } = useInstallationProgress(); const themeColor = settings.themeColor; const getPhaseOrder = (): InstallationPhase[] => { @@ -31,9 +33,29 @@ const InstallationStep: React.FC = ({ onNext, onBack }) = }; const phaseOrder = getPhaseOrder(); - const [currentPhase, setCurrentPhase] = useState(phaseOrder[0]); - const [selectedPhase, setSelectedPhase] = useState(phaseOrder[0]); - const [completedPhases, setCompletedPhases] = useState>(new Set()); + const completedPhaseSet = new Set(); + + // If we have a stored phase then we need to set all the completed phases before too + if (storedPhase) { + const completedPhases = phaseOrder.slice(0, phaseOrder.indexOf(storedPhase)) + completedPhases.forEach(phase => completedPhaseSet.add(phase)) + } + + // If we have a stored phase use it + const initialPhase = storedPhase || phaseOrder[0]; + + // Initialize currentPhase from context or default to first phase + const [currentPhase, setCurrentPhaseState] = useState(initialPhase); + + // Selected phase for UI (can be current or any completed phase) + const [selectedPhase, setSelectedPhase] = useState(initialPhase); + + // Wrapper for setCurrentPhase that also updates context + const setCurrentPhase = useCallback((phase: InstallationPhase) => { + setCurrentPhaseState(phase); + setInstallationPhase(phase); + }, [setInstallationPhase]); + const [completedPhases, setCompletedPhases] = useState>(completedPhaseSet); const [nextButtonConfig, setNextButtonConfig] = useState(null); const [backButtonConfig, setBackButtonConfig] = useState(null); const nextButtonRef = useRef(null); diff --git a/web/src/components/wizard/installation/InstallationTimeline.tsx b/web/src/components/wizard/installation/InstallationTimeline.tsx index 330c83e7d1..6bbae66e20 100644 --- a/web/src/components/wizard/installation/InstallationTimeline.tsx +++ b/web/src/components/wizard/installation/InstallationTimeline.tsx @@ -1,10 +1,8 @@ import React from 'react'; import { CheckCircle, XCircle, Loader2, Clock } from 'lucide-react'; -import { State } from '../../../types'; +import { State, InstallationPhaseId } from '../../../types'; import { useWizard } from "../../../contexts/WizardModeContext"; -export type InstallationPhaseId = 'linux-preflight' | 'linux-installation' | 'kubernetes-installation' | 'app-preflight' | 'app-installation'; - export interface PhaseStatus { status: State; title: string; diff --git a/web/src/components/wizard/installation/phases/AppInstallationPhase.tsx b/web/src/components/wizard/installation/phases/AppInstallationPhase.tsx index c0ccd61e40..10bf7ea1d7 100644 --- a/web/src/components/wizard/installation/phases/AppInstallationPhase.tsx +++ b/web/src/components/wizard/installation/phases/AppInstallationPhase.tsx @@ -8,6 +8,7 @@ import { NextButtonConfig } from "../types"; import { State, AppInstallStatus } from "../../../../types"; import { getApiBase } from '../../../../utils/api-base'; import ErrorMessage from "../shared/ErrorMessage"; +import { ApiError } from '../../../../utils/api-error'; interface AppInstallationPhaseProps { onNext: () => void; @@ -36,8 +37,7 @@ const AppInstallationPhase: React.FC = ({ onNext, set }, }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to get app installation status"); + throw await ApiError.fromResponse(response, "Failed to get app installation status") } return response.json() as Promise; }, diff --git a/web/src/components/wizard/installation/phases/AppPreflightCheck.tsx b/web/src/components/wizard/installation/phases/AppPreflightCheck.tsx index 770456d1a6..0ae1b58778 100644 --- a/web/src/components/wizard/installation/phases/AppPreflightCheck.tsx +++ b/web/src/components/wizard/installation/phases/AppPreflightCheck.tsx @@ -7,6 +7,7 @@ import Button from "../../../common/Button"; import { useAuth } from "../../../../contexts/AuthContext"; import { PreflightOutput, AppPreflightResponse } from "../../../../types"; import { getApiBase } from '../../../../utils/api-base'; +import { ApiError } from '../../../../utils/api-error'; interface AppPreflightCheckProps { onRun: () => void; @@ -14,7 +15,7 @@ interface AppPreflightCheckProps { } const AppPreflightCheck: React.FC = ({ onRun, onComplete }) => { - const [isPreflightsPolling, setIsPreflightsPolling] = useState(false); + const [isPreflightsPolling, setIsPreflightsPolling] = useState(true); const { settings } = useSettings(); const { target, mode } = useWizard(); const themeColor = settings.themeColor; @@ -47,8 +48,7 @@ const AppPreflightCheck: React.FC = ({ onRun, onComplete body: JSON.stringify({ isUi: true }), }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to run application preflight checks"); + throw await ApiError.fromResponse(response, "Failed to run application preflight checks") } return response.json() as Promise; }, @@ -73,8 +73,7 @@ const AppPreflightCheck: React.FC = ({ onRun, onComplete }, }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to get application preflight status"); + throw await ApiError.fromResponse(response, "Failed to get application preflight status") } return response.json() as Promise; }, @@ -94,11 +93,6 @@ const AppPreflightCheck: React.FC = ({ onRun, onComplete } }, [preflightResponse]); - // Start app preflights immediately when component mounts - useEffect(() => { - runPreflights(); - }, []); - if (isPreflightsPolling) { return (
diff --git a/web/src/components/wizard/installation/phases/AppPreflightPhase.tsx b/web/src/components/wizard/installation/phases/AppPreflightPhase.tsx index fcc47249b3..eac6b02b03 100644 --- a/web/src/components/wizard/installation/phases/AppPreflightPhase.tsx +++ b/web/src/components/wizard/installation/phases/AppPreflightPhase.tsx @@ -9,6 +9,7 @@ import { useAuth } from "../../../../contexts/AuthContext"; import { NextButtonConfig } from "../types"; import { State } from "../../../../types"; import { getApiBase } from '../../../../utils/api-base'; +import { ApiError } from '../../../../utils/api-error'; interface AppPreflightPhaseProps { onNext: () => void; @@ -57,8 +58,7 @@ const AppPreflightPhase: React.FC = ({ onNext, setNextBu }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to start application installation"); + throw await ApiError.fromResponse(response, "Failed to start application installation") } return response.json(); }, diff --git a/web/src/components/wizard/installation/phases/KubernetesInstallationPhase.tsx b/web/src/components/wizard/installation/phases/KubernetesInstallationPhase.tsx index 3dd4d4f791..fe034d0001 100644 --- a/web/src/components/wizard/installation/phases/KubernetesInstallationPhase.tsx +++ b/web/src/components/wizard/installation/phases/KubernetesInstallationPhase.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { useSettings } from '../../../../contexts/SettingsContext'; import { useAuth } from "../../../../contexts/AuthContext"; import { useWizard } from '../../../../contexts/WizardModeContext'; @@ -10,6 +10,7 @@ import StatusIndicator from '../shared/StatusIndicator'; import ErrorMessage from '../shared/ErrorMessage'; import { NextButtonConfig, BackButtonConfig } from '../types'; import { getApiBase } from '../../../../utils/api-base'; +import { ApiError } from '../../../../utils/api-error'; interface KubernetesInstallationPhaseProps { onNext: () => void; @@ -40,8 +41,7 @@ const KubernetesInstallationPhase: React.FC = }, }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to get infra status"); + throw await ApiError.fromResponse(response, "Failed to get infra status") } return response.json() as Promise; }, @@ -109,11 +109,39 @@ const KubernetesInstallationPhase: React.FC =
); + // Mutation for starting app preflights + const { mutate: startAppPreflights } = useMutation({ + mutationFn: async () => { + const apiBase = getApiBase("kubernetes", mode); + const response = await fetch(`${apiBase}/app-preflights/run`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ isUi: true }), + }); + + if (!response.ok) { + throw await ApiError.fromResponse(response, "Failed to start app preflight checks") + } + return response.json(); + }, + onSuccess: () => { + onNext(); + }, + onError: (err: Error) => { + // Log error but still proceed to next step - preflight status will show the error + console.error("Failed to start app preflights:", err); + onNext(); + }, + }); + // Update next button configuration useEffect(() => { setNextButtonConfig({ disabled: !installComplete, - onClick: onNext, + onClick: () => startAppPreflights(), }); }, [installComplete]); diff --git a/web/src/components/wizard/installation/phases/LinuxInstallationPhase.tsx b/web/src/components/wizard/installation/phases/LinuxInstallationPhase.tsx index 5c22d674bd..d800223696 100644 --- a/web/src/components/wizard/installation/phases/LinuxInstallationPhase.tsx +++ b/web/src/components/wizard/installation/phases/LinuxInstallationPhase.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { useSettings } from '../../../../contexts/SettingsContext'; import { useAuth } from "../../../../contexts/AuthContext"; import { useWizard } from '../../../../contexts/WizardModeContext'; @@ -10,6 +10,7 @@ import StatusIndicator from '../shared/StatusIndicator'; import ErrorMessage from '../shared/ErrorMessage'; import { NextButtonConfig, BackButtonConfig } from '../types'; import { getApiBase } from '../../../../utils/api-base'; +import { ApiError } from '../../../../utils/api-error'; interface LinuxInstallationPhaseProps { onNext: () => void; @@ -40,8 +41,7 @@ const LinuxInstallationPhase: React.FC = ({ onNext, }, }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to get infra status"); + throw await ApiError.fromResponse(response, "Failed to get infra status") } return response.json() as Promise; }, @@ -77,6 +77,46 @@ const LinuxInstallationPhase: React.FC = ({ onNext, return Math.round((completedComponents / components.length) * 100); } + // Mutation for starting app preflights + const { mutate: startAppPreflights, error: appPreflightError } = useMutation({ + mutationFn: async () => { + const apiBase = getApiBase("linux", mode); + const response = await fetch(`${apiBase}/app-preflights/run`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ isUi: true }), + }); + + if (!response.ok) { + throw await ApiError.fromResponse(response, "Failed to start app preflight checks") + } + return response.json(); + }, + onSuccess: () => { + onNext(); + }, + }); + + // Update next button configuration + useEffect(() => { + setNextButtonConfig({ + disabled: !installComplete, + onClick: () => startAppPreflights(), + }); + }, [installComplete]); + + // Update back button configuration + useEffect(() => { + // Back button is hidden for linux-installation phase as the changes made in this phase are currently irreversible + setBackButtonConfig({ + hidden: true, + onClick: onBack, + }); + }, [setBackButtonConfig, onBack]); + const renderInfrastructurePhase = () => (
= ({ onNext, onToggle={() => setShowLogs(!showLogs)} /> + {appPreflightError && } {infraStatusError && } {infraStatusResponse?.status?.state === 'Failed' && }
); - // Update next button configuration - useEffect(() => { - setNextButtonConfig({ - disabled: !installComplete, - onClick: onNext, - }); - }, [installComplete]); - - // Update back button configuration - useEffect(() => { - // Back button is hidden for linux-installation phase as the changes made in this phase are currently irreversible - setBackButtonConfig({ - hidden: true, - onClick: onBack, - }); - }, [setBackButtonConfig, onBack]); return (
diff --git a/web/src/components/wizard/installation/phases/LinuxPreflightCheck.tsx b/web/src/components/wizard/installation/phases/LinuxPreflightCheck.tsx index a0bb43a18d..ab6e8bd2bb 100644 --- a/web/src/components/wizard/installation/phases/LinuxPreflightCheck.tsx +++ b/web/src/components/wizard/installation/phases/LinuxPreflightCheck.tsx @@ -7,6 +7,7 @@ import { useWizard } from "../../../../contexts/WizardModeContext"; import { useAuth } from "../../../../contexts/AuthContext"; import { useSettings } from "../../../../contexts/SettingsContext"; import { getApiBase } from '../../../../utils/api-base'; +import { ApiError } from '../../../../utils/api-error'; interface LinuxPreflightCheckProps { onRun: () => void; @@ -21,7 +22,7 @@ interface InstallationStatusResponse { const LinuxPreflightCheck: React.FC = ({ onRun, onComplete }) => { const { target, mode } = useWizard(); - const [isPreflightsPolling, setIsPreflightsPolling] = useState(false); + const [isPreflightsPolling, setIsPreflightsPolling] = useState(true); const [isInstallationStatusPolling, setIsInstallationStatusPolling] = useState(true); const { settings } = useSettings(); const themeColor = settings.themeColor; @@ -56,8 +57,7 @@ const LinuxPreflightCheck: React.FC = ({ onRun, onComp body: JSON.stringify({ isUi: true }), }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to run preflight checks"); + throw await ApiError.fromResponse(response, "Failed to run preflight checks") } return response.json() as Promise; }, @@ -82,8 +82,7 @@ const LinuxPreflightCheck: React.FC = ({ onRun, onComp }, }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to get installation status"); + throw await ApiError.fromResponse(response, "Failed to get installation status") } return response.json() as Promise; }, @@ -104,8 +103,7 @@ const LinuxPreflightCheck: React.FC = ({ onRun, onComp }, }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to get preflight status"); + throw await ApiError.fromResponse(response, "Failed to get preflight status") } return response.json() as Promise; }, @@ -124,15 +122,14 @@ const LinuxPreflightCheck: React.FC = ({ onRun, onComp } }, [preflightResponse]); + // Stop polling installation status once preflights start or if installation fails useEffect(() => { if (installationStatus?.state === "Failed") { setIsInstallationStatusPolling(false); - return; // Prevent running preflights if failed + setIsPreflightsPolling(false); } if (installationStatus?.state === "Succeeded") { - setIsPreflightsPolling(true); setIsInstallationStatusPolling(false); - runPreflights(); } }, [installationStatus]); diff --git a/web/src/components/wizard/installation/phases/LinuxPreflightPhase.tsx b/web/src/components/wizard/installation/phases/LinuxPreflightPhase.tsx index 06434f2960..e87f2b6d92 100644 --- a/web/src/components/wizard/installation/phases/LinuxPreflightPhase.tsx +++ b/web/src/components/wizard/installation/phases/LinuxPreflightPhase.tsx @@ -9,6 +9,7 @@ import { useAuth } from "../../../../contexts/AuthContext"; import { NextButtonConfig, BackButtonConfig } from "../types"; import { State } from "../../../../types"; import { getApiBase } from '../../../../utils/api-base'; +import { ApiError } from '../../../../utils/api-error'; interface LinuxPreflightPhaseProps { onNext: () => void; @@ -56,8 +57,7 @@ const LinuxPreflightPhase: React.FC = ({ onNext, onBac }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to start installation"); + throw await ApiError.fromResponse(response, "Failed to start installation") } return response.json(); }, diff --git a/web/src/components/wizard/setup/KubernetesSetupStep.tsx b/web/src/components/wizard/setup/KubernetesSetupStep.tsx index 015e8b0b19..38bcc1086a 100644 --- a/web/src/components/wizard/setup/KubernetesSetupStep.tsx +++ b/web/src/components/wizard/setup/KubernetesSetupStep.tsx @@ -6,11 +6,11 @@ import { useKubernetesConfig } from "../../../contexts/KubernetesConfigContext"; import { useWizard } from "../../../contexts/WizardModeContext"; import { useQuery, useMutation } from "@tanstack/react-query"; import { useAuth } from "../../../contexts/AuthContext"; -import { handleUnauthorized } from "../../../utils/auth"; import { formatErrorMessage } from "../../../utils/errorMessage"; import { ChevronRight, ChevronLeft } from "lucide-react"; import { KubernetesConfig } from "../../../types"; import { getApiBase } from '../../../utils/api-base'; +import { ApiError } from '../../../utils/api-error'; /** * Maps internal field names to user-friendly display names. @@ -65,12 +65,7 @@ const KubernetesSetupStep: React.FC = ({ onNext, onBac }, }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (response.status === 401) { - handleUnauthorized(errorData); - throw new Error("Session expired. Please log in again."); - } - throw new Error(errorData.message || "Failed to fetch install configuration"); + throw await ApiError.fromResponse(response, "Failed to fetch install configuration") } const configResponse = await response.json(); // Update the global config with resolved config which includes user values and defaults. @@ -96,12 +91,7 @@ const KubernetesSetupStep: React.FC = ({ onNext, onBac }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (response.status === 401) { - handleUnauthorized(errorData); - throw new Error("Session expired. Please log in again."); - } - throw errorData; + throw await ApiError.fromResponse(response, "Failed to submit configuration") } return response.json(); }, @@ -130,8 +120,7 @@ const KubernetesSetupStep: React.FC = ({ onNext, onBac }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to start installation"); + throw await ApiError.fromResponse(response, "Failed to start installation") } return response.json(); }, diff --git a/web/src/components/wizard/setup/LinuxSetupStep.tsx b/web/src/components/wizard/setup/LinuxSetupStep.tsx index 5f5d5a78c2..0cb8c771fe 100644 --- a/web/src/components/wizard/setup/LinuxSetupStep.tsx +++ b/web/src/components/wizard/setup/LinuxSetupStep.tsx @@ -8,11 +8,11 @@ import { useLinuxConfig } from "../../../contexts/LinuxConfigContext"; import { useWizard } from "../../../contexts/WizardModeContext"; import { useQuery, useMutation } from "@tanstack/react-query"; import { useAuth } from "../../../contexts/AuthContext"; -import { handleUnauthorized } from "../../../utils/auth"; import { formatErrorMessage } from "../../../utils/errorMessage"; import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import { LinuxConfig } from "../../../types"; import { getApiBase } from '../../../utils/api-base'; +import { ApiError } from '../../../utils/api-error'; /** * Maps internal field names to user-friendly display names. @@ -45,10 +45,6 @@ interface Status { description?: string; } -interface ConfigError extends Error { - errors?: { field: string; message: string }[]; -} - interface LinuxConfigResponse { values: LinuxConfig; defaults: LinuxConfig; @@ -80,12 +76,7 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { }, }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (response.status === 401) { - handleUnauthorized(errorData); - throw new Error("Session expired. Please log in again."); - } - throw new Error(errorData.message || "Failed to fetch install configuration"); + throw await ApiError.fromResponse(response, "Failed to fetch install configuration") } const configResponse = await response.json(); // Update the global config with resolved config which includes user values and defaults. @@ -107,20 +98,41 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { Authorization: `Bearer ${token}`, }, }); + if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (response.status === 401) { - handleUnauthorized(errorData); - throw new Error("Session expired. Please log in again."); - } - throw new Error(errorData.message || "Failed to fetch network interfaces"); + throw await ApiError.fromResponse(response, "Failed to fetch network interfaces") } return response.json(); }, }); + // Mutation for starting host preflights + const { mutate: startHostPreflights } = useMutation({ + mutationFn: async () => { + const response = await fetch(`${apiBase}/host-preflights/run`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ isUi: true }), + }); + + if (!response.ok) { + throw await ApiError.fromResponse(response, 'Failed to start preflight checks') + } + return response.json(); + }, + onSuccess: () => { + onNext(); + }, + onError: (err: Error) => { + setError(err.message || "Failed to start preflights"); + }, + }); + // Mutation for submitting the configuration - const { mutate: submitConfig, error: submitError } = useMutation({ + const { mutate: submitConfig, error: submitError } = useMutation({ mutationFn: async (configData) => { const response = await fetch(`${apiBase}/installation/configure`, { method: "POST", @@ -132,12 +144,7 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (response.status === 401) { - handleUnauthorized(errorData); - throw new Error("Session expired. Please log in again."); - } - throw errorData; + throw await ApiError.fromResponse(response, 'Failed to submit configuration') } return response.json(); }, @@ -146,9 +153,10 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { updateConfig(configValues); // Clear any previous errors setError(null); - onNext(); + // Start host preflights after successful configuration + startHostPreflights(); }, - onError: (err: ConfigError) => { + onError: (err: ApiError) => { setError(err.message || "Failed to configure installation"); return err; }, @@ -156,8 +164,8 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { // Expand advanced settings if there is an error in an advanced field useEffect(() => { - if (submitError?.errors) { - if (submitError.errors.some(e => e.field === "networkInterface" || e.field === "globalCidr")) { + if (submitError?.configErrors) { + if (submitError.configErrors.some(e => e.field === "networkInterface" || e.field === "globalCidr")) { setShowAdvanced(true); } } @@ -187,7 +195,7 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { const availableNetworkInterfaces = networkInterfacesData?.networkInterfaces || []; const getFieldError = (fieldName: string) => { - const fieldError = submitError?.errors?.find((err) => err.field === fieldName); + const fieldError = submitError?.configErrors?.find((err) => err.field === fieldName); return fieldError ? formatErrorMessage(fieldError.message, fieldNames) : undefined; }; @@ -341,7 +349,7 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { {error && (
- {submitError?.errors && submitError.errors.length > 0 + {submitError?.configErrors && submitError.configErrors.length > 0 ? "Please fix the errors in the form above before proceeding." : error } diff --git a/web/src/components/wizard/tests/ConfigurationStep.test.tsx b/web/src/components/wizard/tests/ConfigurationStep.test.tsx index 205430da08..ab4021f514 100644 --- a/web/src/components/wizard/tests/ConfigurationStep.test.tsx +++ b/web/src/components/wizard/tests/ConfigurationStep.test.tsx @@ -1944,7 +1944,7 @@ describe.each([ http.patch(`*/api/${target}/${mode}/app/config/values`, () => { return new HttpResponse(JSON.stringify({ message: "required fields not completed", - status_code: 400, + statusCode: 400, errors: [ { field: "required_field", @@ -2042,7 +2042,7 @@ describe.each([ http.patch(`*/api/${target}/${mode}/app/config/values`, () => { return new HttpResponse(JSON.stringify({ message: "required fields not completed", - status_code: 400, + statusCode: 400, errors: [ { field: "first_required_field", @@ -2153,7 +2153,7 @@ describe.each([ http.patch(`*/api/${target}/${mode}/app/config/values`, () => { return new HttpResponse(JSON.stringify({ message: "required fields not completed", - status_code: 400, + statusCode: 400, errors: [ { field: "db_required_field", @@ -2256,7 +2256,7 @@ describe.each([ http.patch(`*/api/${target}/${mode}/app/config/values`, () => { return new HttpResponse(JSON.stringify({ message: "required fields not completed", - status_code: 400, + statusCode: 400, errors: [ { field: "required_text_field", @@ -2351,7 +2351,7 @@ describe.each([ http.patch(`*/api/${target}/${mode}/app/config/values`, () => { return new HttpResponse(JSON.stringify({ message: "required fields not completed", - status_code: 400, + statusCode: 400, errors: [ { field: "auth_method", diff --git a/web/src/contexts/InstallationProgressContext.tsx b/web/src/contexts/InstallationProgressContext.tsx new file mode 100644 index 0000000000..503ac6cb88 --- /dev/null +++ b/web/src/contexts/InstallationProgressContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext } from "react"; +import { WizardStep, InstallationPhaseId } from "../types"; + +export interface StoredInstallState { + wizardStep: WizardStep; + installationPhase?: InstallationPhaseId; +} + +interface InstallationProgressContextType { + wizardStep: WizardStep; + setWizardStep: (step: WizardStep) => void; + installationPhase: InstallationPhaseId | undefined; + setInstallationPhase: (phase: InstallationPhaseId | undefined) => void; + clearProgress: () => void; +} + +export const InstallationProgressContext = createContext(undefined); + +export const useInstallationProgress = () => { + const context = useContext(InstallationProgressContext); + if (context === undefined) { + throw new Error("useInstallationProgress must be used within an InstallationProgressProvider"); + } + return context; +}; diff --git a/web/src/providers/InstallationProgressProvider.tsx b/web/src/providers/InstallationProgressProvider.tsx new file mode 100644 index 0000000000..ae0611da53 --- /dev/null +++ b/web/src/providers/InstallationProgressProvider.tsx @@ -0,0 +1,77 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { InstallationProgressContext, StoredInstallState } from "../contexts/InstallationProgressContext"; +import { WizardStep, InstallationPhaseId } from "../types"; +import { useInitialState } from "../contexts/InitialStateContext"; + +const STORAGE_KEY = "embedded-cluster-install-progress"; + +export const InstallationProgressProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { installTarget } = useInitialState(); + + // Initialize state from sessionStorage or defaults + const [wizardStep, setWizardStepState] = useState(() => { + try { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed: StoredInstallState = JSON.parse(stored); + return parsed.wizardStep; + } + } catch (error) { + console.error("Failed to restore installation progress:", error); + } + return "welcome"; + }); + + const [installationPhase, setInstallationPhaseState] = useState(() => { + try { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed: StoredInstallState = JSON.parse(stored); + return parsed.installationPhase; + } + } catch (error) { + console.error("Failed to restore installation progress:", error); + } + return undefined; + }); + + // Wrapper functions that update both state and sessionStorage + const setWizardStep = useCallback((step: WizardStep) => { + setWizardStepState(step); + }, []); + + const setInstallationPhase = useCallback((phase: InstallationPhaseId | undefined) => { + setInstallationPhaseState(phase); + }, []); + + const clearProgress = useCallback(() => { + sessionStorage.removeItem(STORAGE_KEY); + }, []); + + // Save to sessionStorage whenever state changes + useEffect(() => { + const state: StoredInstallState = { + wizardStep, + installationPhase, + }; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + console.error("Failed to save installation progress:", error); + } + }, [wizardStep, installationPhase, installTarget]); + + const value = { + wizardStep, + setWizardStep, + installationPhase, + setInstallationPhase, + clearProgress, + }; + + return ( + + {children} + + ); +}; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index bcfa6fc4af..fc5fd1d443 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -83,6 +83,7 @@ export interface WizardText { nextButtonText: string; } +// WizardStep type represents the different steps in the installation or upgrade wizard export type WizardStep = | "welcome" | "configuration" @@ -92,6 +93,14 @@ export type WizardStep = | "linux-completion" | "kubernetes-completion"; +// InstallationPhaseId type represents the different phases of the installation process +export type InstallationPhaseId = + | "linux-preflight" + | "linux-installation" + | "kubernetes-installation" + | "app-preflight" + | "app-installation"; + // App Configuration Types export interface AppConfig { groups: AppConfigGroup[]; @@ -180,3 +189,10 @@ export interface InstallationStatusResponse { lastUpdated: string; state: State; } + +export interface ApiErrorResponse { + statusCode: number; + message: string; + field: string; + errors?: { field: string; message: string }[]; +} diff --git a/web/src/utils/api-error.ts b/web/src/utils/api-error.ts new file mode 100644 index 0000000000..ff4916ea3e --- /dev/null +++ b/web/src/utils/api-error.ts @@ -0,0 +1,34 @@ +import { ApiErrorResponse } from "../types"; + +export class ApiError extends Error { + statusCode: number; + details: string = ""; // will hold additional error details from the body response if available + fieldErrors?: { field: string; message: string }[]; // contains the multiple config errors if that's the case + + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.statusCode = status; + this.message = message; + } + + // fromResponse creates an ApiError from a fetch Response object + // It attempts to parse the response body as JSON to extract additional error details + // If parsing fails, it ignores the error and returns the ApiError with just the status and message + static fromResponse = async ( + response: Response, + message: string, + ): Promise => { + const error = new ApiError(response.status, message); + try { + const data = (await response.json()) as ApiErrorResponse; + error.details = data.message; + error.fieldErrors = data.errors; + } catch { + // Ignore JSON parsing errors + } + return error; + }; +} + +export class ConfigError extends ApiError {} diff --git a/web/src/utils/auth.ts b/web/src/utils/auth.ts index 0435c18fd3..bf92536648 100644 --- a/web/src/utils/auth.ts +++ b/web/src/utils/auth.ts @@ -1,15 +1,48 @@ +// isFetchError checks if the error is a fetch error with a status property +function isFetchError(error: unknown): error is { status: number } { + return ( + typeof error === "object" && + error !== null && + "status" in error && + typeof error.status === "number" + ); +} + +// isAPIBodyError checks if the error is a response from our API with a statusCode property +function isAPIBodyError(error: unknown): error is { statusCode: number } { + return ( + typeof error === "object" && + error !== null && + "statusCode" in error && + typeof error.statusCode === "number" + ); +} + +/** + * Handle unauthorized errors (HTTP 401). + * Clears auth state and reloads the page to reset the application state. + * @param error The error object to check. + * @returns true if the error was handled, false otherwise. + */ export const handleUnauthorized = (error: unknown) => { // Check if it's a fetch error with a response - if (error instanceof Error && 'status' in error) { - const status = (error as { status: number }).status; - if (status === 401) { - // Get auth context from localStorage since we can't use hooks in regular functions - localStorage.removeItem("auth"); - // Force reload the page to reset all auth state - window.location.reload(); - return true; - } + let status = 200; + if (isAPIBodyError(error)) { + status = error.statusCode; + } else if (isFetchError(error)) { + status = error.status; + } + + if (status === 401) { + // Get auth context from localStorage since we can't use hooks in regular functions + localStorage.removeItem("auth"); + // Clear session storage to reset installation progress + sessionStorage.clear(); + // Force reload the page to reset all auth state + window.location.reload(); + return true; } return false; -}; \ No newline at end of file +}; + From 085e033d1f3df460affcc39db2f3d480c849bdc7 Mon Sep 17 00:00:00 2001 From: JGAntunes Date: Wed, 8 Oct 2025 14:48:31 +0100 Subject: [PATCH 02/10] chore: type fixes --- web/src/components/wizard/setup/LinuxSetupStep.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/components/wizard/setup/LinuxSetupStep.tsx b/web/src/components/wizard/setup/LinuxSetupStep.tsx index 0cb8c771fe..f74dd26d61 100644 --- a/web/src/components/wizard/setup/LinuxSetupStep.tsx +++ b/web/src/components/wizard/setup/LinuxSetupStep.tsx @@ -164,8 +164,8 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { // Expand advanced settings if there is an error in an advanced field useEffect(() => { - if (submitError?.configErrors) { - if (submitError.configErrors.some(e => e.field === "networkInterface" || e.field === "globalCidr")) { + if (submitError?.fieldErrors) { + if (submitError.fieldErrors.some(e => e.field === "networkInterface" || e.field === "globalCidr")) { setShowAdvanced(true); } } @@ -195,7 +195,7 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { const availableNetworkInterfaces = networkInterfacesData?.networkInterfaces || []; const getFieldError = (fieldName: string) => { - const fieldError = submitError?.configErrors?.find((err) => err.field === fieldName); + const fieldError = submitError?.fieldErrors?.find((err) => err.field === fieldName); return fieldError ? formatErrorMessage(fieldError.message, fieldNames) : undefined; }; @@ -349,7 +349,7 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { {error && (
- {submitError?.configErrors && submitError.configErrors.length > 0 + {submitError?.fieldErrors && submitError.fieldErrors.length > 0 ? "Please fix the errors in the form above before proceeding." : error } From bd84af520b570bb4f1d573f8023ca84521e39e42 Mon Sep 17 00:00:00 2001 From: JGAntunes Date: Wed, 8 Oct 2025 18:12:28 +0100 Subject: [PATCH 03/10] chore: fix tests --- .../wizard/config/ConfigurationStep.tsx | 8 +- .../wizard/installation/UpgradeStep.tsx | 4 +- .../installation/phases/AppPreflightCheck.tsx | 2 +- .../installation/phases/AppPreflightPhase.tsx | 13 ++- .../phases/KubernetesInstallationPhase.tsx | 53 ++++++------ .../phases/LinuxPreflightPhase.tsx | 13 ++- .../tests/AppPreflightCheck.test.tsx | 28 +------ .../tests/AppPreflightPhase.test.tsx | 18 ++--- .../tests/LinuxPreflightCheck.test.tsx | 23 ------ .../tests/LinuxPreflightPhase.test.tsx | 12 +-- .../wizard/setup/KubernetesSetupStep.tsx | 19 ++--- .../wizard/setup/LinuxSetupStep.tsx | 8 +- .../wizard/tests/ConfigurationStep.test.tsx | 2 +- .../wizard/tests/LinuxSetupStep.test.tsx | 80 ++++++++++++++++--- web/src/test/setup.ts | 31 ------- web/src/test/setup.tsx | 57 +++++++++++-- web/vitest.setup.ts | 15 +++- 17 files changed, 214 insertions(+), 172 deletions(-) delete mode 100644 web/src/test/setup.ts diff --git a/web/src/components/wizard/config/ConfigurationStep.tsx b/web/src/components/wizard/config/ConfigurationStep.tsx index aadd801fa3..48bdc42634 100644 --- a/web/src/components/wizard/config/ConfigurationStep.tsx +++ b/web/src/components/wizard/config/ConfigurationStep.tsx @@ -78,7 +78,13 @@ const ConfigurationStep: React.FC = ({ onNext }) => { setAppConfig(config); } catch (error) { console.log(error) - setGeneralError(error instanceof Error ? error.message : String(error)); + if (error instanceof ApiError) { + setGeneralError(error.details || error.message); + } else if (error instanceof Error) { + setGeneralError(error.message); + } else { + setGeneralError(String(error)) + } } }, [target, mode, token, debouncedFetch]); diff --git a/web/src/components/wizard/installation/UpgradeStep.tsx b/web/src/components/wizard/installation/UpgradeStep.tsx index e492feb127..b9297d47e7 100644 --- a/web/src/components/wizard/installation/UpgradeStep.tsx +++ b/web/src/components/wizard/installation/UpgradeStep.tsx @@ -3,9 +3,9 @@ import Card from '../../common/Card'; import Button from '../../common/Button'; import { useWizard } from '../../../contexts/WizardModeContext'; import { useSettings } from '../../../contexts/SettingsContext'; -import { State } from '../../../types'; +import { State, InstallationPhaseId as InstallationPhase } from '../../../types'; import { ChevronLeft, ChevronRight } from 'lucide-react'; -import InstallationTimeline, { InstallationPhaseId as InstallationPhase, PhaseStatus } from './InstallationTimeline'; +import InstallationTimeline, { PhaseStatus } from './InstallationTimeline'; import AppPreflightPhase from './phases/AppPreflightPhase'; import AppInstallationPhase from './phases/AppInstallationPhase'; import { NextButtonConfig, BackButtonConfig } from './types'; diff --git a/web/src/components/wizard/installation/phases/AppPreflightCheck.tsx b/web/src/components/wizard/installation/phases/AppPreflightCheck.tsx index 0ae1b58778..d766cf8d57 100644 --- a/web/src/components/wizard/installation/phases/AppPreflightCheck.tsx +++ b/web/src/components/wizard/installation/phases/AppPreflightCheck.tsx @@ -229,7 +229,7 @@ const AppPreflightCheck: React.FC = ({ onRun, onComplete
  • • Re-run the validation once issues are addressed
  • -
    diff --git a/web/src/components/wizard/installation/phases/AppPreflightPhase.tsx b/web/src/components/wizard/installation/phases/AppPreflightPhase.tsx index eac6b02b03..cc3331ee3d 100644 --- a/web/src/components/wizard/installation/phases/AppPreflightPhase.tsx +++ b/web/src/components/wizard/installation/phases/AppPreflightPhase.tsx @@ -17,6 +17,10 @@ interface AppPreflightPhaseProps { onStateChange: (status: State) => void; } +interface StartAppInstallationRequest { + ignoreAppPreflights: boolean; +} + const AppPreflightPhase: React.FC = ({ onNext, setNextButtonConfig, onStateChange }) => { const { text, target, mode } = useWizard(); const [preflightComplete, setPreflightComplete] = React.useState(false); @@ -43,8 +47,8 @@ const AppPreflightPhase: React.FC = ({ onNext, setNextBu onStateChange(success ? 'Succeeded' : 'Failed'); }, []); - const { mutate: startAppInstallation } = useMutation({ - mutationFn: async ({ ignoreAppPreflights }: { ignoreAppPreflights: boolean }) => { + const { mutate: startAppInstallation } = useMutation({ + mutationFn: async ({ ignoreAppPreflights }) => { const apiBase = getApiBase(target, mode); const response = await fetch(`${apiBase}/app/${mode}`, { method: "POST", @@ -66,8 +70,9 @@ const AppPreflightPhase: React.FC = ({ onNext, setNextBu setError(null); // Clear any previous errors onNext(); }, - onError: (err: Error) => { - setError(err.message || "Failed to start application installation"); + onError: (err: ApiError) => { + // share the error message from the API + setError(err.details || err.message); }, }); diff --git a/web/src/components/wizard/installation/phases/KubernetesInstallationPhase.tsx b/web/src/components/wizard/installation/phases/KubernetesInstallationPhase.tsx index fe034d0001..ac5a2e7f93 100644 --- a/web/src/components/wizard/installation/phases/KubernetesInstallationPhase.tsx +++ b/web/src/components/wizard/installation/phases/KubernetesInstallationPhase.tsx @@ -49,6 +49,30 @@ const KubernetesInstallationPhase: React.FC = refetchInterval: 2000, }); + // Mutation for starting app preflights + const { mutate: startAppPreflights, error: startAppPreflightsError } = useMutation({ + mutationFn: async () => { + const apiBase = getApiBase("kubernetes", mode); + const response = await fetch(`${apiBase}/app-preflights/run`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ isUi: true }), + }); + + if (!response.ok) { + throw await ApiError.fromResponse(response, "Failed to start app preflight checks") + } + return response.json(); + }, + onSuccess: () => { + onNext(); + }, + }); + + // Report that step is running when component mounts useEffect(() => { onStateChange('Running'); @@ -104,39 +128,12 @@ const KubernetesInstallationPhase: React.FC = onToggle={() => setShowLogs(!showLogs)} /> + {startAppPreflightsError && } {infraStatusError && } {infraStatusResponse?.status?.state === 'Failed' && }
    ); - // Mutation for starting app preflights - const { mutate: startAppPreflights } = useMutation({ - mutationFn: async () => { - const apiBase = getApiBase("kubernetes", mode); - const response = await fetch(`${apiBase}/app-preflights/run`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ isUi: true }), - }); - - if (!response.ok) { - throw await ApiError.fromResponse(response, "Failed to start app preflight checks") - } - return response.json(); - }, - onSuccess: () => { - onNext(); - }, - onError: (err: Error) => { - // Log error but still proceed to next step - preflight status will show the error - console.error("Failed to start app preflights:", err); - onNext(); - }, - }); - // Update next button configuration useEffect(() => { setNextButtonConfig({ diff --git a/web/src/components/wizard/installation/phases/LinuxPreflightPhase.tsx b/web/src/components/wizard/installation/phases/LinuxPreflightPhase.tsx index e87f2b6d92..e4e29b4aea 100644 --- a/web/src/components/wizard/installation/phases/LinuxPreflightPhase.tsx +++ b/web/src/components/wizard/installation/phases/LinuxPreflightPhase.tsx @@ -19,6 +19,10 @@ interface LinuxPreflightPhaseProps { onStateChange: (status: State) => void; } +interface StartInstallationRequest { + ignoreHostPreflights: boolean; +} + const LinuxPreflightPhase: React.FC = ({ onNext, onBack, setNextButtonConfig, setBackButtonConfig, onStateChange }) => { const { text, target, mode } = useWizard(); const [preflightComplete, setPreflightComplete] = React.useState(false); @@ -42,8 +46,8 @@ const LinuxPreflightPhase: React.FC = ({ onNext, onBac onStateChange(success ? 'Succeeded' : 'Failed'); }, []); - const { mutate: startInstallation } = useMutation({ - mutationFn: async ({ ignoreHostPreflights }: { ignoreHostPreflights: boolean }) => { + const { mutate: startInstallation } = useMutation({ + mutationFn: async ({ ignoreHostPreflights }) => { const apiBase = getApiBase(target, mode); const response = await fetch(`${apiBase}/infra/setup`, { method: "POST", @@ -65,8 +69,9 @@ const LinuxPreflightPhase: React.FC = ({ onNext, onBac setError(null); // Clear any previous errors onNext(); }, - onError: (err: Error) => { - setError(err.message || "Failed to start installation"); + onError: (err: ApiError) => { + // share the error message from the API + setError(err.details || err.message); }, }); diff --git a/web/src/components/wizard/installation/tests/AppPreflightCheck.test.tsx b/web/src/components/wizard/installation/tests/AppPreflightCheck.test.tsx index 16ce589ac7..c48c1bddf4 100644 --- a/web/src/components/wizard/installation/tests/AppPreflightCheck.test.tsx +++ b/web/src/components/wizard/installation/tests/AppPreflightCheck.test.tsx @@ -127,30 +127,6 @@ describe.each([ expect(mockOnComplete).toHaveBeenCalledWith(true, false, false); // success: true, allowIgnore: false, hasStrictFailures: false }); - it("handles preflight run error", async () => { - server.use( - http.post(`*/api/${target}/install/app-preflights/run`, ({ request }) => { - const authHeader = request.headers.get("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return new HttpResponse(null, { status: 401 }); - } - return HttpResponse.json({ message: "Failed to run application preflight checks" }, { status: 500 }); - }) - ); - - renderWithProviders(, { - wrapperProps: { - target, - authToken: TEST_TOKEN, - }, - }); - - await waitFor(() => { - expect(screen.getByText("Unable to complete application requirement checks")).toBeInTheDocument(); - expect(screen.getByText("Failed to run application preflight checks")).toBeInTheDocument(); - }); - }); - it("allows re-running validation when there are failures", async () => { renderWithProviders(, { wrapperProps: { @@ -167,7 +143,7 @@ describe.each([ // Clear the mock to isolate the button click action mockOnRun.mockClear(); - const runValidationButton = screen.getByRole("button", { name: "Run Validation Again" }); + const runValidationButton = screen.getByTestId("run-validation"); expect(runValidationButton).toBeInTheDocument(); fireEvent.click(runValidationButton); @@ -378,4 +354,4 @@ describe.each([ // Should use the hasStrictAppPreflightFailures from API response (true), not client-side detection (false) expect(mockOnComplete).toHaveBeenCalledWith(false, true, true); }); -}); \ No newline at end of file +}); diff --git a/web/src/components/wizard/installation/tests/AppPreflightPhase.test.tsx b/web/src/components/wizard/installation/tests/AppPreflightPhase.test.tsx index 8585c02171..d4ee74ce33 100644 --- a/web/src/components/wizard/installation/tests/AppPreflightPhase.test.tsx +++ b/web/src/components/wizard/installation/tests/AppPreflightPhase.test.tsx @@ -726,17 +726,14 @@ describe.each([ // Should call onStateChange with "Running" immediately on mount expect(mockOnStateChange).toHaveBeenCalledWith('Running'); - // Clear the mock after the initial mount call - mockOnStateChange.mockClear(); - // Wait for preflights to complete and show success await waitFor(() => { expect(screen.getByText('Application validation successful!')).toBeInTheDocument(); }); - // Should call onStateChange exactly twice: once for onRun('Running') and once for final state - expect(mockOnStateChange).toHaveBeenCalledWith('Running'); // from onRun - expect(mockOnStateChange).toHaveBeenCalledWith('Succeeded'); // from onComplete + // Expect sequence: Running (mount), Succeeded (complete) + const calls = mockOnStateChange.mock.calls.map(args => args[0]); + expect(calls).toEqual(['Running', 'Succeeded']); expect(mockOnStateChange).toHaveBeenCalledTimes(2); }); @@ -770,17 +767,14 @@ describe.each([ // Should call onStateChange with "Running" immediately on mount expect(mockOnStateChange).toHaveBeenCalledWith('Running'); - // Clear the mock after the initial mount call - mockOnStateChange.mockClear(); - // Wait for preflights to complete and show failures await waitFor(() => { expect(screen.getByText('Application Requirements Not Met')).toBeInTheDocument(); }); - // Should call onStateChange exactly twice: once for onRun('Running') and once for final state - expect(mockOnStateChange).toHaveBeenCalledWith('Running'); // from onRun - expect(mockOnStateChange).toHaveBeenCalledWith('Failed'); // from onComplete + // Expect sequence: Running (mount), Failed (complete) + const calls = mockOnStateChange.mock.calls.map(args => args[0]); + expect(calls).toEqual(['Running', 'Failed']); expect(mockOnStateChange).toHaveBeenCalledTimes(2); }); diff --git a/web/src/components/wizard/installation/tests/LinuxPreflightCheck.test.tsx b/web/src/components/wizard/installation/tests/LinuxPreflightCheck.test.tsx index 66fd9a299b..d3d45ca9d1 100644 --- a/web/src/components/wizard/installation/tests/LinuxPreflightCheck.test.tsx +++ b/web/src/components/wizard/installation/tests/LinuxPreflightCheck.test.tsx @@ -181,29 +181,6 @@ describe("LinuxPreflightCheck", () => { }); }); - it("handles preflight run error", async () => { - server.use( - http.post("*/api/linux/install/host-preflights/run", ({ request }) => { - const authHeader = request.headers.get("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return new HttpResponse(null, { status: 401 }); - } - return HttpResponse.json({ message: "Failed to run preflight checks" }, { status: 500 }); - }) - ); - - renderWithProviders(, { - wrapperProps: { - authToken: TEST_TOKEN, - }, - }); - - await waitFor(() => { - expect(screen.getByText("Unable to complete system requirement checks")).toBeInTheDocument(); - expect(screen.getByText("Failed to run preflight checks")).toBeInTheDocument(); - }); - }); - it("allows re-running validation when there are failures", async () => { renderWithProviders(, { wrapperProps: { diff --git a/web/src/components/wizard/installation/tests/LinuxPreflightPhase.test.tsx b/web/src/components/wizard/installation/tests/LinuxPreflightPhase.test.tsx index d3558478e2..57b984bb9f 100644 --- a/web/src/components/wizard/installation/tests/LinuxPreflightPhase.test.tsx +++ b/web/src/components/wizard/installation/tests/LinuxPreflightPhase.test.tsx @@ -854,10 +854,10 @@ describe('LinuxPreflightPhase - onStateChange Tests', () => { expect(screen.getByText('Host validation successful!')).toBeInTheDocument(); }); - // Expect sequence: Running (mount), Running (preflights started), Succeeded (complete) + // Expect sequence: Running (mount), Succeeded (complete) const calls = mockOnStateChange.mock.calls.map(args => args[0]); - expect(calls).toEqual(['Running', 'Running', 'Succeeded']); - expect(mockOnStateChange).toHaveBeenCalledTimes(3); + expect(calls).toEqual(['Running', 'Succeeded']); + expect(mockOnStateChange).toHaveBeenCalledTimes(2); }); it('calls onStateChange with "Failed" when preflights complete with failures', async () => { @@ -895,10 +895,10 @@ describe('LinuxPreflightPhase - onStateChange Tests', () => { expect(screen.getByText('Host Requirements Not Met')).toBeInTheDocument(); }); - // Expect sequence: Running (mount), Running (preflights started), Failed (complete) + // Expect sequence: Running (mount), Failed (complete) const calls = mockOnStateChange.mock.calls.map(args => args[0]); - expect(calls).toEqual(['Running', 'Running', 'Failed']); - expect(mockOnStateChange).toHaveBeenCalledTimes(3); + expect(calls).toEqual(['Running', 'Failed']); + expect(mockOnStateChange).toHaveBeenCalledTimes(2); }); it('calls onStateChange("Running") when rerun button is clicked', async () => { diff --git a/web/src/components/wizard/setup/KubernetesSetupStep.tsx b/web/src/components/wizard/setup/KubernetesSetupStep.tsx index 38bcc1086a..995a9181cc 100644 --- a/web/src/components/wizard/setup/KubernetesSetupStep.tsx +++ b/web/src/components/wizard/setup/KubernetesSetupStep.tsx @@ -36,10 +36,6 @@ interface Status { description?: string; } -interface ConfigError extends Error { - errors?: { field: string; message: string }[]; -} - interface KubernetesConfigResponse { values: KubernetesConfig; defaults: KubernetesConfig; @@ -79,7 +75,7 @@ const KubernetesSetupStep: React.FC = ({ onNext, onBac }); // Mutation for submitting the configuration - const { mutate: submitConfig, error: submitError } = useMutation({ + const { mutate: submitConfig, error: submitError } = useMutation({ mutationFn: async (configData) => { const response = await fetch(`${apiBase}/installation/configure`, { method: "POST", @@ -102,9 +98,8 @@ const KubernetesSetupStep: React.FC = ({ onNext, onBac setError(null); startInstallation(); }, - onError: (err: ConfigError) => { - setError(err.message || "Failed to submit config"); - return err; + onError: (err: ApiError) => { + setError(err.details || err.message); }, }); @@ -128,8 +123,8 @@ const KubernetesSetupStep: React.FC = ({ onNext, onBac setError(null); // Clear any previous errors onNext(); }, - onError: (err: Error) => { - setError(err.message || "Failed to start installation"); + onError: (err: ApiError) => { + setError(err.details || err.message); }, }); @@ -149,7 +144,7 @@ const KubernetesSetupStep: React.FC = ({ onNext, onBac }; const getFieldError = (fieldName: string) => { - const fieldError = submitError?.errors?.find((err) => err.field === fieldName); + const fieldError = submitError?.fieldErrors?.find((err) => err.field === fieldName); return fieldError ? formatErrorMessage(fieldError.message, fieldNames) : undefined; }; @@ -230,7 +225,7 @@ const KubernetesSetupStep: React.FC = ({ onNext, onBac {error && (
    - {submitError?.errors && submitError.errors.length > 0 + {submitError?.fieldErrors && submitError.fieldErrors.length > 0 ? "Please fix the errors in the form above before proceeding." : error } diff --git a/web/src/components/wizard/setup/LinuxSetupStep.tsx b/web/src/components/wizard/setup/LinuxSetupStep.tsx index f74dd26d61..eedd7a594b 100644 --- a/web/src/components/wizard/setup/LinuxSetupStep.tsx +++ b/web/src/components/wizard/setup/LinuxSetupStep.tsx @@ -126,8 +126,8 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { onSuccess: () => { onNext(); }, - onError: (err: Error) => { - setError(err.message || "Failed to start preflights"); + onError: (err: ApiError) => { + setError(err.details || err.message); }, }); @@ -157,8 +157,8 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { startHostPreflights(); }, onError: (err: ApiError) => { - setError(err.message || "Failed to configure installation"); - return err; + // share the error message from the API + setError(err.details || err.message); }, }); diff --git a/web/src/components/wizard/tests/ConfigurationStep.test.tsx b/web/src/components/wizard/tests/ConfigurationStep.test.tsx index ab4021f514..55a5c393f3 100644 --- a/web/src/components/wizard/tests/ConfigurationStep.test.tsx +++ b/web/src/components/wizard/tests/ConfigurationStep.test.tsx @@ -568,7 +568,7 @@ describe.each([ expect(screen.getByTestId("configuration-step-error")).toBeInTheDocument(); }); expect(screen.getByText("Failed to load configuration")).toBeInTheDocument(); - expect(screen.getByText("Session expired. Please log in again.")).toBeInTheDocument(); + expect(screen.getByText("Unauthorized")).toBeInTheDocument(); }); it("only submits changed values", async () => { diff --git a/web/src/components/wizard/tests/LinuxSetupStep.test.tsx b/web/src/components/wizard/tests/LinuxSetupStep.test.tsx index 61aa92aa78..0fcd9483ed 100644 --- a/web/src/components/wizard/tests/LinuxSetupStep.test.tsx +++ b/web/src/components/wizard/tests/LinuxSetupStep.test.tsx @@ -228,7 +228,11 @@ describe("LinuxSetupStep", () => { }), http.post("*/api/linux/install/installation/configure", () => { return new HttpResponse(JSON.stringify({ message: "Initial error" }), { status: 400 }); - }) + }), + // Mock preflight run endpoint + http.post('*/api/linux/install/host-preflights/run', () => { + return HttpResponse.json({ success: true }); + }), ); renderWithProviders(, { @@ -311,7 +315,7 @@ describe("LinuxSetupStep", () => { // Check that port inputs are empty (not displaying "0") const adminPortInput = screen.getByTestId("admin-console-port-input") as HTMLInputElement; const mirrorPortInput = screen.getByTestId("local-artifact-mirror-port-input") as HTMLInputElement; - + expect(adminPortInput.value).toBe(""); expect(mirrorPortInput.value).toBe(""); }); @@ -345,7 +349,7 @@ describe("LinuxSetupStep", () => { // Check that inputs show empty values appropriately const dataDirectoryInput = screen.getByTestId("data-directory-input") as HTMLInputElement; const adminPortInput = screen.getByTestId("admin-console-port-input") as HTMLInputElement; - + expect(dataDirectoryInput.value).toBe(""); expect(adminPortInput.value).toBe(""); }); @@ -371,18 +375,18 @@ describe("LinuxSetupStep", () => { }); const adminPortInput = screen.getByTestId("admin-console-port-input") as HTMLInputElement; - + // Clear the existing value first fireEvent.change(adminPortInput, { target: { value: "" } }); - + // Test that decimal values are rejected fireEvent.change(adminPortInput, { target: { value: "8080.5" } }); expect(adminPortInput.value).toBe(""); - + // Test that non-numeric values are rejected fireEvent.change(adminPortInput, { target: { value: "abc" } }); expect(adminPortInput.value).toBe(""); - + // Test that valid integer is accepted fireEvent.change(adminPortInput, { target: { value: "8080" } }); expect(adminPortInput.value).toBe("8080"); @@ -490,7 +494,12 @@ describe("LinuxSetupStep", () => { "Content-Type": "application/json", }, }); - }) + }), + // Mock preflight run endpoint + http.post('*/api/linux/install/host-preflights/run', () => { + return HttpResponse.json({ success: true }); + }), + ); renderWithProviders(, { @@ -557,5 +566,58 @@ describe("LinuxSetupStep", () => { globalCidr: "10.244.0.0/16", }); }); + + + it("handles preflight run error", async () => { + // Mock all required API endpoints + server.use( + // Mock install config endpoint + http.get("*/api/linux/install/installation/config", () => { + return HttpResponse.json(MOCK_KUBERNETES_INSTALL_CONFIG_RESPONSE); + }), + // Mock network interfaces endpoint + http.get("*/api/console/available-network-interfaces", () => { + return HttpResponse.json(MOCK_NETWORK_INTERFACES); + }), + // Mock config submission endpoint + http.post("*/api/linux/install/installation/configure", async () => { + return new HttpResponse(JSON.stringify({ success: true }), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }); + }), + http.post("*/api/linux/install/host-preflights/run", () => { + return HttpResponse.json({ message: "Failed to run preflight checks" }, { status: 500 }); + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + contextValues: { + linuxConfigContext: { + config: { + dataDirectory: "", + }, + updateConfig: mockUpdateConfig, + resetConfig: vi.fn(), + }, + }, + }, + }); + + // Get the next button and ensure it's not disabled + const nextButton = screen.getByTestId("linux-setup-submit-button"); + expect(nextButton).not.toBeDisabled(); + + // Submit form + fireEvent.click(nextButton); + + await waitFor(() => { + expect(screen.getByText("Failed to run preflight checks")).toBeInTheDocument(); + }); + }); }); -}); \ No newline at end of file +}); diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts deleted file mode 100644 index 2f6e524d66..0000000000 --- a/web/src/test/setup.ts +++ /dev/null @@ -1,31 +0,0 @@ -import '@testing-library/jest-dom'; -import { vi } from 'vitest'; - -// Mock window.matchMedia -Object.defineProperty(window, 'matchMedia', { - writable: true, - value: vi.fn().mockImplementation(query => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })), -}); - -// Mock ResizeObserver -global.ResizeObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})); - -// Mock IntersectionObserver -global.IntersectionObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})); diff --git a/web/src/test/setup.tsx b/web/src/test/setup.tsx index b3b7a5a97f..e0f8958cd9 100644 --- a/web/src/test/setup.tsx +++ b/web/src/test/setup.tsx @@ -15,6 +15,7 @@ import { SettingsContext, Settings } from "../contexts/SettingsContext"; import { WizardContext } from "../contexts/WizardModeContext"; import { InitialStateContext } from "../contexts/InitialStateContext"; import { AuthContext } from "../contexts/AuthContext"; +import { InstallationProgressProvider } from "../providers/InstallationProgressProvider"; // Mock localStorage for tests const mockLocalStorage = { @@ -25,11 +26,49 @@ const mockLocalStorage = { }; Object.defineProperty(window, "localStorage", { value: mockLocalStorage }); +// Mock sessionStorage for tests +const mockSessionStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), +}; +Object.defineProperty(window, "sessionStorage", { value: mockSessionStorage }); + // Mock scrollIntoView for all tests (JSDOM does not implement it) if (!window.HTMLElement.prototype.scrollIntoView) { window.HTMLElement.prototype.scrollIntoView = vi.fn(); } +// Mock window.matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Mock ResizeObserver +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +// Mock IntersectionObserver +global.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + interface MockProviderProps { children: React.ReactNode; queryClient: ReturnType; @@ -77,13 +116,17 @@ const MockProvider = ({ children, queryClient, contexts }: MockProviderProps) => - - - - {children} - - - + + + + + + {children} + + + + + diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts index 7e966549ff..20605472da 100644 --- a/web/vitest.setup.ts +++ b/web/vitest.setup.ts @@ -2,6 +2,7 @@ import { expect, vi, beforeEach, afterEach } from "vitest"; import * as matchers from "@testing-library/jest-dom/matchers"; import { act, cleanup } from "@testing-library/react"; import { faker } from "@faker-js/faker"; +import "./src/test/setup"; expect.extend(matchers); @@ -31,9 +32,18 @@ const location = window.location; beforeEach(() => { // mock window.location const url = faker.internet.url(); + const mockLocation = new URL(url); + + // Add reload method to mock location + Object.defineProperty(mockLocation, 'reload', { + configurable: true, + writable: true, + value: vi.fn(), + }); + Object.defineProperties(window, { location: { - value: new URL(url), + value: mockLocation, }, }); window.location.href = url; @@ -48,6 +58,9 @@ afterEach(async () => { value: location, }); + // Clear sessionStorage after each test + sessionStorage.clear(); + // flush all pending requests await act(() => new Promise((resolve) => setTimeout(resolve))); vi.restoreAllMocks(); From 416fc7594b91690c1bdd3d2b1bbb5288a5826408 Mon Sep 17 00:00:00 2001 From: JGAntunes Date: Wed, 8 Oct 2025 18:51:55 +0100 Subject: [PATCH 04/10] chore: more tests --- .../wizard/tests/InstallWizard.test.tsx | 152 +++++++++++++ .../InstallationProgressProvider.test.tsx | 215 ++++++++++++++++++ web/src/test/setup.tsx | 22 +- web/src/utils/auth.test.ts | 84 +++++++ 4 files changed, 467 insertions(+), 6 deletions(-) create mode 100644 web/src/providers/tests/InstallationProgressProvider.test.tsx create mode 100644 web/src/utils/auth.test.ts diff --git a/web/src/components/wizard/tests/InstallWizard.test.tsx b/web/src/components/wizard/tests/InstallWizard.test.tsx index ec2ed9eed1..c43d826888 100644 --- a/web/src/components/wizard/tests/InstallWizard.test.tsx +++ b/web/src/components/wizard/tests/InstallWizard.test.tsx @@ -119,6 +119,57 @@ const createServer = (target: string) => setupServer( // Mock app upgrade start endpoint http.post(`*/api/${target}/upgrade/app/upgrade`, () => { return HttpResponse.json({ success: true }); + }), + + // Mock installation status endpoint + http.get(`*/api/${target}/install/installation/status`, () => { + return HttpResponse.json({ + state: 'Pending', + description: 'Waiting to start installation', + lastUpdated: new Date().toISOString() + }); + }), + + // Mock infra status endpoint + http.get(`*/api/${target}/install/infra/status`, () => { + return HttpResponse.json({ + components: [], + status: { + state: 'Pending', + description: 'Waiting to start', + lastUpdated: new Date().toISOString() + }, + logs: '' + }); + }), + + // Mock host preflights status endpoint + http.get(`*/api/${target}/install/host-preflights/status`, () => { + return HttpResponse.json({ + titles: [], + status: { + state: 'Pending', + description: 'Waiting to start', + lastUpdated: new Date().toISOString() + }, + output: { pass: [], warn: [], fail: [] }, + allowIgnoreHostPreflights: false + }); + }), + + // Mock app preflights status endpoint + http.get(`*/api/${target}/install/app-preflights/status`, () => { + return HttpResponse.json({ + titles: [], + status: { + state: 'Pending', + description: 'Waiting to start', + lastUpdated: new Date().toISOString() + }, + output: { pass: [], warn: [], fail: [] }, + allowIgnoreAppPreflights: false, + hasStrictAppPreflightFailures: false + }); }) ); @@ -302,4 +353,105 @@ describe.each([ expect(screen.getByTestId("configuration-step")).toBeInTheDocument(); }); + + it("restores to configuration step from sessionStorage", async () => { + const STORAGE_KEY = "embedded-cluster-install-progress"; + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + wizardStep: "configuration", + installationPhase: undefined, + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + target: target, + }, + }); + + await waitForForm(); + + expect(screen.getByTestId("configuration-step")).toBeInTheDocument(); + }); + + it("restores to setup step from sessionStorage", async () => { + const STORAGE_KEY = "embedded-cluster-install-progress"; + const setupStep = target === "linux" ? "linux-setup" : "kubernetes-setup"; + + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + wizardStep: setupStep, + installationPhase: undefined, + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + target: target, + }, + }); + + await waitFor(() => { + expect(screen.getByTestId(`${target}-setup`)).toBeInTheDocument(); + }); + }); + + it("restores to installation step from sessionStorage", async () => { + const STORAGE_KEY = "embedded-cluster-install-progress"; + const firstPhase = target === "kubernetes" ? "kubernetes-installation" : "linux-preflight"; + + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + wizardStep: "installation", + installationPhase: undefined, + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + target: target, + }, + }); + + await waitFor(() => { + expect(screen.getByTestId(`${firstPhase}-container`)).toBeInTheDocument(); + }); + }); + + it("defaults to welcome step when sessionStorage has invalid data", async () => { + const STORAGE_KEY = "embedded-cluster-install-progress"; + sessionStorage.setItem(STORAGE_KEY, "invalid-json{"); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + target: target, + }, + }); + + await waitFor(() => { + expect(screen.getByText("Welcome")).toBeInTheDocument(); + }); + }); + + it("defaults to welcome step when no sessionStorage data exists", async () => { + sessionStorage.clear(); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + target: target, + }, + }); + + await waitFor(() => { + expect(screen.getByText("Welcome")).toBeInTheDocument(); + }); + }); }); diff --git a/web/src/providers/tests/InstallationProgressProvider.test.tsx b/web/src/providers/tests/InstallationProgressProvider.test.tsx new file mode 100644 index 0000000000..bda79404b8 --- /dev/null +++ b/web/src/providers/tests/InstallationProgressProvider.test.tsx @@ -0,0 +1,215 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { InstallationProgressProvider } from "../InstallationProgressProvider"; +import { useInstallationProgress } from "../../contexts/InstallationProgressContext"; +import { InitialStateContext } from "../../contexts/InitialStateContext"; + +const STORAGE_KEY = "embedded-cluster-install-progress"; + +describe("InstallationProgressProvider", () => { + beforeEach(() => { + sessionStorage.clear(); + vi.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + describe("Initialization", () => { + it("initializes with default state when sessionStorage is empty", () => { + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + expect(result.current.wizardStep).toBe("welcome"); + expect(result.current.installationPhase).toBeUndefined(); + }); + + it("restores wizardStep from sessionStorage on mount", () => { + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + wizardStep: "configuration", + installationPhase: undefined, + }) + ); + + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + expect(result.current.wizardStep).toBe("configuration"); + expect(result.current.installationPhase).toBeUndefined(); + }); + + it("restores installationPhase from sessionStorage on mount", () => { + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + wizardStep: "installation", + installationPhase: "linux-preflight", + }) + ); + + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + expect(result.current.wizardStep).toBe("installation"); + expect(result.current.installationPhase).toBe("linux-preflight"); + }); + + it("handles corrupted sessionStorage data gracefully", () => { + sessionStorage.setItem(STORAGE_KEY, "invalid-json{"); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + expect(result.current.wizardStep).toBe("welcome"); + expect(result.current.installationPhase).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Failed to restore installation progress:", + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + + it("handles missing sessionStorage gracefully", () => { + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + expect(result.current.wizardStep).toBe("welcome"); + expect(result.current.installationPhase).toBeUndefined(); + }); + }); + + describe("State Updates", () => { + it("setWizardStep updates state and persists to sessionStorage", () => { + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + act(() => { + result.current.setWizardStep("configuration"); + }); + + expect(result.current.wizardStep).toBe("configuration"); + + const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "{}"); + expect(stored.wizardStep).toBe("configuration"); + }); + + it("setInstallationPhase updates state and persists to sessionStorage", () => { + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + act(() => { + result.current.setInstallationPhase("linux-preflight"); + }); + + expect(result.current.installationPhase).toBe("linux-preflight"); + + const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "{}"); + expect(stored.installationPhase).toBe("linux-preflight"); + }); + + it("updates both wizardStep and installationPhase together", () => { + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + act(() => { + result.current.setWizardStep("installation"); + result.current.setInstallationPhase("app-installation"); + }); + + expect(result.current.wizardStep).toBe("installation"); + expect(result.current.installationPhase).toBe("app-installation"); + + const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "{}"); + expect(stored.wizardStep).toBe("installation"); + expect(stored.installationPhase).toBe("app-installation"); + }); + + it("allows setting installationPhase to undefined", () => { + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + act(() => { + result.current.setInstallationPhase("linux-preflight"); + }); + + expect(result.current.installationPhase).toBe("linux-preflight"); + + act(() => { + result.current.setInstallationPhase(undefined); + }); + + expect(result.current.installationPhase).toBeUndefined(); + + const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "{}"); + expect(stored.installationPhase).toBeUndefined(); + }); + }); + + describe("clearProgress", () => { + it("removes data from sessionStorage", () => { + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + act(() => { + result.current.setWizardStep("installation"); + result.current.setInstallationPhase("app-installation"); + }); + + expect(sessionStorage.getItem(STORAGE_KEY)).not.toBeNull(); + + act(() => { + result.current.clearProgress(); + }); + + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it("does not reset state in memory when clearing", () => { + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + act(() => { + result.current.setWizardStep("installation"); + result.current.setInstallationPhase("app-installation"); + }); + + act(() => { + result.current.clearProgress(); + }); + + expect(result.current.wizardStep).toBe("installation"); + expect(result.current.installationPhase).toBe("app-installation"); + }); + }); + + describe("Error Handling", () => { + it("handles sessionStorage.setItem errors gracefully", () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const originalSetItem = sessionStorage.setItem; + + // Mock setItem to throw an error + sessionStorage.setItem = vi.fn(() => { + throw new Error("Storage quota exceeded"); + }); + + const { result } = renderHook(() => useInstallationProgress(), { wrapper }); + + act(() => { + result.current.setWizardStep("configuration"); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Failed to save installation progress:", + expect.any(Error) + ); + + // Restore original implementation + sessionStorage.setItem = originalSetItem; + consoleErrorSpy.mockRestore(); + }); + }); + + describe("Context Usage", () => { + it("throws error when useInstallationProgress is used outside provider", () => { + expect(() => { + renderHook(() => useInstallationProgress()); + }).toThrow("useInstallationProgress must be used within an InstallationProgressProvider"); + }); + }); +}); diff --git a/web/src/test/setup.tsx b/web/src/test/setup.tsx index e0f8958cd9..fb069938be 100644 --- a/web/src/test/setup.tsx +++ b/web/src/test/setup.tsx @@ -26,13 +26,23 @@ const mockLocalStorage = { }; Object.defineProperty(window, "localStorage", { value: mockLocalStorage }); -// Mock sessionStorage for tests -const mockSessionStorage = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), +// Mock sessionStorage for tests with real storage functionality +const createMockStorage = () => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + }; }; +const mockSessionStorage = createMockStorage(); Object.defineProperty(window, "sessionStorage", { value: mockSessionStorage }); // Mock scrollIntoView for all tests (JSDOM does not implement it) diff --git a/web/src/utils/auth.test.ts b/web/src/utils/auth.test.ts new file mode 100644 index 0000000000..ef20e99737 --- /dev/null +++ b/web/src/utils/auth.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { handleUnauthorized } from "./auth"; + +describe("auth utilities", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + }); + + describe("handleUnauthorized", () => { + it("clears localStorage and sessionStorage when status is 401", () => { + const reloadSpy = vi.spyOn(window.location, "reload").mockImplementation(() => {}); + localStorage.setItem("auth", "test-token"); + sessionStorage.setItem("embedded-cluster-install-progress", JSON.stringify({ wizardStep: "installation" })); + + const error = { statusCode: 401 }; + const result = handleUnauthorized(error); + + expect(result).toBe(true); + expect(localStorage.getItem("auth")).toBeFalsy(); + expect(sessionStorage.getItem("embedded-cluster-install-progress")).toBeFalsy(); + expect(reloadSpy).toHaveBeenCalledOnce(); + + reloadSpy.mockRestore(); + }); + + it("clears sessionStorage for fetch errors with status 401", () => { + const reloadSpy = vi.spyOn(window.location, "reload").mockImplementation(() => {}); + sessionStorage.setItem("embedded-cluster-install-progress", JSON.stringify({ wizardStep: "installation" })); + + const error = { status: 401 }; + const result = handleUnauthorized(error); + + expect(result).toBe(true); + expect(sessionStorage.getItem("embedded-cluster-install-progress")).toBeFalsy(); + expect(reloadSpy).toHaveBeenCalledOnce(); + + reloadSpy.mockRestore(); + }); + + it("does not clear sessionStorage for non-401 status codes", () => { + const reloadSpy = vi.spyOn(window.location, "reload").mockImplementation(() => {}); + sessionStorage.setItem("embedded-cluster-install-progress", JSON.stringify({ wizardStep: "installation" })); + + const error = { statusCode: 500 }; + const result = handleUnauthorized(error); + + expect(result).toBe(false); + expect(sessionStorage.getItem("embedded-cluster-install-progress")).not.toBeNull(); + expect(reloadSpy).not.toHaveBeenCalled(); + + reloadSpy.mockRestore(); + }); + + it("does not clear sessionStorage for 403 forbidden errors", () => { + const reloadSpy = vi.spyOn(window.location, "reload").mockImplementation(() => {}); + sessionStorage.setItem("embedded-cluster-install-progress", JSON.stringify({ wizardStep: "installation" })); + + const error = { statusCode: 403 }; + const result = handleUnauthorized(error); + + expect(result).toBe(false); + expect(sessionStorage.getItem("embedded-cluster-install-progress")).not.toBeNull(); + expect(reloadSpy).not.toHaveBeenCalled(); + + reloadSpy.mockRestore(); + }); + + it("returns false for errors without status property", () => { + const reloadSpy = vi.spyOn(window.location, "reload").mockImplementation(() => {}); + sessionStorage.setItem("embedded-cluster-install-progress", JSON.stringify({ wizardStep: "installation" })); + + const error = { message: "Unknown error" }; + const result = handleUnauthorized(error); + + expect(result).toBe(false); + expect(sessionStorage.getItem("embedded-cluster-install-progress")).not.toBeNull(); + expect(reloadSpy).not.toHaveBeenCalled(); + + reloadSpy.mockRestore(); + }); + }); +}); From 25b3264d58793f8df2318729196f1f0a44e99676 Mon Sep 17 00:00:00 2001 From: JGAntunes Date: Wed, 8 Oct 2025 18:56:27 +0100 Subject: [PATCH 05/10] chore: remove console.error ref --- web/src/components/wizard/config/ConfigurationStep.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/components/wizard/config/ConfigurationStep.tsx b/web/src/components/wizard/config/ConfigurationStep.tsx index 48bdc42634..7c16b644eb 100644 --- a/web/src/components/wizard/config/ConfigurationStep.tsx +++ b/web/src/components/wizard/config/ConfigurationStep.tsx @@ -77,7 +77,6 @@ const ConfigurationStep: React.FC = ({ onNext }) => { const config = await response.json(); setAppConfig(config); } catch (error) { - console.log(error) if (error instanceof ApiError) { setGeneralError(error.details || error.message); } else if (error instanceof Error) { From 5c362c5ac94d84e82f7fabdd5d53d4814e99a766 Mon Sep 17 00:00:00 2001 From: JGAntunes Date: Thu, 9 Oct 2025 14:41:46 +0100 Subject: [PATCH 06/10] fix: installation setup race condition --- .../phases/LinuxPreflightCheck.tsx | 54 +--- .../tests/LinuxPreflightCheck.test.tsx | 66 ---- .../wizard/setup/LinuxSetupStep.tsx | 64 +++- .../wizard/tests/LinuxSetupStep.test.tsx | 297 ++++++++++++++++-- 4 files changed, 323 insertions(+), 158 deletions(-) diff --git a/web/src/components/wizard/installation/phases/LinuxPreflightCheck.tsx b/web/src/components/wizard/installation/phases/LinuxPreflightCheck.tsx index ab6e8bd2bb..ff42d46b94 100644 --- a/web/src/components/wizard/installation/phases/LinuxPreflightCheck.tsx +++ b/web/src/components/wizard/installation/phases/LinuxPreflightCheck.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { XCircle, CheckCircle, Loader2, AlertTriangle, RefreshCw } from "lucide-react"; import { useQuery, useMutation } from "@tanstack/react-query"; import Button from "../../../common/Button"; -import { PreflightOutput, HostPreflightResponse, State } from "../../../../types"; +import { PreflightOutput, HostPreflightResponse } from "../../../../types"; import { useWizard } from "../../../../contexts/WizardModeContext"; import { useAuth } from "../../../../contexts/AuthContext"; import { useSettings } from "../../../../contexts/SettingsContext"; @@ -14,16 +14,9 @@ interface LinuxPreflightCheckProps { onComplete: (success: boolean, allowIgnoreHostPreflights: boolean) => void; } -interface InstallationStatusResponse { - description: string; - lastUpdated: string; - state: State; -} - const LinuxPreflightCheck: React.FC = ({ onRun, onComplete }) => { const { target, mode } = useWizard(); const [isPreflightsPolling, setIsPreflightsPolling] = useState(true); - const [isInstallationStatusPolling, setIsInstallationStatusPolling] = useState(true); const { settings } = useSettings(); const themeColor = settings.themeColor; const { token } = useAuth(); @@ -33,9 +26,6 @@ const LinuxPreflightCheck: React.FC = ({ onRun, onComp const isSuccessful = (response?: HostPreflightResponse) => response?.status?.state === "Succeeded"; const getErrorMessage = () => { - if (installationStatus?.state === "Failed") { - return installationStatus?.description; - } if (preflightsRunError) { return preflightsRunError.message; } @@ -70,27 +60,6 @@ const LinuxPreflightCheck: React.FC = ({ onRun, onComp }, }); - // Query to poll installation status - const { data: installationStatus } = useQuery({ - queryKey: ["installationStatus"], - queryFn: async () => { - const response = await fetch(`${apiBase}/installation/status`, { - headers: { - ...(localStorage.getItem("auth") && { - Authorization: `Bearer ${localStorage.getItem("auth")}`, - }), - }, - }); - if (!response.ok) { - throw await ApiError.fromResponse(response, "Failed to get installation status") - } - return response.json() as Promise; - }, - enabled: isInstallationStatusPolling, - refetchInterval: 1000, - gcTime: 0, - }); - // Query to poll preflight status const { data: preflightResponse } = useQuery({ queryKey: ["preflightStatus"], @@ -122,27 +91,6 @@ const LinuxPreflightCheck: React.FC = ({ onRun, onComp } }, [preflightResponse]); - // Stop polling installation status once preflights start or if installation fails - useEffect(() => { - if (installationStatus?.state === "Failed") { - setIsInstallationStatusPolling(false); - setIsPreflightsPolling(false); - } - if (installationStatus?.state === "Succeeded") { - setIsInstallationStatusPolling(false); - } - }, [installationStatus]); - - if (isInstallationStatusPolling) { - return ( -
    - -

    Initializing...

    -

    Preparing the host.

    -
    - ); - } - if (isPreflightsPolling) { return (
    diff --git a/web/src/components/wizard/installation/tests/LinuxPreflightCheck.test.tsx b/web/src/components/wizard/installation/tests/LinuxPreflightCheck.test.tsx index d3d45ca9d1..e85162845a 100644 --- a/web/src/components/wizard/installation/tests/LinuxPreflightCheck.test.tsx +++ b/web/src/components/wizard/installation/tests/LinuxPreflightCheck.test.tsx @@ -8,15 +8,6 @@ import { http, HttpResponse } from "msw"; const TEST_TOKEN = "test-auth-token"; const server = setupServer( - // Mock installation status endpoint - http.get("*/api/linux/install/installation/status", ({ request }) => { - const authHeader = request.headers.get("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return new HttpResponse(null, { status: 401 }); - } - return HttpResponse.json({ state: "Succeeded" }); - }), - // Mock preflight status endpoint http.get("*/api/linux/install/host-preflights/status", ({ request }) => { const authHeader = request.headers.get("Authorization"); @@ -58,27 +49,6 @@ describe("LinuxPreflightCheck", () => { }); afterAll(() => server.close()); - it("shows initializing state when installation status is polling", async () => { - server.use( - http.get("*/api/linux/install/installation/status", ({ request }) => { - const authHeader = request.headers.get("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return new HttpResponse(null, { status: 401 }); - } - return HttpResponse.json({ state: "Running" }); - }) - ); - - renderWithProviders(, { - wrapperProps: { - authToken: TEST_TOKEN, - }, - }); - - expect(screen.getByText("Initializing...")).toBeInTheDocument(); - expect(screen.getByText("Preparing the host.")).toBeInTheDocument(); - }); - it("shows validating state when preflights are polling", async () => { server.use( http.get("*/api/linux/install/host-preflights/status", ({ request }) => { @@ -145,42 +115,6 @@ describe("LinuxPreflightCheck", () => { expect(mockOnComplete).toHaveBeenCalledWith(true, false); // success: true, allowIgnore: false (default) }); - it("handles installation status error", async () => { - server.use( - http.get("*/api/linux/install/installation/status", ({ request }) => { - const authHeader = request.headers.get("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return new HttpResponse(null, { status: 401 }); - } - return HttpResponse.json({ - state: "Failed", - description: "Failed to configure the host", - }); - }), - http.get("*/api/linux/install/host-preflights/status", ({ request }) => { - const authHeader = request.headers.get("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return new HttpResponse(null, { status: 401 }); - } - return HttpResponse.json({ - output: {}, - status: { state: "Failed" }, - }); - }) - ); - - renderWithProviders(, { - wrapperProps: { - authToken: TEST_TOKEN, - }, - }); - - await waitFor(() => { - expect(screen.getByText("Unable to complete system requirement checks")); - expect(screen.getByText("Failed to configure the host")).toBeInTheDocument(); - }); - }); - it("allows re-running validation when there are failures", async () => { renderWithProviders(, { wrapperProps: { diff --git a/web/src/components/wizard/setup/LinuxSetupStep.tsx b/web/src/components/wizard/setup/LinuxSetupStep.tsx index eedd7a594b..6832806be0 100644 --- a/web/src/components/wizard/setup/LinuxSetupStep.tsx +++ b/web/src/components/wizard/setup/LinuxSetupStep.tsx @@ -10,7 +10,7 @@ import { useQuery, useMutation } from "@tanstack/react-query"; import { useAuth } from "../../../contexts/AuthContext"; import { formatErrorMessage } from "../../../utils/errorMessage"; import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; -import { LinuxConfig } from "../../../types"; +import { LinuxConfig, State } from "../../../types"; import { getApiBase } from '../../../utils/api-base'; import { ApiError } from '../../../utils/api-error'; @@ -51,6 +51,12 @@ interface LinuxConfigResponse { resolved: LinuxConfig; } +interface InstallationStatusResponse { + description: string; + lastUpdated: string; + state: State; +} + interface NetworkInterfacesResponse { networkInterfaces: string[] } @@ -59,6 +65,7 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { const { updateConfig } = useLinuxConfig(); // We need to make sure to update the global config const { text, target, mode } = useWizard(); const { title } = useInitialState(); + const [isInstallationStatusPolling, setIsInstallationStatusPolling] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); const [error, setError] = useState(null); const [defaults, setDefaults] = useState({ dataDirectory: "" }); @@ -106,6 +113,26 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { }, }); + // Query to poll installation status + const { data: installationStatus } = useQuery({ + queryKey: ["installationStatus"], + queryFn: async () => { + const response = await fetch(`${apiBase}/installation/status`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw await ApiError.fromResponse(response, "Failed to get installation status") + } + return response.json() as Promise; + }, + enabled: isInstallationStatusPolling, + refetchInterval: 1000, + gcTime: 0, + }); + + // Mutation for starting host preflights const { mutate: startHostPreflights } = useMutation({ mutationFn: async () => { @@ -153,8 +180,8 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { updateConfig(configValues); // Clear any previous errors setError(null); - // Start host preflights after successful configuration - startHostPreflights(); + // Start polling installation status + setIsInstallationStatusPolling(true); }, onError: (err: ApiError) => { // share the error message from the API @@ -171,6 +198,22 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { } }, [submitError]); + + // Trigger host preflights when installation status polling finishes + useEffect(() => { + if (installationStatus?.state === "Failed") { + setIsInstallationStatusPolling(false); + setError(`Installation configuration failed with: ${installationStatus.description}`) + return; // Prevent running preflights if failed + } + if (installationStatus?.state === "Succeeded") { + setIsInstallationStatusPolling(false); + startHostPreflights(); + } + }, [installationStatus]); + + + // Handle input changes for text and number inputs const handleInputChange = (e: React.ChangeEvent) => { const { id, value } = e.target; if (id === "adminConsolePort" || id === "localArtifactMirrorPort") { @@ -191,7 +234,7 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { setConfigValues({ ...configValues, [id]: value }); }; - const isLoading = isConfigLoading || isInterfacesLoading; + const isLoading = isConfigLoading || isInterfacesLoading || isInstallationStatusPolling; const availableNetworkInterfaces = networkInterfacesData?.networkInterfaces || []; const getFieldError = (fieldName: string) => { @@ -199,6 +242,13 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { return fieldError ? formatErrorMessage(fieldError.message, fieldNames) : undefined; }; + const getLoadingText = () => { + if (isInstallationStatusPolling) { + return "Preparing the host." + } + return "Loading configuration..." + } + return (
    @@ -208,9 +258,9 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => {
    {isLoading ? ( -
    +
    -

    Loading configuration...

    +

    {getLoadingText()}

    ) : ( <> @@ -348,7 +398,7 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => {
    {error && ( -
    +
    {submitError?.fieldErrors && submitError.fieldErrors.length > 0 ? "Please fix the errors in the form above before proceeding." : error diff --git a/web/src/components/wizard/tests/LinuxSetupStep.test.tsx b/web/src/components/wizard/tests/LinuxSetupStep.test.tsx index 0fcd9483ed..625d1f25ca 100644 --- a/web/src/components/wizard/tests/LinuxSetupStep.test.tsx +++ b/web/src/components/wizard/tests/LinuxSetupStep.test.tsx @@ -20,6 +20,20 @@ const server = setupServer( // Mock config submission endpoint http.post("*/api/linux/install/installation/configure", () => { return HttpResponse.json({ success: true }); + }), + + // Mock installation status endpoint + http.get("*/api/linux/install/installation/status", () => { + return HttpResponse.json({ + state: "Succeeded", + description: "Installation configured successfully", + lastUpdated: new Date().toISOString() + }); + }), + + // Mock preflight run endpoint + http.post("*/api/linux/install/host-preflights/run", () => { + return HttpResponse.json({ success: true }); }) ); @@ -223,16 +237,9 @@ describe("LinuxSetupStep", () => { it("clears errors when re-submitting after previous failure", async () => { // First, set up server to return an error server.use( - http.get("*/api/console/available-network-interfaces", () => { - return HttpResponse.json(MOCK_NETWORK_INTERFACES); - }), http.post("*/api/linux/install/installation/configure", () => { return new HttpResponse(JSON.stringify({ message: "Initial error" }), { status: 400 }); - }), - // Mock preflight run endpoint - http.post('*/api/linux/install/host-preflights/run', () => { - return HttpResponse.json({ success: true }); - }), + }) ); renderWithProviders(, { @@ -494,12 +501,7 @@ describe("LinuxSetupStep", () => { "Content-Type": "application/json", }, }); - }), - // Mock preflight run endpoint - http.post('*/api/linux/install/host-preflights/run', () => { - return HttpResponse.json({ success: true }); - }), - + }) ); renderWithProviders(, { @@ -569,25 +571,7 @@ describe("LinuxSetupStep", () => { it("handles preflight run error", async () => { - // Mock all required API endpoints server.use( - // Mock install config endpoint - http.get("*/api/linux/install/installation/config", () => { - return HttpResponse.json(MOCK_KUBERNETES_INSTALL_CONFIG_RESPONSE); - }), - // Mock network interfaces endpoint - http.get("*/api/console/available-network-interfaces", () => { - return HttpResponse.json(MOCK_NETWORK_INTERFACES); - }), - // Mock config submission endpoint - http.post("*/api/linux/install/installation/configure", async () => { - return new HttpResponse(JSON.stringify({ success: true }), { - status: 200, - headers: { - "Content-Type": "application/json", - }, - }); - }), http.post("*/api/linux/install/host-preflights/run", () => { return HttpResponse.json({ message: "Failed to run preflight checks" }, { status: 500 }); }) @@ -620,4 +604,253 @@ describe("LinuxSetupStep", () => { }); }); }); + + describe("Installation Status Polling", () => { + it("shows 'Preparing the host.' loading state when installation status is polling", async () => { + server.use( + http.get("*/api/linux/install/installation/status", () => { + return HttpResponse.json({ + state: "Running", + description: "Configuring installation", + lastUpdated: new Date().toISOString() + }); + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + contextValues: { + linuxConfigContext: { + config: { dataDirectory: '' }, + updateConfig: mockUpdateConfig, + resetConfig: vi.fn(), + }, + }, + }, + }); + + await screen.findByText("Configure the installation settings."); + + const nextButton = screen.getByTestId("linux-setup-submit-button"); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(screen.getByTestId("linux-setup-loading-text")).toHaveTextContent("Preparing the host."); + }); + }); + + it("triggers preflights after installation status succeeds", async () => { + let statusCallCount = 0; + server.use( + http.get("*/api/linux/install/installation/status", () => { + statusCallCount++; + if (statusCallCount === 1) { + return HttpResponse.json({ + state: "Running", + description: "Configuring installation", + lastUpdated: new Date().toISOString() + }); + } + return HttpResponse.json({ + state: "Succeeded", + description: "Installation configured successfully", + lastUpdated: new Date().toISOString() + }); + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + contextValues: { + linuxConfigContext: { + config: { dataDirectory: '' }, + updateConfig: mockUpdateConfig, + resetConfig: vi.fn(), + }, + }, + }, + }); + + await screen.findByText("Configure the installation settings."); + + const nextButton = screen.getByTestId("linux-setup-submit-button"); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(mockOnNext).toHaveBeenCalled(); + }, { timeout: 5000 }); + }); + + it("handles installation status failure and shows error", async () => { + server.use( + http.get("*/api/linux/install/installation/status", () => { + return HttpResponse.json({ + state: "Failed", + description: "Network configuration failed", + lastUpdated: new Date().toISOString() + }); + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + contextValues: { + linuxConfigContext: { + config: { dataDirectory: '' }, + updateConfig: mockUpdateConfig, + resetConfig: vi.fn(), + }, + }, + }, + }); + + await screen.findByText("Configure the installation settings."); + + const nextButton = screen.getByTestId("linux-setup-submit-button"); + fireEvent.click(nextButton); + + await waitFor(() => { + const errorElement = screen.getByTestId("linux-setup-error"); + expect(errorElement).toHaveTextContent("Installation configuration failed with: Network configuration failed"); + }); + + expect(mockOnNext).not.toHaveBeenCalled(); + }); + + it("stops polling installation status on failure", async () => { + let statusCallCount = 0; + server.use( + http.get("*/api/linux/install/installation/status", () => { + statusCallCount++; + return HttpResponse.json({ + state: "Failed", + description: "Configuration error", + lastUpdated: new Date().toISOString() + }); + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + contextValues: { + linuxConfigContext: { + config: { dataDirectory: '' }, + updateConfig: mockUpdateConfig, + resetConfig: vi.fn(), + }, + }, + }, + }); + + await screen.findByText("Configure the installation settings."); + + const nextButton = screen.getByTestId("linux-setup-submit-button"); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(screen.getByTestId("linux-setup-error")).toBeInTheDocument(); + }); + + const initialCallCount = statusCallCount; + await new Promise(resolve => setTimeout(resolve, 2000)); + + expect(statusCallCount).toBe(initialCallCount); + }); + + it("does not trigger preflights if installation status is still running", async () => { + server.use( + http.get("*/api/linux/install/installation/status", () => { + return HttpResponse.json({ + state: "Running", + description: "Still configuring", + lastUpdated: new Date().toISOString() + }); + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + contextValues: { + linuxConfigContext: { + config: { dataDirectory: '' }, + updateConfig: mockUpdateConfig, + resetConfig: vi.fn(), + }, + }, + }, + }); + + await screen.findByText("Configure the installation settings."); + + const nextButton = screen.getByTestId("linux-setup-submit-button"); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(screen.getByTestId("linux-setup-loading-text")).toHaveTextContent("Preparing the host."); + }); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + expect(mockOnNext).not.toHaveBeenCalled(); + }); + + it("allows retry after installation status failure", async () => { + let submitCount = 0; + server.use( + http.post("*/api/linux/install/installation/configure", () => { + submitCount++; + return HttpResponse.json({ success: true }); + }), + http.get("*/api/linux/install/installation/status", () => { + if (submitCount === 1) { + return HttpResponse.json({ + state: "Failed", + description: "First attempt failed", + lastUpdated: new Date().toISOString() + }); + } + return HttpResponse.json({ + state: "Succeeded", + description: "Installation configured successfully", + lastUpdated: new Date().toISOString() + }); + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + contextValues: { + linuxConfigContext: { + config: { dataDirectory: '' }, + updateConfig: mockUpdateConfig, + resetConfig: vi.fn(), + }, + }, + }, + }); + + await screen.findByText("Configure the installation settings."); + + const nextButton = screen.getByTestId("linux-setup-submit-button"); + + fireEvent.click(nextButton); + + await waitFor(() => { + const errorElement = screen.getByTestId("linux-setup-error"); + expect(errorElement).toHaveTextContent("Installation configuration failed with: First attempt failed"); + }); + + fireEvent.click(nextButton); + + await waitFor(() => { + expect(mockOnNext).toHaveBeenCalled(); + }, { timeout: 5000 }); + }); + }); }); From 761cbd4add116f321dedb0f2c83cd13f37c81a42 Mon Sep 17 00:00:00 2001 From: JGAntunes Date: Thu, 9 Oct 2025 16:25:05 +0100 Subject: [PATCH 07/10] chore: cursor feedback --- web/src/providers/InstallationProgressProvider.tsx | 5 +---- .../tests/InstallationProgressProvider.test.tsx | 9 +++------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/web/src/providers/InstallationProgressProvider.tsx b/web/src/providers/InstallationProgressProvider.tsx index ae0611da53..d2fee68be2 100644 --- a/web/src/providers/InstallationProgressProvider.tsx +++ b/web/src/providers/InstallationProgressProvider.tsx @@ -1,13 +1,10 @@ import React, { useState, useEffect, useCallback } from "react"; import { InstallationProgressContext, StoredInstallState } from "../contexts/InstallationProgressContext"; import { WizardStep, InstallationPhaseId } from "../types"; -import { useInitialState } from "../contexts/InitialStateContext"; const STORAGE_KEY = "embedded-cluster-install-progress"; export const InstallationProgressProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { installTarget } = useInitialState(); - // Initialize state from sessionStorage or defaults const [wizardStep, setWizardStepState] = useState(() => { try { @@ -59,7 +56,7 @@ export const InstallationProgressProvider: React.FC<{ children: React.ReactNode } catch (error) { console.error("Failed to save installation progress:", error); } - }, [wizardStep, installationPhase, installTarget]); + }, [wizardStep, installationPhase]); const value = { wizardStep, diff --git a/web/src/providers/tests/InstallationProgressProvider.test.tsx b/web/src/providers/tests/InstallationProgressProvider.test.tsx index bda79404b8..7abd74ea64 100644 --- a/web/src/providers/tests/InstallationProgressProvider.test.tsx +++ b/web/src/providers/tests/InstallationProgressProvider.test.tsx @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { InstallationProgressProvider } from "../InstallationProgressProvider"; import { useInstallationProgress } from "../../contexts/InstallationProgressContext"; -import { InitialStateContext } from "../../contexts/InitialStateContext"; const STORAGE_KEY = "embedded-cluster-install-progress"; @@ -13,9 +12,7 @@ describe("InstallationProgressProvider", () => { }); const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - + {children} ); describe("Initialization", () => { @@ -58,7 +55,7 @@ describe("InstallationProgressProvider", () => { it("handles corrupted sessionStorage data gracefully", () => { sessionStorage.setItem(STORAGE_KEY, "invalid-json{"); - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { }); const { result } = renderHook(() => useInstallationProgress(), { wrapper }); @@ -180,7 +177,7 @@ describe("InstallationProgressProvider", () => { describe("Error Handling", () => { it("handles sessionStorage.setItem errors gracefully", () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { }); const originalSetItem = sessionStorage.setItem; // Mock setItem to throw an error From 468c74a7906adf7b2b88e5af0ae93f648f9b8723 Mon Sep 17 00:00:00 2001 From: JGAntunes Date: Thu, 9 Oct 2025 19:05:51 +0100 Subject: [PATCH 08/10] fix: upgrade flow --- .../wizard/installation/UpgradeStep.tsx | 9 +- .../phases/UpgradeInstallationPhase.tsx | 90 +++++ .../tests/UpgradeInstallationPhase.test.tsx | 369 ++++++++++++++++++ .../installation/tests/UpgradeStep.test.tsx | 241 ++++++++---- 4 files changed, 625 insertions(+), 84 deletions(-) create mode 100644 web/src/components/wizard/installation/phases/UpgradeInstallationPhase.tsx create mode 100644 web/src/components/wizard/installation/tests/UpgradeInstallationPhase.test.tsx diff --git a/web/src/components/wizard/installation/UpgradeStep.tsx b/web/src/components/wizard/installation/UpgradeStep.tsx index b9297d47e7..dfb601df30 100644 --- a/web/src/components/wizard/installation/UpgradeStep.tsx +++ b/web/src/components/wizard/installation/UpgradeStep.tsx @@ -9,6 +9,7 @@ import InstallationTimeline, { PhaseStatus } from './InstallationTimeline'; import AppPreflightPhase from './phases/AppPreflightPhase'; import AppInstallationPhase from './phases/AppInstallationPhase'; import { NextButtonConfig, BackButtonConfig } from './types'; +import UpgradeInstallationPhase from './phases/UpgradeInstallationPhase'; interface InstallationStepProps { onNext: () => void; @@ -16,13 +17,13 @@ interface InstallationStepProps { } const UpgradeStep: React.FC = ({ onNext, onBack }) => { - const { text } = useWizard(); + const { text, target } = useWizard(); const { settings } = useSettings(); const themeColor = settings.themeColor; const getPhaseOrder = (): InstallationPhase[] => { // Iteration 3: Include app preflights before app installation - return ["app-preflight", "app-installation"]; + return [`${target}-installation`, "app-preflight", "app-installation"]; }; const phaseOrder = getPhaseOrder(); @@ -127,6 +128,10 @@ const UpgradeStep: React.FC = ({ onNext, onBack }) => { }; switch (phase) { + //TODO this is a temporary hack to have an initial phase to trigger the app preflights. Once we support these phases we can removed and delete this component. + case 'kubernetes-installation': + case 'linux-installation': + return case 'app-preflight': return ; case 'app-installation': diff --git a/web/src/components/wizard/installation/phases/UpgradeInstallationPhase.tsx b/web/src/components/wizard/installation/phases/UpgradeInstallationPhase.tsx new file mode 100644 index 0000000000..dfb3ddf1b2 --- /dev/null +++ b/web/src/components/wizard/installation/phases/UpgradeInstallationPhase.tsx @@ -0,0 +1,90 @@ +import React, { useEffect } from 'react'; +import { useMutation } from "@tanstack/react-query"; +import { useAuth } from "../../../../contexts/AuthContext"; +import { useWizard } from '../../../../contexts/WizardModeContext'; +import { State } from '../../../../types'; +import ErrorMessage from '../shared/ErrorMessage'; +import { NextButtonConfig, BackButtonConfig } from '../types'; +import { getApiBase } from '../../../../utils/api-base'; +import { ApiError } from '../../../../utils/api-error'; + +interface KubernetesInstallationPhaseProps { + onNext: () => void; + onBack: () => void; + setNextButtonConfig: (config: NextButtonConfig) => void; + setBackButtonConfig: (config: BackButtonConfig) => void; + onStateChange: (status: State) => void; +} + +// TODO this is just a placeholder component to trigger the app preflights for the upgrade flow while we're missing the other phases of the upgrade +const UpgradeInstallationPhase: React.FC = ({ onNext, onBack, setNextButtonConfig, setBackButtonConfig, onStateChange }) => { + const { token } = useAuth(); + const { mode, target } = useWizard(); + + // Mutation for starting app preflights + const { mutate: startAppPreflights, error: startAppPreflightsError } = useMutation({ + mutationFn: async () => { + const apiBase = getApiBase(target, mode); + const response = await fetch(`${apiBase}/app-preflights/run`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ isUi: true }), + }); + + if (!response.ok) { + throw await ApiError.fromResponse(response, "Failed to start app preflight checks") + } + return response.json(); + }, + onSuccess: () => { + onStateChange('Succeeded'); + onNext(); + }, + }); + + // Report that step is running when component mounts + useEffect(() => { + onStateChange('Running'); + }, []); + + // Report failing state if there is an error starting app preflights + useEffect(() => { + if (startAppPreflightsError) { + onStateChange('Failed'); + } + }, [startAppPreflightsError]); + + // Update next button configuration + useEffect(() => { + setNextButtonConfig({ + disabled: false, + onClick: () => startAppPreflights(), + }); + }, [setNextButtonConfig]); + + // Update back button configuration + useEffect(() => { + setBackButtonConfig({ + hidden: false, + onClick: onBack, + }); + }, [setBackButtonConfig, onBack]); + + return ( +
    +
    +

    Installation

    +

    Start App Upgrade

    +
    + +
    + {startAppPreflightsError && } +
    +
    + ); +}; + +export default UpgradeInstallationPhase; diff --git a/web/src/components/wizard/installation/tests/UpgradeInstallationPhase.test.tsx b/web/src/components/wizard/installation/tests/UpgradeInstallationPhase.test.tsx new file mode 100644 index 0000000000..aca1dcbfa2 --- /dev/null +++ b/web/src/components/wizard/installation/tests/UpgradeInstallationPhase.test.tsx @@ -0,0 +1,369 @@ +import { describe, it, expect, vi, beforeAll, afterEach, afterAll, beforeEach } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { renderWithProviders } from '../../../../test/setup.tsx'; +import UpgradeInstallationPhase from '../phases/UpgradeInstallationPhase.tsx'; +import { withTestButton } from './TestWrapper.tsx'; + +const TestUpgradeInstallationPhase = withTestButton(UpgradeInstallationPhase); + +const createServer = () => setupServer( + // Mock app preflight run endpoint - matches both install and upgrade modes for both linux and kubernetes targets + http.post('*/api/*/install/app-preflights/run', () => { + return HttpResponse.json({ success: true }); + }), + http.post('*/api/*/upgrade/app-preflights/run', () => { + return HttpResponse.json({ success: true }); + }) +); + +describe('UpgradeInstallationPhase', () => { + const mockOnNext = vi.fn(); + const mockOnStateChange = vi.fn(); + let server: ReturnType; + + beforeAll(() => { + server = createServer(); + server.listen(); + }); + + afterEach(() => { + server.resetHandlers(); + vi.clearAllMocks(); + }); + + afterAll(() => { + server.close(); + }); + + // Basic Rendering Tests + it('renders basic UI elements', async () => { + renderWithProviders( + , + { + wrapperProps: { + mode: 'upgrade', + authenticated: true + } + } + ); + + expect(screen.getByText('Installation')).toBeInTheDocument(); + expect(screen.getByText('Start App Upgrade')).toBeInTheDocument(); + + // Next button should be enabled + await waitFor(() => { + const nextButton = screen.getByTestId('next-button'); + expect(nextButton).not.toBeDisabled(); + }); + }); + + // API Call Tests + it('calls app-preflights/run endpoint when Next is clicked', async () => { + let requestBody: any; + server.use( + http.post('*/api/*/upgrade/app-preflights/run', async ({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer test-token'); + expect(request.headers.get('Content-Type')).toBe('application/json'); + requestBody = await request.json(); + return HttpResponse.json({ success: true }); + }) + ); + + renderWithProviders( + , + { + wrapperProps: { + mode: 'upgrade', + authenticated: true + } + } + ); + + // Click Next button + await waitFor(() => { + const nextButton = screen.getByTestId('next-button'); + expect(nextButton).not.toBeDisabled(); + fireEvent.click(nextButton); + }); + + // Verify request body contains isUi: true + await waitFor(() => { + expect(requestBody).toEqual({ isUi: true }); + }); + }); + + // Success Flow Tests + it('calls onNext when API call succeeds', async () => { + renderWithProviders( + , + { + wrapperProps: { + mode: 'upgrade', + authenticated: true + } + } + ); + + // Click Next button + await waitFor(() => { + const nextButton = screen.getByTestId('next-button'); + expect(nextButton).not.toBeDisabled(); + fireEvent.click(nextButton); + }); + + // Should call onNext after successful API call + await waitFor(() => { + expect(mockOnNext).toHaveBeenCalledTimes(1); + }); + + // No error message should be displayed + expect(screen.queryByTestId('error-message')).not.toBeInTheDocument(); + }); + + // Error Handling Tests + it('displays error message when API call fails', async () => { + server.use( + http.post('*/api/*/upgrade/app-preflights/run', () => { + return HttpResponse.json( + { + statusCode: 500, + message: 'Internal server error' + }, + { status: 500 } + ); + }) + ); + + renderWithProviders( + , + { + wrapperProps: { + mode: 'upgrade', + authenticated: true + } + } + ); + + // Click Next button + await waitFor(() => { + const nextButton = screen.getByTestId('next-button'); + expect(nextButton).not.toBeDisabled(); + fireEvent.click(nextButton); + }); + + // Should show error message + await waitFor(() => { + expect(screen.getByText(/Failed to start app preflight checks/)).toBeInTheDocument(); + }); + + // Should NOT call onNext + expect(mockOnNext).not.toHaveBeenCalled(); + }); + + it('does not call onNext when API call fails', async () => { + server.use( + http.post('*/api/*/upgrade/app-preflights/run', () => { + return HttpResponse.json( + { statusCode: 400, message: 'Bad request' }, + { status: 400 } + ); + }) + ); + + renderWithProviders( + , + { + wrapperProps: { + mode: 'upgrade', + authenticated: true + } + } + ); + + // Click Next button + await waitFor(() => { + const nextButton = screen.getByTestId('next-button'); + fireEvent.click(nextButton); + }); + + // Wait for error to appear + await waitFor(() => { + expect(screen.getByText(/Failed to start app preflight checks/)).toBeInTheDocument(); + }); + + // onNext should not have been called + expect(mockOnNext).not.toHaveBeenCalled(); + }); + + it('handles network failure gracefully', async () => { + server.use( + http.post('*/api/*/upgrade/app-preflights/run', () => { + return HttpResponse.error(); + }) + ); + + renderWithProviders( + , + { + wrapperProps: { + mode: 'upgrade', + authenticated: true + } + } + ); + + // Click Next button + await waitFor(() => { + const nextButton = screen.getByTestId('next-button'); + fireEvent.click(nextButton); + }); + + // Should show network error message + await waitFor(() => { + expect(screen.getByText(/Failed to fetch/)).toBeInTheDocument(); + }); + + // Should NOT call onNext + expect(mockOnNext).not.toHaveBeenCalled(); + }); +}); + +// onStateChange Tests +describe('UpgradeInstallationPhase - onStateChange Tests', () => { + let server: ReturnType; + const mockOnNext = vi.fn(); + const mockOnStateChange = vi.fn(); + + beforeAll(() => { + server = createServer(); + server.listen(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + }); + + it('calls onStateChange with "Running" immediately when component mounts', async () => { + renderWithProviders( + , + { + wrapperProps: { + mode: 'upgrade', + authenticated: true + } + } + ); + + // Should call onStateChange with "Running" immediately on mount + expect(mockOnStateChange).toHaveBeenCalledWith('Running'); + expect(mockOnStateChange).toHaveBeenCalledTimes(1); + }); + + it('calls onStateChange with "Succeeded" when API call succeeds', async () => { + renderWithProviders( + , + { + wrapperProps: { + mode: 'upgrade', + authenticated: true + } + } + ); + + // Should call onStateChange with "Running" immediately on mount + expect(mockOnStateChange).toHaveBeenCalledWith('Running'); + + // Click Next button + await waitFor(() => { + const nextButton = screen.getByTestId('next-button'); + fireEvent.click(nextButton); + }); + + // Should call onStateChange with "Succeeded" when API call succeeds + await waitFor(() => { + expect(mockOnStateChange).toHaveBeenCalledWith('Succeeded'); + }); + + // Expect sequence: Running (mount), Succeeded (success) + const calls = mockOnStateChange.mock.calls.map(args => args[0]); + expect(calls).toEqual(['Running', 'Succeeded']); + expect(mockOnStateChange).toHaveBeenCalledTimes(2); + }); + + it('calls onStateChange with "Failed" when API call fails', async () => { + server.use( + http.post('*/api/*/upgrade/app-preflights/run', () => { + return HttpResponse.json( + { statusCode: 500, message: 'Internal server error' }, + { status: 500 } + ); + }) + ); + + renderWithProviders( + , + { + wrapperProps: { + mode: 'upgrade', + authenticated: true + } + } + ); + + // Should call onStateChange with "Running" immediately on mount + expect(mockOnStateChange).toHaveBeenCalledWith('Running'); + + // Click Next button + await waitFor(() => { + const nextButton = screen.getByTestId('next-button'); + fireEvent.click(nextButton); + }); + + // Should call onStateChange with "Failed" when API call fails + await waitFor(() => { + expect(mockOnStateChange).toHaveBeenCalledWith('Failed'); + }); + + // Expect sequence: Running (mount), Failed (error) + const calls = mockOnStateChange.mock.calls.map(args => args[0]); + expect(calls).toEqual(['Running', 'Failed']); + expect(mockOnStateChange).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/src/components/wizard/installation/tests/UpgradeStep.test.tsx b/web/src/components/wizard/installation/tests/UpgradeStep.test.tsx index 19eb134fdc..5aeca13b73 100644 --- a/web/src/components/wizard/installation/tests/UpgradeStep.test.tsx +++ b/web/src/components/wizard/installation/tests/UpgradeStep.test.tsx @@ -19,6 +19,11 @@ type PhaseOutcome = 'success' | 'failure'; // Mock configuration for each phase const phaseMockConfig = { + upgradeInstallation: { + outcome: 'success' as PhaseOutcome, + buttonDisabled: false, + autoStateChange: null as { delay: number, state: State } | null + }, appPreflight: { outcome: 'success' as PhaseOutcome, buttonDisabled: false, @@ -75,6 +80,10 @@ const createPhaseMock = (phaseName: string, phaseKey: keyof typeof phaseMockConf }; // Mock all the phase components +vi.mock('../phases/UpgradeInstallationPhase', () => ({ + default: (props: PhaseProps) => createPhaseMock('Upgrade Installation Phase', 'upgradeInstallation', 'upgrade-installation-phase')(props) +})); + vi.mock('../phases/AppPreflightPhase', () => ({ default: (props: PhaseProps) => createPhaseMock('App Preflight Phase', 'appPreflight', 'app-preflight-phase')(props) })); @@ -97,6 +106,7 @@ describe('UpgradeStep', () => { vi.clearAllMocks(); // Reset all phases + phaseMockConfig.upgradeInstallation = { outcome: 'success', buttonDisabled: false, autoStateChange: null }; phaseMockConfig.appPreflight = { outcome: 'success', buttonDisabled: false, autoStateChange: null }; phaseMockConfig.appInstallation = { outcome: 'success', buttonDisabled: false, autoStateChange: null }; }); @@ -105,7 +115,7 @@ describe('UpgradeStep', () => { server.close(); }); - const renderUpgradeStep = () => { + const renderUpgradeStep = (target: 'linux' | 'kubernetes' = 'linux') => { const mockOnBack = vi.fn(); return { ...renderWithProviders( @@ -113,6 +123,7 @@ describe('UpgradeStep', () => { { wrapperProps: { mode: 'upgrade', + target, authenticated: true } } @@ -121,25 +132,83 @@ describe('UpgradeStep', () => { }; }; - describe('Phase Rendering', () => { - it('renders correct phase order for upgrade', () => { - renderUpgradeStep(); + describe('Linux Target', () => { + it('renders correct phase order for Linux upgrade', () => { + renderUpgradeStep('linux'); // Should show timeline with correct phases expect(screen.getByTestId('timeline-title')).toBeInTheDocument(); + expect(screen.getByTestId('timeline-linux-installation')).toBeInTheDocument(); expect(screen.getByTestId('timeline-app-preflight')).toBeInTheDocument(); expect(screen.getByTestId('timeline-app-installation')).toBeInTheDocument(); - // Should start with App preflight phase - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); + // Should start with Upgrade Installation phase + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); }); - it('progresses through all phases in correct order', async () => { - renderUpgradeStep(); + it('progresses through all Linux phases in correct order', async () => { + renderUpgradeStep('linux'); + + // Start with Upgrade Installation phase - button should be enabled by default + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId('installation-next-button')).not.toBeDisabled(); + }); + + // Click next to go to App preflight + fireEvent.click(screen.getByTestId('installation-next-button')); - // Start with App preflight - button should be enabled by default - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); await waitFor(() => { + expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); + expect(screen.getByTestId('installation-next-button')).not.toBeDisabled(); + }); + + // Click next to go to App installation + fireEvent.click(screen.getByTestId('installation-next-button')); + + await waitFor(() => { + expect(screen.getByTestId('app-installation-phase')).toBeInTheDocument(); + // Button should still be enabled for this phase + expect(screen.getByTestId('installation-next-button')).not.toBeDisabled(); + }); + + // Click finish + fireEvent.click(screen.getByTestId('installation-next-button')); + + await waitFor(() => { + expect(mockOnNext).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Kubernetes Target', () => { + it('renders correct phase order for Kubernetes upgrade', () => { + renderUpgradeStep('kubernetes'); + + // Should show timeline with correct phases + expect(screen.getByTestId('timeline-title')).toBeInTheDocument(); + expect(screen.getByTestId('timeline-kubernetes-installation')).toBeInTheDocument(); + expect(screen.getByTestId('timeline-app-preflight')).toBeInTheDocument(); + expect(screen.getByTestId('timeline-app-installation')).toBeInTheDocument(); + + // Should start with Upgrade Installation phase + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); + }); + + it('progresses through all Kubernetes phases in correct order', async () => { + renderUpgradeStep('kubernetes'); + + // Start with Upgrade Installation phase - button should be enabled by default + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId('installation-next-button')).not.toBeDisabled(); + }); + + // Click next to go to App preflight + fireEvent.click(screen.getByTestId('installation-next-button')); + + await waitFor(() => { + expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); expect(screen.getByTestId('installation-next-button')).not.toBeDisabled(); }); @@ -182,60 +251,60 @@ describe('UpgradeStep', () => { it('keeps completed and current phases mounted when switching between phases', async () => { renderUpgradeStep(); - // Start with App preflight - should be visible - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); - expect(screen.getByTestId('app-preflight-container')).toHaveClass('block'); - expect(screen.getByTestId('app-preflight-container')).not.toHaveClass('hidden'); + // Start with Upgrade Installation phase - should be visible + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); + expect(screen.getByTestId('linux-installation-container')).toHaveClass('block'); + expect(screen.getByTestId('linux-installation-container')).not.toHaveClass('hidden'); - // Move to second phase + // Move to second phase (App preflight) fireEvent.click(screen.getByTestId('installation-next-button')); await waitFor(() => { // Second phase should now be visible - expect(screen.getByTestId('app-installation-phase')).toBeInTheDocument(); - expect(screen.getByTestId('app-installation-container')).toHaveClass('block'); - expect(screen.getByTestId('app-installation-container')).not.toHaveClass('hidden'); + expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); + expect(screen.getByTestId('app-preflight-container')).toHaveClass('block'); + expect(screen.getByTestId('app-preflight-container')).not.toHaveClass('hidden'); // First phase should now be hidden but still mounted - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); - expect(screen.getByTestId('app-preflight-container')).toHaveClass('hidden'); - expect(screen.getByTestId('app-preflight-container')).not.toHaveClass('block'); + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); + expect(screen.getByTestId('linux-installation-container')).toHaveClass('hidden'); + expect(screen.getByTestId('linux-installation-container')).not.toHaveClass('block'); }); // Click back on first completed phase in timeline - fireEvent.click(screen.getByTestId('timeline-app-preflight')); + fireEvent.click(screen.getByTestId('timeline-linux-installation')); await waitFor(() => { // First phase container should be visible - expect(screen.getByTestId('app-preflight-container')).toHaveClass('block'); - expect(screen.getByTestId('app-preflight-container')).not.toHaveClass('hidden'); + expect(screen.getByTestId('linux-installation-container')).toHaveClass('block'); + expect(screen.getByTestId('linux-installation-container')).not.toHaveClass('hidden'); // Second phase should still exist in DOM but hidden - expect(screen.getByTestId('app-installation-phase')).toBeInTheDocument(); - expect(screen.getByTestId('app-installation-container')).toHaveClass('hidden'); - expect(screen.getByTestId('app-installation-container')).not.toHaveClass('block'); + expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); + expect(screen.getByTestId('app-preflight-container')).toHaveClass('hidden'); + expect(screen.getByTestId('app-preflight-container')).not.toHaveClass('block'); }); }); it('allows clicking on completed phases to view them', async () => { renderUpgradeStep(); - // Start with App preflight - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); + // Start with Upgrade Installation phase + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); // Complete first phase fireEvent.click(screen.getByTestId('installation-next-button')); await waitFor(() => { - expect(screen.getByTestId('app-installation-phase')).toBeInTheDocument(); + expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); }); - // Now click back on the completed App preflight phase in timeline - fireEvent.click(screen.getByTestId('timeline-app-preflight')); + // Now click back on the completed Upgrade Installation phase in timeline + fireEvent.click(screen.getByTestId('timeline-linux-installation')); await waitFor(() => { // Should show the previous phase content - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); }); }); @@ -250,8 +319,8 @@ describe('UpgradeStep', () => { describe('Failure State Handling', () => { it('displays failure state in timeline when a phase fails', async () => { - // Configure App preflight to fail - phaseMockConfig.appPreflight.outcome = 'failure'; + // Configure Upgrade Installation phase to fail + phaseMockConfig.upgradeInstallation.outcome = 'failure'; renderUpgradeStep(); @@ -270,22 +339,22 @@ describe('UpgradeStep', () => { }); // Should not progress to next phase - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); - expect(screen.queryByTestId('app-installation-phase')).not.toBeInTheDocument(); + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); + expect(screen.queryByTestId('app-preflight-phase')).not.toBeInTheDocument(); }); it('allows clicking on failed phases to view them', async () => { - // Configure second phase to fail - phaseMockConfig.appInstallation.outcome = 'failure'; + // Configure second phase (app-preflight) to fail + phaseMockConfig.appPreflight.outcome = 'failure'; renderUpgradeStep(); - // Complete first phase (preflight) - should succeed and move to second phase + // Complete first phase (upgrade installation) - should succeed and move to second phase fireEvent.click(screen.getByTestId('installation-next-button')); await waitFor(() => { // Should now be on the second phase (which is configured to fail) - expect(screen.getByTestId('app-installation-phase')).toBeInTheDocument(); + expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); }); // Try to complete the second phase - should fail and stay on same phase @@ -295,46 +364,54 @@ describe('UpgradeStep', () => { // Should show failure icon in timeline expect(screen.getByTestId('icon-failed')).toBeInTheDocument(); // Should still be showing the failed phase - expect(screen.getByTestId('app-installation-phase')).toBeInTheDocument(); + expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); }); // Should be able to click on the failed phase button in timeline - const failedPhaseButton = screen.getByTestId('timeline-app-installation'); + const failedPhaseButton = screen.getByTestId('timeline-app-preflight'); expect(failedPhaseButton).not.toBeDisabled(); // Click on completed phase first - fireEvent.click(screen.getByTestId('timeline-app-preflight')); + fireEvent.click(screen.getByTestId('timeline-linux-installation')); await waitFor(() => { - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); }); // Then click back to failed phase fireEvent.click(failedPhaseButton); await waitFor(() => { - expect(screen.getByTestId('app-installation-phase')).toBeInTheDocument(); + expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); }); }); it('shows mixed success and failure states in timeline', async () => { - // Configure: first succeeds, second fails + // Configure: first two phases succeed, third fails phaseMockConfig.appInstallation.outcome = 'failure'; renderUpgradeStep(); - // Complete first phase successfully + // Complete first phase successfully (upgrade installation) fireEvent.click(screen.getByTestId('installation-next-button')); await waitFor(() => { - // Should be on the second phase + // Should be on the second phase (app preflight) + expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); + }); + + // Complete second phase successfully + fireEvent.click(screen.getByTestId('installation-next-button')); + + await waitFor(() => { + // Should be on the third phase (app installation) expect(screen.getByTestId('app-installation-phase')).toBeInTheDocument(); }); - // Fail second phase + // Fail third phase fireEvent.click(screen.getByTestId('installation-next-button')); await waitFor(() => { - // Should have both success and failure icons - expect(screen.getByTestId('icon-succeeded')).toBeInTheDocument(); + // Should have multiple success icons (for completed phases) and one failure icon + expect(screen.getAllByTestId('icon-succeeded').length).toBeGreaterThan(0); expect(screen.getByTestId('icon-failed')).toBeInTheDocument(); }); }); @@ -346,8 +423,8 @@ describe('UpgradeStep', () => { await waitFor(() => { // Running phase expect(screen.getByTestId('icon-running')).toBeInTheDocument(); - // Pending phase - expect(screen.getByTestId('icon-pending')).toBeInTheDocument(); + // Pending phases + expect(screen.getAllByTestId('icon-pending').length).toBeGreaterThan(0); }); // Complete first phase @@ -363,7 +440,7 @@ describe('UpgradeStep', () => { it('handles disabled button states from phases', async () => { // Configure first phase to disable the button - phaseMockConfig.appPreflight.buttonDisabled = true; + phaseMockConfig.upgradeInstallation.buttonDisabled = true; renderUpgradeStep(); @@ -376,14 +453,14 @@ describe('UpgradeStep', () => { fireEvent.click(screen.getByTestId('installation-next-button')); // Should still be on first phase - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); - expect(screen.queryByTestId('app-installation-phase')).not.toBeInTheDocument(); + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); + expect(screen.queryByTestId('app-preflight-phase')).not.toBeInTheDocument(); }); it('enables button when phase configuration changes', async () => { // Test that different phases can have different button states - phaseMockConfig.appPreflight.buttonDisabled = false; // enabled - phaseMockConfig.appInstallation.buttonDisabled = true; // disabled + phaseMockConfig.upgradeInstallation.buttonDisabled = false; // enabled + phaseMockConfig.appPreflight.buttonDisabled = true; // disabled renderUpgradeStep(); @@ -397,7 +474,7 @@ describe('UpgradeStep', () => { // Second phase button should be disabled await waitFor(() => { - expect(screen.getByTestId('app-installation-phase')).toBeInTheDocument(); + expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); expect(screen.getByTestId('installation-next-button')).toBeDisabled(); }); }); @@ -406,44 +483,44 @@ describe('UpgradeStep', () => { describe('Auto-advance functionality', () => { it('automatically advances when phase succeeds via auto state change', async () => { // Configure first phase to automatically succeed after 100ms - phaseMockConfig.appPreflight.autoStateChange = { delay: 100, state: 'Succeeded' }; + phaseMockConfig.upgradeInstallation.autoStateChange = { delay: 100, state: 'Succeeded' }; renderUpgradeStep(); - // Should start with App preflight - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); - expect(screen.getByTestId('app-preflight-container')).toHaveClass('block'); - expect(screen.getByTestId('app-preflight-container')).not.toHaveClass('hidden'); + // Should start with Upgrade Installation phase + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); + expect(screen.getByTestId('linux-installation-container')).toHaveClass('block'); + expect(screen.getByTestId('linux-installation-container')).not.toHaveClass('hidden'); // Wait for the phase to initialize await waitFor(() => { expect(screen.getByTestId('installation-next-button')).not.toBeDisabled(); }); - // Should automatically advance to App Installation Phase + // Should automatically advance to App Preflight Phase await waitFor(() => { // Second phase should now be visible - expect(screen.getByTestId('app-installation-phase')).toBeInTheDocument(); - expect(screen.getByTestId('app-installation-container')).toHaveClass('block'); - expect(screen.getByTestId('app-installation-container')).not.toHaveClass('hidden'); + expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); + expect(screen.getByTestId('app-preflight-container')).toHaveClass('block'); + expect(screen.getByTestId('app-preflight-container')).not.toHaveClass('hidden'); // First phase should now be hidden but still mounted - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); - expect(screen.getByTestId('app-preflight-container')).toHaveClass('hidden'); - expect(screen.getByTestId('app-preflight-container')).not.toHaveClass('block'); + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); + expect(screen.getByTestId('linux-installation-container')).toHaveClass('hidden'); + expect(screen.getByTestId('linux-installation-container')).not.toHaveClass('block'); }); }); it('does not auto-advance when phase fails', async () => { // Configure first phase to automatically fail after 100ms - phaseMockConfig.appPreflight.autoStateChange = { delay: 100, state: 'Failed' }; + phaseMockConfig.upgradeInstallation.autoStateChange = { delay: 100, state: 'Failed' }; renderUpgradeStep(); - // Should start with App preflight - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); - expect(screen.getByTestId('app-preflight-container')).toHaveClass('block'); - expect(screen.getByTestId('app-preflight-container')).not.toHaveClass('hidden'); + // Should start with Upgrade Installation phase + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); + expect(screen.getByTestId('linux-installation-container')).toHaveClass('block'); + expect(screen.getByTestId('linux-installation-container')).not.toHaveClass('hidden'); // Wait for the phase to initialize await waitFor(() => { @@ -456,13 +533,13 @@ describe('UpgradeStep', () => { }); // Should still be on the same phase, not advance - expect(screen.getByTestId('app-preflight-phase')).toBeInTheDocument(); - expect(screen.getByTestId('app-preflight-container')).toHaveClass('block'); - expect(screen.getByTestId('app-preflight-container')).not.toHaveClass('hidden'); + expect(screen.getByTestId('upgrade-installation-phase')).toBeInTheDocument(); + expect(screen.getByTestId('linux-installation-container')).toHaveClass('block'); + expect(screen.getByTestId('linux-installation-container')).not.toHaveClass('hidden'); // Second phase should not be mounted at all - expect(screen.queryByTestId('app-installation-phase')).not.toBeInTheDocument(); - expect(screen.queryByTestId('app-installation-container')).not.toBeInTheDocument(); + expect(screen.queryByTestId('app-preflight-phase')).not.toBeInTheDocument(); + expect(screen.queryByTestId('app-preflight-container')).not.toBeInTheDocument(); }); }); From f927600bab4f1ba41d71b9e361fe64e3d6afb183 Mon Sep 17 00:00:00 2001 From: JGAntunes Date: Thu, 9 Oct 2025 19:09:31 +0100 Subject: [PATCH 09/10] chore: lint --- .../installation/tests/UpgradeInstallationPhase.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/wizard/installation/tests/UpgradeInstallationPhase.test.tsx b/web/src/components/wizard/installation/tests/UpgradeInstallationPhase.test.tsx index aca1dcbfa2..83d387d499 100644 --- a/web/src/components/wizard/installation/tests/UpgradeInstallationPhase.test.tsx +++ b/web/src/components/wizard/installation/tests/UpgradeInstallationPhase.test.tsx @@ -64,12 +64,12 @@ describe('UpgradeInstallationPhase', () => { // API Call Tests it('calls app-preflights/run endpoint when Next is clicked', async () => { - let requestBody: any; + let requestBody: { isUi: boolean } | undefined; server.use( http.post('*/api/*/upgrade/app-preflights/run', async ({ request }) => { expect(request.headers.get('Authorization')).toBe('Bearer test-token'); expect(request.headers.get('Content-Type')).toBe('application/json'); - requestBody = await request.json(); + requestBody = await request.json() as { isUi: boolean }; return HttpResponse.json({ success: true }); }) ); From a01cb679f4b51fbb756817685c532e2ad6874e95 Mon Sep 17 00:00:00 2001 From: JGAntunes Date: Thu, 9 Oct 2025 19:12:31 +0100 Subject: [PATCH 10/10] chore: utility scripts --- web/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index ba79ccc8c4..9cd0353ad6 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,8 @@ "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", "lint-fix": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --fix", "preview": "vite preview", - "test:unit": "vitest run" + "test:unit": "vitest run", + "test": "npm run lint && npm run test:unit" }, "dependencies": { "@tailwindcss/forms": "^0.5.10",