diff --git a/api/internal/managers/infra/install.go b/api/internal/managers/infra/install.go index 57b870f5b..13b85fcaa 100644 --- a/api/internal/managers/infra/install.go +++ b/api/internal/managers/infra/install.go @@ -176,6 +176,8 @@ func (m *infraManager) installK0s(ctx context.Context, rc runtimeconfig.RuntimeC if err := m.setComponentStatus(componentName, types.StateSucceeded, ""); err != nil { m.logger.WithField("error", err).Error("set succeeded status") } + // Send completion message + m.setStatusDesc(fmt.Sprintf("%s is ready", componentName)) } }() @@ -343,6 +345,15 @@ func (m *infraManager) installExtensions(ctx context.Context, hcli helm.Client) return fmt.Errorf("set extensions status: %w", err) } + progressChan := make(chan extensions.ExtensionsProgress) + defer close(progressChan) + + go func() { + for progress := range progressChan { + m.setStatusDesc(fmt.Sprintf("Installing %s (%d/%d)", componentName, progress.Current, progress.Total)) + } + }() + defer func() { if r := recover(); r != nil { finalErr = fmt.Errorf("install extensions recovered from panic: %v: %s", r, string(debug.Stack())) @@ -362,7 +373,7 @@ func (m *infraManager) installExtensions(ctx context.Context, hcli helm.Client) logFn := m.logFn("extensions") logFn("installing extensions") - if err := extensions.Install(ctx, hcli, nil); err != nil { + if err := extensions.Install(ctx, hcli, progressChan); err != nil { return fmt.Errorf("install extensions: %w", err) } return nil diff --git a/web/src/components/wizard/InstallationStep.tsx b/web/src/components/wizard/InstallationStep.tsx index 88cef8057..1a7a27d9a 100644 --- a/web/src/components/wizard/InstallationStep.tsx +++ b/web/src/components/wizard/InstallationStep.tsx @@ -60,8 +60,64 @@ const InstallationStep: React.FC = ({ onNext }) => { if (components.length === 0) { return 0; } - const completedComponents = components.filter(component => component.status?.state === 'Succeeded').length; - return Math.round((completedComponents / components.length) * 100); + + let totalProgress = 0; + const componentWeight = 100 / components.length; + + components.forEach(component => { + let componentProgress = 0; + + switch (component.status?.state) { + case 'Succeeded': + componentProgress = 100; + break; + case 'Running': + if (component.name === 'Runtime') { + // Split Runtime's progress between installing and waiting phases + const statusDescription = infraStatusResponse?.status?.description || ''; + if (statusDescription.includes('Installing Runtime')) { + componentProgress = 25; // 25% of Runtime's weight = installing phase + } else if (statusDescription.includes('Waiting for Runtime')) { + componentProgress = 75; // 75% of Runtime's weight = waiting phase + } else { + componentProgress = 50; // Fallback for other Running states + } + } else if (component.name === 'Additional Components') { + // Parse incremental progress for additional components + const statusDescription = infraStatusResponse?.status?.description || ''; + const match = statusDescription.match(/Installing additional components \((\d+)\/(\d+)\)/); + if (match) { + const current = parseInt(match[1]); + const total = parseInt(match[2]); + if (total > 0) { + // Calculate progress based on completed extensions + current extension progress + const completedExtensions = current - 1; // Extensions that are done + const currentExtensionProgress = 50; // Current extension gets 50% while running + const totalProgress = (completedExtensions * 100) + currentExtensionProgress; + componentProgress = totalProgress / total; // Average across all extensions + } else { + componentProgress = 50; // Fallback if parsing fails + } + } else { + componentProgress = 50; // Fallback for other Running states + } + } else { + componentProgress = 50; // Other components get 50% when Running + } + break; + case 'Failed': + componentProgress = 0; // No progress for failed components + break; + case 'Pending': + default: + componentProgress = 0; // No progress for pending components + break; + } + + totalProgress += (componentProgress / 100) * componentWeight; + }); + + return Math.round(totalProgress); } const renderInfrastructurePhase = () => ( diff --git a/web/src/components/wizard/tests/InstallationStep.test.tsx b/web/src/components/wizard/tests/InstallationStep.test.tsx index bc1b54a18..8c92bf550 100644 --- a/web/src/components/wizard/tests/InstallationStep.test.tsx +++ b/web/src/components/wizard/tests/InstallationStep.test.tsx @@ -274,4 +274,151 @@ describe("InstallationStep", () => { expect(screen.queryByTestId("log-viewer-content")).not.toBeInTheDocument(); }); }); + + it("shows correct progress for Runtime installation phases", async () => { + const mockOnNext = vi.fn(); + + // Test Runtime installing phase + server.use( + http.get("*/api/install/infra/status", ({ request }) => { + expect(request.headers.get("Authorization")).toBe("Bearer test-token"); + return HttpResponse.json({ + status: { state: "Running", description: "Installing Runtime" }, + components: [ + { name: "Runtime", status: { state: "Running" } }, + { name: "Disaster Recovery", status: { state: "Pending" } } + ] + }); + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + preloadedState: { + prototypeSettings: MOCK_PROTOTYPE_SETTINGS, + config: mockConfig, + }, + }, + }); + + // Wait for progress update + await waitFor(() => { + expect(screen.getByText("Installing Runtime")).toBeInTheDocument(); + }); + + // Verify Runtime component is installing + const runtimeContainer = screen.getByTestId("status-indicator-runtime"); + expect(runtimeContainer).toBeInTheDocument(); + expect(within(runtimeContainer).getByTestId("status-title")).toHaveTextContent("Runtime"); + expect(within(runtimeContainer).getByTestId("status-text")).toHaveTextContent("Installing..."); + + // Test Runtime waiting phase + server.use( + http.get("*/api/install/infra/status", ({ request }) => { + expect(request.headers.get("Authorization")).toBe("Bearer test-token"); + return HttpResponse.json({ + status: { state: "Running", description: "Waiting for Runtime" }, + components: [ + { name: "Runtime", status: { state: "Running" } }, + { name: "Disaster Recovery", status: { state: "Pending" } } + ] + }); + }) + ); + + // Wait for progress update with retry + await waitFor(() => { + expect(screen.getByText("Waiting for Runtime")).toBeInTheDocument(); + }, { timeout: 5000 }); // Increase timeout and add implicit retry + + // Verify Runtime component is still running but in waiting phase + expect(within(runtimeContainer).getByTestId("status-text")).toHaveTextContent("Installing..."); + }); + + it("shows correct progress for mixed component states", async () => { + const mockOnNext = vi.fn(); + server.use( + http.get("*/api/install/infra/status", ({ request }) => { + expect(request.headers.get("Authorization")).toBe("Bearer test-token"); + return HttpResponse.json({ + status: { state: "Running", description: "Installing components..." }, + components: [ + { name: "Runtime", status: { state: "Succeeded" } }, + { name: "Disaster Recovery", status: { state: "Running" } }, + { name: "Additional Components", status: { state: "Pending" } } + ] + }); + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + preloadedState: { + prototypeSettings: MOCK_PROTOTYPE_SETTINGS, + config: mockConfig, + }, + }, + }); + + // Wait for progress update + await waitFor(() => { + expect(screen.getByText("Installing components...")).toBeInTheDocument(); + }); + + // Verify component states + const runtimeContainer = screen.getByTestId("status-indicator-runtime"); + expect(within(runtimeContainer).getByTestId("status-text")).toHaveTextContent("Complete"); + + const drContainer = screen.getByTestId("status-indicator-disaster-recovery"); + expect(within(drContainer).getByTestId("status-text")).toHaveTextContent("Installing..."); + + const additionalContainer = screen.getByTestId("status-indicator-additional-components"); + expect(within(additionalContainer).getByTestId("status-text")).toHaveTextContent("Pending"); + }); + + it("shows completion messages when components succeed", async () => { + const mockOnNext = vi.fn(); + + // Test component completion message + server.use( + http.get("*/api/install/infra/status", ({ request }) => { + expect(request.headers.get("Authorization")).toBe("Bearer test-token"); + return HttpResponse.json({ + status: { state: "Running", description: "Installing components..." }, + components: [ + { name: "Runtime", status: { state: "Succeeded" } }, + { name: "Storage", status: { state: "Succeeded" } }, + { name: "Runtime Operator", status: { state: "Running" } } + ] + }); + }) + ); + + renderWithProviders(, { + wrapperProps: { + authenticated: true, + preloadedState: { + prototypeSettings: MOCK_PROTOTYPE_SETTINGS, + config: mockConfig, + }, + }, + }); + + // Wait for completion message + await waitFor(() => { + expect(screen.getByText("Installing components...")).toBeInTheDocument(); + }); + + // Verify component states + const runtimeContainer = screen.getByTestId("status-indicator-runtime"); + expect(within(runtimeContainer).getByTestId("status-text")).toHaveTextContent("Complete"); + + const storageContainer = screen.getByTestId("status-indicator-storage"); + expect(within(storageContainer).getByTestId("status-text")).toHaveTextContent("Complete"); + + const operatorContainer = screen.getByTestId("status-indicator-runtime-operator"); + expect(within(operatorContainer).getByTestId("status-text")).toHaveTextContent("Installing..."); + }); });