Skip to content
Merged
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
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
3 changes: 2 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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
Loading