Skip to content

progress bar improvements #2344

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion api/internal/managers/infra/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}()

Expand Down Expand Up @@ -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()))
Expand All @@ -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
Expand Down
60 changes: 58 additions & 2 deletions web/src/components/wizard/InstallationStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,64 @@ const InstallationStep: React.FC<InstallationStepProps> = ({ 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 = () => (
Expand Down
147 changes: 147 additions & 0 deletions web/src/components/wizard/tests/InstallationStep.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<InstallationStep onNext={mockOnNext} />, {
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(<InstallationStep onNext={mockOnNext} />, {
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(<InstallationStep onNext={mockOnNext} />, {
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...");
});
});