Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion api/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion api/docs/docs.go

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion api/docs/swagger.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion api/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ components:
type: string
message:
type: string
status_code:
statusCode:
type: integer
type: object
types.AppConfig:
Expand Down
14 changes: 7 additions & 7 deletions api/internal/handlers/utils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
{
Expand All @@ -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",
},
},
{
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion api/types/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
41 changes: 22 additions & 19 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -17,25 +18,27 @@ function App() {
<QueryClientProvider client={queryClient}>
<AuthProvider>
<SettingsProvider>
<LinuxConfigProvider>
<KubernetesConfigProvider>
<div className="min-h-screen bg-gray-50 text-gray-900 font-sans">
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<WizardProvider>
<InstallWizard />
</WizardProvider>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</div>
</KubernetesConfigProvider>
</LinuxConfigProvider>
<InstallationProgressProvider>
<LinuxConfigProvider>
<KubernetesConfigProvider>
<div className="min-h-screen bg-gray-50 text-gray-900 font-sans">
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<WizardProvider>
<InstallWizard />
</WizardProvider>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</div>
</KubernetesConfigProvider>
</LinuxConfigProvider>
</InstallationProgressProvider>
</SettingsProvider>
</AuthProvider>
<ConnectionMonitor />
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/wizard/InstallWizard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<WizardStep>("welcome");
const { wizardStep: currentStep, setWizardStep: setCurrentStep } = useInstallationProgress();
const { text, target, mode } = useWizard();
let steps: WizardStep[] = []

Expand Down
76 changes: 34 additions & 42 deletions web/src/components/wizard/config/ConfigurationStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConfigurationStepProps> = ({ onNext }) => {
const { text, target, mode } = useWizard();
const { token } = useAuth();
Expand All @@ -38,18 +35,18 @@ const ConfigurationStep: React.FC<ConfigurationStepProps> = ({ onNext }) => {
const [generalError, setGeneralError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const { debouncedFetch } = useDebouncedFetch({ debounceMs: 250 });

const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
const [itemToFocus, setItemToFocus] = useState<AppConfigItem | null>(null);

// Holds refs to each item by name for focusing
const itemRefs = useRef<Record<string, HTMLElement | null>>({});

// 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) => {
Expand All @@ -72,18 +69,21 @@ const ConfigurationStep: React.FC<ConfigurationStepProps> = ({ 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) {
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]);

Expand All @@ -102,7 +102,7 @@ const ConfigurationStep: React.FC<ConfigurationStepProps> = ({ 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) {
Expand All @@ -111,54 +111,54 @@ const ConfigurationStep: React.FC<ConfigurationStepProps> = ({ 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;
};

// 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<string, string> => {
const parseServerErrors = (error: ApiError): Record<string, string> => {
const itemErrors: Record<string, string> = {};

// 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<void, ConfigError>({
const { mutate: submitConfigValues } = useMutation<void, ApiError>({
mutationFn: async () => {
const apiBase = getApiBase(target, mode);
const response = await fetch(`${apiBase}/app/config/values`, {
Expand All @@ -171,15 +171,7 @@ const ConfigurationStep: React.FC<ConfigurationStepProps> = ({ 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: () => {
Expand All @@ -190,10 +182,10 @@ const ConfigurationStep: React.FC<ConfigurationStepProps> = ({ 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);
Expand All @@ -216,11 +208,11 @@ const ConfigurationStep: React.FC<ConfigurationStepProps> = ({ 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
Expand Down
32 changes: 27 additions & 5 deletions web/src/components/wizard/installation/InstallationStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +22,7 @@ interface InstallationStepProps {
const InstallationStep: React.FC<InstallationStepProps> = ({ onNext, onBack }) => {
const { target, text } = useWizard();
const { settings } = useSettings();
const { installationPhase: storedPhase, setInstallationPhase } = useInstallationProgress();
const themeColor = settings.themeColor;

const getPhaseOrder = (): InstallationPhase[] => {
Expand All @@ -31,9 +33,29 @@ const InstallationStep: React.FC<InstallationStepProps> = ({ onNext, onBack }) =
};

const phaseOrder = getPhaseOrder();
const [currentPhase, setCurrentPhase] = useState<InstallationPhase>(phaseOrder[0]);
const [selectedPhase, setSelectedPhase] = useState<InstallationPhase>(phaseOrder[0]);
const [completedPhases, setCompletedPhases] = useState<Set<InstallationPhase>>(new Set());
const completedPhaseSet = new Set<InstallationPhase>();

// 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<InstallationPhase>(initialPhase);

// Selected phase for UI (can be current or any completed phase)
const [selectedPhase, setSelectedPhase] = useState<InstallationPhase>(initialPhase);

// Wrapper for setCurrentPhase that also updates context
const setCurrentPhase = useCallback((phase: InstallationPhase) => {
setCurrentPhaseState(phase);
setInstallationPhase(phase);
}, [setInstallationPhase]);
const [completedPhases, setCompletedPhases] = useState<Set<InstallationPhase>>(completedPhaseSet);
const [nextButtonConfig, setNextButtonConfig] = useState<NextButtonConfig | null>(null);
const [backButtonConfig, setBackButtonConfig] = useState<BackButtonConfig | null>(null);
const nextButtonRef = useRef<HTMLButtonElement>(null);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading