diff --git a/.cursor/plans/cluster_sizer_api_integration_4747313e.plan.md b/.cursor/plans/cluster_sizer_api_integration_4747313e.plan.md new file mode 100644 index 00000000..f7811056 --- /dev/null +++ b/.cursor/plans/cluster_sizer_api_integration_4747313e.plan.md @@ -0,0 +1,110 @@ +--- +name: Cluster Sizer API Integration +overview: Integrate the ClusterSizingWizard with the real AssessmentApi by replacing mock data with the calculateAssessmentClusterRequirements endpoint, using the existing DI container pattern. +todos: + - id: update-types + content: Update types.ts to re-export API types from api-client and keep only UI-specific types + status: completed + - id: update-wizard + content: Update ClusterSizingWizard to use AssessmentApi via DI container instead of mocks + status: completed + - id: update-result + content: Add null checks in SizingResult for optional resourceConsumption fields + status: completed + - id: cleanup-report + content: Remove unused onCalculate prop from ClusterSizingWizard usage in Report.tsx + status: completed + - id: remove-ha-text + content: "Remove \"High Availability: Yes\" text from SizingResult component" + status: completed + - id: update-default-overcommit + content: Change default overcommit ratio from 1:6 to 1:4 in constants.ts + status: completed +--- + +# Cluster Sizer API Integration Plan + +## Overview + +The `@migration-planner-ui/api-client` package now includes the `calculateAssessmentClusterRequirements` method in `AssessmentApi` along with the `ClusterRequirementsRequest` and `ClusterRequirementsResponse` types. The wizard will use the existing DI pattern (`useInjection` hook) to access the API. + +## Key Files + +- [`src/pages/report/cluster-sizer/ClusterSizingWizard.tsx`](src/pages/report/cluster-sizer/ClusterSizingWizard.tsx) - Main component to update +- [`src/pages/report/cluster-sizer/types.ts`](src/pages/report/cluster-sizer/types.ts) - Replace API types with api-client imports +- [`src/pages/report/cluster-sizer/SizingResult.tsx`](src/pages/report/cluster-sizer/SizingResult.tsx) - Add null checks for optional fields +- [`src/main/Symbols.ts`](src/main/Symbols.ts) - Already has `AssessmentApi` symbol registered + +## API Details + +The api-client provides: + +- `AssessmentApi.calculateAssessmentClusterRequirements({ id, clusterRequirementsRequest })` +- Returns `ClusterRequirementsResponse` with `clusterSizing`, `resourceConsumption`, and `inventoryTotals` + +Note: `resourceConsumption.limits` and `resourceConsumption.overCommitRatio` are optional in the api-client types. + +## Implementation Steps + +### 1. Update types.ts + +Remove duplicate API types and re-export from api-client: + +```typescript +// Re-export API types from api-client +export type { + ClusterRequirementsRequest, + ClusterRequirementsResponse, + ClusterSizing, + InventoryTotals, + SizingResourceConsumption, +} from '@migration-planner-ui/api-client/models'; + +// Keep UI-specific types (SizingFormValues, WorkerNodePreset, etc.) +``` + +### 2. Update ClusterSizingWizard.tsx + +- Import `useInjection` from `@migration-planner-ui/ioc` +- Import `AssessmentApi` from `@migration-planner-ui/api-client/apis` +- Import `Symbols` from `@/main/Symbols` +- Remove `fetchMockClusterRequirements` import +- Remove `onCalculate` prop (no longer needed) +- Use `assessmentApi.calculateAssessmentClusterRequirements()` directly +```typescript +const assessmentApi = useInjection(Symbols.AssessmentApi); + +const result = await assessmentApi.calculateAssessmentClusterRequirements({ + id: assessmentId, + clusterRequirementsRequest: request, +}); +``` + + +### 3. Update SizingResult.tsx + +Add null checks for optional API response fields: + +```typescript +sizerOutput.resourceConsumption.overCommitRatio?.cpu ?? 0 +sizerOutput.resourceConsumption.limits?.cpu ?? 0 +``` + +### 4. Clean up Report.tsx + +Remove the unused `onCalculate` prop from `ClusterSizingWizard` usage. + +### 5. Remove "High Availability: Yes" from SizingResult + +Remove the hardcoded "High Availability: Yes" text from both: + +- The `generatePlainTextRecommendation` function (clipboard copy text) +- The JSX render section in `SizingResult.tsx` + +### 6. Change default overcommit ratio + +In [`src/pages/report/cluster-sizer/constants.ts`](src/pages/report/cluster-sizer/constants.ts), update `DEFAULT_FORM_VALUES.overcommitRatio` from `6` (High Density 1:6) to `4` (Standard 1:4). + +### 7. Mock files decision + +Keep `src/pages/report/cluster-sizer/mocks/` folder for unit testing purposes, but remove the runtime dependency on `data.mock.ts`. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 27b18c14..da7950f1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,9 @@ # vscode config # .vscode/* -.cursor/**/* +.cursor/* +!.cursor/plans/ +!.cursor/plans/** # ide config diff --git a/package-lock.json b/package-lock.json index 753c902e..207b0499 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "hasInstallScript": true, "dependencies": { "@emotion/css": "^11.13.0", - "@migration-planner-ui/api-client": "^0.0.36", + "@migration-planner-ui/api-client": "^0.0.37", "@migration-planner-ui/ioc": "^0.0.36", "@patternfly/react-charts": "7.4.9", "@patternfly/react-core": "^6.2.2", @@ -1685,9 +1685,9 @@ "license": "MIT" }, "node_modules/@migration-planner-ui/api-client": { - "version": "0.0.36", - "resolved": "https://registry.npmjs.org/@migration-planner-ui/api-client/-/api-client-0.0.36.tgz", - "integrity": "sha512-rq3eGCHYEPa71TFfLP8hAwggXuS/nGbZqPRZ5dq5GRlFNo/0YAvAAIC1L8GpHRvv3I+NK0Pt0dPzrERn5YF90w==", + "version": "0.0.37", + "resolved": "https://registry.npmjs.org/@migration-planner-ui/api-client/-/api-client-0.0.37.tgz", + "integrity": "sha512-odEKmsWgSQdLKYliUxCpNjbpG/LKSBBAuEfc3hVY1f3QQgLNw2OiWDOxZX/xh3DESeXYAgl5pELws/YY4MXmyQ==", "license": "Apache-2.0" }, "node_modules/@migration-planner-ui/ioc": { diff --git a/package.json b/package.json index 6927b0be..9104f71f 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@emotion/css": "^11.13.0", - "@migration-planner-ui/api-client": "^0.0.36", + "@migration-planner-ui/api-client": "^0.0.37", "@migration-planner-ui/ioc": "^0.0.36", "@patternfly/react-charts": "7.4.9", "@patternfly/react-core": "^6.2.2", diff --git a/specs/cluster-sizer.md b/specs/cluster-sizer.md new file mode 100644 index 00000000..15fe7b82 --- /dev/null +++ b/specs/cluster-sizer.md @@ -0,0 +1,100 @@ +# Cluster Sizer – Target Cluster Recommendations + +## Overview +The Cluster Sizer feature helps users generate OpenShift cluster sizing recommendations based on their VMware inventory data and migration preferences. It presents a two-step wizard that collects user preferences and displays calculated requirements for the target OpenShift cluster. + +## Related Jira Tickets +- **ECOPROJECT-3637**: Implement "Recommend me an OpenShift cluster" wizard +- **ECOPROJECT-3631**: Cluster requirements API integration (backend sizer library) + +## Figma Mockups +- Configuration step: `node-id=6896-15678` +- Results step: `node-id=7181-9318` +- File: `Migration-assessment` design file + +## Wizard Steps + +### Step 1: Migration Preferences +Users configure their target cluster parameters: +- **Run workloads on control plane nodes** (checkbox): Whether to schedule VM workloads on control plane nodes +- **Worker node CPU cores** (dropdown, required): CPU cores per worker node (8, 16, 32, 64, 96, 128) +- **Worker node memory** (dropdown, required): Memory in GB per worker node (16, 32, 64, 128, 256, 512) +- **Over-commit ratio** (dropdown, required): Resource sharing factor (1:1, 1:2, 1:4, 1:6) + - 1:1 = No over-commit (dedicated) + - 1:2 = Low density + - 1:4 = Standard density + - 1:6 = High density + +### Step 2: Review Cluster Recommendations +Displays calculated sizing based on inventory data and user preferences: +- **Inventory Summary**: Total VMs, CPU cores, and memory from source VMware cluster +- **Cluster Sizing**: Recommended worker nodes, control plane nodes, total nodes, total CPU, total memory +- **Resource Utilization**: CPU consumption %, memory consumption %, resource limits, over-commit ratios + +## API Integration + +### Endpoint +``` +POST /api/v1/assessments/{id}/cluster-requirements +``` + +### Request Payload +```typescript +interface ClusterRequirementsRequest { + clusterId: string; // VMware cluster ID + overCommitRatio: "1:1" | "1:2" | "1:4" | "1:6"; + workerNodeCPU: number; // CPU cores per worker + workerNodeMemory: number; // Memory in GB per worker + controlPlaneSchedulable: boolean; +} +``` + +### Response Payload +```typescript +interface ClusterRequirementsResponse { + clusterSizing: { + controlPlaneNodes: number; + totalCPU: number; + totalMemory: number; + totalNodes: number; + workerNodes: number; + }; + inventoryTotals: { + totalCPU: number; + totalMemory: number; + totalVMs: number; + }; + resourceConsumption: { + cpu: number; // percentage + memory: number; // percentage + limits: { cpu: number; memory: number }; + overCommitRatio: { cpu: number; memory: number }; + }; +} +``` + +## File Structure +``` +src/pages/report/cluster-sizer/ +├── index.ts # Re-exports +├── types.ts # TypeScript interfaces and type definitions +├── constants.ts # Form options (CPU, memory, over-commit dropdowns) +├── ClusterSizingWizard.tsx # Main wizard modal component +├── SizingInputForm.tsx # Step 1: Migration preferences form +├── SizingResult.tsx # Step 2: Results display +└── mockData.ts # Mock API responses for development +``` + +## UX Behavior Notes +- Modal title: "Target cluster recommendations" +- Default values: Control plane schedulable = false, CPU = 32, Memory = 32GB, Over-commit = 1:6 +- Footer buttons: + - Step 1: "Next" (primary) + "Cancel" (link) + - Step 2: "Close" (primary) + "Back" (link) +- Copy to clipboard: Results can be copied as plain text for sharing +- Loading state: Shows spinner while calculating recommendations +- Error handling: Displays error message if API call fails + +## Integration Point +The wizard is triggered from the Report page (`src/pages/report/Report.tsx`) via a "Get cluster sizing recommendation" button. It receives the `assessmentId` as a prop to identify which VMware cluster inventory to use. + diff --git a/docs/cluster-switcher.md b/specs/cluster-switcher.md similarity index 100% rename from docs/cluster-switcher.md rename to specs/cluster-switcher.md diff --git a/src/pages/report/Report.tsx b/src/pages/report/Report.tsx index 3b9d5962..e1232e21 100644 --- a/src/pages/report/Report.tsx +++ b/src/pages/report/Report.tsx @@ -32,7 +32,6 @@ import { useDiscoverySources } from '../../migration-wizard/contexts/discovery-s import { Provider as DiscoverySourcesProvider } from '../../migration-wizard/contexts/discovery-sources/Provider'; import { EnhancedDownloadButton } from '../../migration-wizard/steps/discovery/EnhancedDownloadButton'; import { ExportError, SnapshotLike } from '../../services/report-export/types'; -import { openAssistedInstaller } from '../assessment/utils/functions'; import { parseLatestSnapshot } from '../assessment/utils/snapshotParser'; import { AgentStatusView } from '../environment/sources-table/AgentStatusView'; @@ -41,6 +40,7 @@ import { ClusterOption, } from './assessment-report/clusterView'; import { Dashboard } from './assessment-report/Dashboard'; +import { ClusterSizingWizard } from './cluster-sizer/ClusterSizingWizard'; type AssessmentLike = { id: string | number; @@ -56,6 +56,7 @@ const Inner: React.FC = () => { const [exportError, setExportError] = useState(null); const [selectedClusterId, setSelectedClusterId] = useState('all'); const [isClusterSelectOpen, setIsClusterSelectOpen] = useState(false); + const [isSizingWizardOpen, setIsSizingWizardOpen] = useState(false); useMount(async () => { if ( @@ -340,11 +341,17 @@ const Inner: React.FC = () => { }`} /> - - - + + {selectedClusterId !== 'all' ? ( + + + + ) : null} ) : undefined } @@ -370,6 +377,14 @@ const Inner: React.FC = () => { )} + + setIsSizingWizardOpen(false)} + clusterName={clusterView.selectionLabel} + clusterId={selectedClusterId} + assessmentId={id || ''} + /> ); }; diff --git a/src/pages/report/cluster-sizer/ClusterSizingWizard.tsx b/src/pages/report/cluster-sizer/ClusterSizingWizard.tsx new file mode 100644 index 00000000..2290aea5 --- /dev/null +++ b/src/pages/report/cluster-sizer/ClusterSizingWizard.tsx @@ -0,0 +1,152 @@ +import React, { useCallback, useState } from 'react'; + +import { AssessmentApi } from '@migration-planner-ui/api-client/apis'; +import { useInjection } from '@migration-planner-ui/ioc'; +import { + Modal, + Wizard, + WizardHeader, + WizardStep, +} from '@patternfly/react-core'; + +import { Symbols } from '../../../main/Symbols'; + +import { DEFAULT_FORM_VALUES, WORKER_NODE_PRESETS } from './constants'; +import { SizingInputForm } from './SizingInputForm'; +import { SizingInputFormWizardStepFooter } from './SizingInputFormWizardStepFooter'; +import { SizingResult } from './SizingResult'; +import { SizingResultWizardStepFooter } from './SizingResultWizardStepFooter'; +import type { ClusterRequirementsResponse, SizingFormValues } from './types'; +import { formValuesToRequest } from './types'; + +interface ClusterSizingWizardProps { + isOpen: boolean; + onClose: () => void; + clusterName: string; + clusterId: string; + /** Assessment ID for the API endpoint */ + assessmentId: string; +} + +export const ClusterSizingWizard: React.FC = ({ + isOpen, + onClose, + clusterName, + clusterId, + assessmentId, +}) => { + const assessmentApi = useInjection(Symbols.AssessmentApi); + + const [formValues, setFormValues] = + useState(DEFAULT_FORM_VALUES); + const [sizerOutput, setSizerOutput] = + useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleClose = useCallback(() => { + // Reset state when closing + setFormValues(DEFAULT_FORM_VALUES); + setSizerOutput(null); + setError(null); + setIsLoading(false); + onClose(); + }, [onClose]); + + const handleCalculate = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + // Get worker node CPU and memory based on preset or custom values + const workerCpu = + formValues.workerNodePreset !== 'custom' + ? WORKER_NODE_PRESETS[formValues.workerNodePreset].cpu + : formValues.customCpu; + const workerMemory = + formValues.workerNodePreset !== 'custom' + ? WORKER_NODE_PRESETS[formValues.workerNodePreset].memoryGb + : formValues.customMemoryGb; + + // Build the API request payload + const clusterRequirementsRequest = formValuesToRequest( + clusterId, + formValues, + workerCpu, + workerMemory, + ); + + // POST /api/v1/assessments/{id}/cluster-requirements + const result = await assessmentApi.calculateAssessmentClusterRequirements( + { + id: assessmentId, + clusterRequirementsRequest, + }, + ); + + setSizerOutput(result); + } catch (err) { + setError( + err instanceof Error ? err : new Error('Failed to calculate sizing'), + ); + } finally { + setIsLoading(false); + } + }, [assessmentApi, assessmentId, clusterId, formValues]); + + if (!isOpen) { + return null; + } + + return ( + + + } + > + + } + > + + + + } + > + + + + + ); +}; + +ClusterSizingWizard.displayName = 'ClusterSizingWizard'; + +export default ClusterSizingWizard; diff --git a/src/pages/report/cluster-sizer/PopoverIcon.tsx b/src/pages/report/cluster-sizer/PopoverIcon.tsx new file mode 100644 index 00000000..68885587 --- /dev/null +++ b/src/pages/report/cluster-sizer/PopoverIcon.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { Button, ButtonProps, Icon, Popover } from '@patternfly/react-core'; +import { PopoverProps } from '@patternfly/react-core/dist/js/components/Popover/Popover'; +import { SVGIconProps } from '@patternfly/react-icons/dist/js/createIcon'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons/dist/js/icons/outlined-question-circle-icon'; + +type PopoverIconProps = PopoverProps & { + variant?: ButtonProps['variant']; + component?: ButtonProps['component']; + IconComponent?: React.ComponentClass; + noVerticalAlign?: boolean; + buttonClassName?: string; + buttonOuiaId?: string; + buttonStyle?: React.CSSProperties; +}; + +const PopoverIcon: React.FC = ({ + component, + variant = 'plain', + IconComponent = OutlinedQuestionCircleIcon, + noVerticalAlign = false, + buttonClassName, + buttonOuiaId, + buttonStyle, + ...props +}) => ( + + + + + + + + + + + + ); +}; + +SizingInputFormWizardStepFooter.displayName = 'SizingInputFormWizardStepFooter'; diff --git a/src/pages/report/cluster-sizer/SizingResult.tsx b/src/pages/report/cluster-sizer/SizingResult.tsx new file mode 100644 index 00000000..b491f8d8 --- /dev/null +++ b/src/pages/report/cluster-sizer/SizingResult.tsx @@ -0,0 +1,252 @@ +import React, { useCallback, useMemo } from 'react'; + +import { + Button, + Content, + Flex, + FlexItem, + Panel, + PanelHeader, + PanelMain, + PanelMainBody, + Spinner, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { CopyIcon } from '@patternfly/react-icons'; + +import { OVERCOMMIT_OPTIONS } from './constants'; +import type { ClusterRequirementsResponse, SizingFormValues } from './types'; + +interface SizingResultProps { + clusterName: string; + formValues: SizingFormValues; + sizerOutput: ClusterRequirementsResponse | null; + isLoading?: boolean; + error?: Error | null; +} + +/** + * Format a number with locale-specific thousands separators + */ +const formatNumber = (value: number): string => value.toLocaleString(); + +/** + * Format a ratio value + */ +const formatRatio = (value: number): string => value.toFixed(2); + +/** + * Get the over-commit ratio label + */ +const getOvercommitLabel = (ratio: number): string => { + const option = OVERCOMMIT_OPTIONS.find((opt) => opt.value === ratio); + return option?.label || `1:${ratio}`; +}; + +/** + * Generate the plain text recommendation for clipboard copy + */ +const generatePlainTextRecommendation = ( + clusterName: string, + formValues: SizingFormValues, + output: ClusterRequirementsResponse, +): string => { + const cpuOverCommitRatio = + output.resourceConsumption.overCommitRatio?.cpu ?? 0; + const memoryOverCommitRatio = + output.resourceConsumption.overCommitRatio?.memory ?? 0; + const cpuLimits = output.resourceConsumption.limits?.cpu ?? 0; + const memoryLimits = output.resourceConsumption.limits?.memory ?? 0; + + return ` +Cluster: ${clusterName} +Total Nodes: ${output.clusterSizing.totalNodes} (${output.clusterSizing.workerNodes} workers + ${output.clusterSizing.controlPlaneNodes} control plane) +Node Size: ${formValues.customCpu} CPU / ${formValues.customMemoryGb} GB + +Additional info +Target Platform: BareMetal +Over-Commitment: ${getOvercommitLabel(formValues.overcommitRatio)} +VMs to Migrate: ${formatNumber(output.inventoryTotals.totalVMs)} VMs +- CPU Over-Commit Ratio: ${formatRatio(cpuOverCommitRatio)} +- Memory Over-Commit Ratio: ${formatRatio(memoryOverCommitRatio)} +Resource Breakdown +VM Resources (requested): ${formatNumber(output.inventoryTotals.totalCPU)} CPU / ${formatNumber(output.inventoryTotals.totalMemory)} GB +With Over-commit (limits): ${formatNumber(cpuLimits)} CPU / ${formatNumber(memoryLimits)} GB +Physical Capacity: ${formatNumber(output.clusterSizing.totalCPU)} CPU / ${formatNumber(output.clusterSizing.totalMemory)} GB +`.trim(); +}; + +export const SizingResult: React.FC = ({ + clusterName, + formValues, + sizerOutput, + isLoading = false, + error = null, +}) => { + const plainTextRecommendation = useMemo(() => { + if (!sizerOutput) return ''; + return generatePlainTextRecommendation( + clusterName, + formValues, + sizerOutput, + ); + }, [clusterName, formValues, sizerOutput]); + + const handleCopyRecommendations = useCallback(async () => { + try { + await navigator.clipboard.writeText(plainTextRecommendation); + } catch (err) { + console.error('Failed to copy recommendations:', err); + } + }, [plainTextRecommendation]); + + if (isLoading) { + return ( + + + + + + + + + + ); + } + + if (error) { + return ( + + + + + Failed to calculate sizing recommendation: {error.message} + + + + + ); + } + + if (!sizerOutput) { + return ( + + + + No sizing data available. + + + + ); + } + + // Extract optional fields with defaults + const cpuOverCommitRatio = + sizerOutput.resourceConsumption.overCommitRatio?.cpu ?? 0; + const memoryOverCommitRatio = + sizerOutput.resourceConsumption.overCommitRatio?.memory ?? 0; + const cpuLimits = sizerOutput.resourceConsumption.limits?.cpu ?? 0; + const memoryLimits = sizerOutput.resourceConsumption.limits?.memory ?? 0; + + return ( + + {/* Sticky Header with title and copy button */} + + + + Review cluster recommendations + + + + + + + + {/* Scrollable Content */} + + + + {/* Main cluster info */} + + + + Cluster: {clusterName} + + + + Total Nodes: {sizerOutput.clusterSizing.totalNodes} ( + {sizerOutput.clusterSizing.workerNodes} workers +{' '} + {sizerOutput.clusterSizing.controlPlaneNodes} control plane) + + + + + Node Size: {formValues.customCpu} CPU /{' '} + {formValues.customMemoryGb} GB + + + + + + {/* Additional info section */} + + + + Additional info + + Target Platform: BareMetal + + Over-Commitment:{' '} + {getOvercommitLabel(formValues.overcommitRatio)} + + + VMs to Migrate:{' '} + {formatNumber(sizerOutput.inventoryTotals.totalVMs)} VMs + + + ~ CPU Over-Commit Ratio: {formatRatio(cpuOverCommitRatio)} + + + - Memory Over-Commit Ratio:{' '} + {formatRatio(memoryOverCommitRatio)} + + Resource Breakdown + + VM Resources (requested):{' '} + {formatNumber(sizerOutput.inventoryTotals.totalCPU)} CPU /{' '} + {formatNumber(sizerOutput.inventoryTotals.totalMemory)} GB + + + With Over-commit (limits): {formatNumber(cpuLimits)} CPU /{' '} + {formatNumber(memoryLimits)} GB + + + Physical Capacity:{' '} + {formatNumber(sizerOutput.clusterSizing.totalCPU)} CPU /{' '} + {formatNumber(sizerOutput.clusterSizing.totalMemory)} GB + + + + + + + + ); +}; + +SizingResult.displayName = 'SizingResult'; + +export default SizingResult; diff --git a/src/pages/report/cluster-sizer/SizingResultWizardStepFooter.tsx b/src/pages/report/cluster-sizer/SizingResultWizardStepFooter.tsx new file mode 100644 index 00000000..0ecef529 --- /dev/null +++ b/src/pages/report/cluster-sizer/SizingResultWizardStepFooter.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { + ActionList, + ActionListGroup, + ActionListItem, + Button, + useWizardContext, + WizardFooterWrapper, +} from '@patternfly/react-core'; + +export interface SizingResultWizardStepFooterProps { + onClose: () => void; +} + +export const SizingResultWizardStepFooter: React.FC< + SizingResultWizardStepFooterProps +> = ({ onClose }) => { + const { goToPrevStep } = useWizardContext(); + + return ( + + + + + + + + + + + + + + + ); +}; + +SizingResultWizardStepFooter.displayName = 'SizingResultWizardStepFooter'; diff --git a/src/pages/report/cluster-sizer/constants.ts b/src/pages/report/cluster-sizer/constants.ts new file mode 100644 index 00000000..a5a31a3e --- /dev/null +++ b/src/pages/report/cluster-sizer/constants.ts @@ -0,0 +1,190 @@ +import type { + HAReplicaCount, + OvercommitRatio, + SizingFormValues, + WorkerNodePreset, +} from './types'; + +/** + * Worker node size presets with CPU and memory configurations + */ +export const WORKER_NODE_PRESETS: Record< + Exclude, + { cpu: number; memoryGb: number; label: string; description: string } +> = { + small: { + cpu: 16, + memoryGb: 64, + label: 'Small (16 CPU / 64 GB)', + description: 'Suitable for lightweight workloads', + }, + medium: { + cpu: 64, + memoryGb: 256, + label: 'Medium (64 CPU / 256 GB)', + description: 'Balanced for general purpose workloads', + }, + large: { + cpu: 200, + memoryGb: 512, + label: 'Large (200 CPU / 512 GB)', + description: 'For demanding, resource-intensive workloads', + }, +}; + +/** + * CPU options for worker nodes (matching Figma design) + */ +export const CPU_OPTIONS: { value: number; label: string }[] = [ + { value: 16, label: '16' }, + { value: 32, label: '32' }, + { value: 64, label: '64' }, + { value: 128, label: '128' }, + { value: 200, label: '200' }, +]; + +/** + * Memory options for worker nodes in GB (matching Figma design) + */ +export const MEMORY_OPTIONS: { value: number; label: string }[] = [ + { value: 32, label: '32' }, + { value: 64, label: '64' }, + { value: 128, label: '128' }, + { value: 256, label: '256' }, + { value: 512, label: '512' }, +]; + +/** + * Over-commit ratio options for resource sharing + */ +export const OVERCOMMIT_OPTIONS: { + value: OvercommitRatio; + label: string; + description: string; + helpText: string; +}[] = [ + { + value: 1, + label: 'Performance (1:1)', + description: 'No sharing. Dedicated power for every VM.', + helpText: 'Best for latency-sensitive or critical workloads.', + }, + { + value: 2, + label: 'Balanced (1:2)', + description: 'Light sharing. Safe for critical apps.', + helpText: 'Good balance between cost and performance.', + }, + { + value: 4, + label: 'Standard (1:4)', + description: 'Moderate sharing. Best for general use.', + helpText: + 'Example: At 1:4, you can run 400 "virtual" CPUs on 100 "physical" cores.', + }, + { + value: 6, + label: 'High Density (1:6)', + description: 'Heavy sharing. Maximum savings for test environments.', + helpText: 'Only recommended for non-production workloads.', + }, +]; + +/** + * High availability configuration options + */ +export const HA_OPTIONS: { + value: HAReplicaCount; + label: string; + description: string; + helpText: string; +}[] = [ + { + value: 1, + label: 'Development', + description: 'Low cost; no protection against crashes.', + helpText: 'Single replica - suitable for development and testing only.', + }, + { + value: 2, + label: 'Production', + description: 'Reliable; stays online if one node fails.', + helpText: 'Two replicas provide basic high availability.', + }, + { + value: 3, + label: 'Critical', + description: 'Maximum uptime; safe during updates and failures.', + helpText: 'Three replicas ensure availability during maintenance windows.', + }, +]; + +/** + * Control plane scheduling options + */ +export const CONTROL_PLANE_OPTIONS = { + yes: { + label: 'Yes', + description: + 'Lower cost. Uses spare capacity on management nodes for your VMs.', + }, + no: { + label: 'No', + description: + 'Higher stability. Keeps cluster management isolated from your app workloads.', + }, +}; + +/** + * Default form values (matching Figma design defaults) + */ +export const DEFAULT_FORM_VALUES: SizingFormValues = { + workerNodePreset: 'custom', + customCpu: 32, + customMemoryGb: 32, + haReplicas: 3, + overcommitRatio: 4, // Standard (1:4) + scheduleOnControlPlane: false, +}; + +/** + * Validation constraints for custom worker node configuration + */ +export const WORKER_NODE_CONSTRAINTS = { + cpu: { + min: 2, + max: 200, + step: 2, + }, + memory: { + min: 4, + max: 512, + step: 4, + }, +}; + +/** + * Form field labels and help text + */ +export const FORM_LABELS = { + workerNodeSize: { + label: 'Worker Node Size', + description: + 'Choose the CPU and memory for each node. Small nodes spread out risk, so a single failure affects fewer VMs. Large nodes are more efficient and reduce the total number of servers you need to manage.', + }, + highAvailability: { + label: 'High Availability Configuration', + description: + 'Choose how many copies of your services to run. Multiple replicas ensure your applications stay online even if a node fails or needs maintenance.', + }, + overcommit: { + label: 'Resource Sharing', + description: + 'Save money by letting VMs share physical hardware. High sharing reduces costs but can slow things down if all VMs peak at once.', + }, + controlPlane: { + label: 'Share Management Nodes', + description: + 'Allow your VMs to run on the same nodes that manage the cluster. This reduces the total number of servers you need.', + }, +}; diff --git a/src/pages/report/cluster-sizer/index.ts b/src/pages/report/cluster-sizer/index.ts new file mode 100644 index 00000000..0dc276cd --- /dev/null +++ b/src/pages/report/cluster-sizer/index.ts @@ -0,0 +1,5 @@ +export { ClusterSizingWizard } from './ClusterSizingWizard'; +export { SizingInputForm } from './SizingInputForm'; +export { SizingResult } from './SizingResult'; +export * from './types'; +export * from './constants'; diff --git a/src/pages/report/cluster-sizer/mocks/clusterRequirementsRequest.mock.ts b/src/pages/report/cluster-sizer/mocks/clusterRequirementsRequest.mock.ts new file mode 100644 index 00000000..3548d58e --- /dev/null +++ b/src/pages/report/cluster-sizer/mocks/clusterRequirementsRequest.mock.ts @@ -0,0 +1,106 @@ +/** + * Mock data for ClusterRequirementsRequest payload + * + * API Endpoint: POST /api/v1/assessments/{id}/cluster-requirements + * @see PR #819 - ECOPROJECT-3719 | feat: add post endpoint for sizing calculations + */ +import type { ClusterRequirementsRequest } from '../types'; + +/** + * Default mock request with medium-sized worker nodes + */ +export const mockClusterRequirementsRequest: ClusterRequirementsRequest = { + clusterId: '71cfef2c-5c64-4a0a-8238-2f7fbb2f6372', + overCommitRatio: '1:4', + workerNodeCPU: 16, + workerNodeMemory: 64, + controlPlaneSchedulable: false, +}; + +/** + * Mock request with small worker nodes (minimal configuration) + */ +export const mockSmallNodeRequest: ClusterRequirementsRequest = { + clusterId: '123e4567-e89b-12d3-a456-426614174000', + overCommitRatio: '1:2', + workerNodeCPU: 4, + workerNodeMemory: 16, + controlPlaneSchedulable: false, +}; + +/** + * Mock request with large worker nodes (high-capacity configuration) + */ +export const mockLargeNodeRequest: ClusterRequirementsRequest = { + clusterId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + overCommitRatio: '1:6', + workerNodeCPU: 64, + workerNodeMemory: 256, + controlPlaneSchedulable: false, +}; + +/** + * Mock request with custom worker nodes and control plane scheduling enabled + */ +export const mockCustomNodeWithControlPlaneRequest: ClusterRequirementsRequest = + { + clusterId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + overCommitRatio: '1:1', + workerNodeCPU: 32, + workerNodeMemory: 128, + controlPlaneSchedulable: true, + }; + +/** + * Mock request with maximum allowed values + */ +export const mockMaxValuesRequest: ClusterRequirementsRequest = { + clusterId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + overCommitRatio: '1:6', + workerNodeCPU: 200, + workerNodeMemory: 512, + controlPlaneSchedulable: true, +}; + +/** + * Mock request with minimum allowed values + */ +export const mockMinValuesRequest: ClusterRequirementsRequest = { + clusterId: 'b2a7e4b6-91f4-4be7-bfcb-043dee7e50e9', + overCommitRatio: '1:1', + workerNodeCPU: 2, + workerNodeMemory: 4, + controlPlaneSchedulable: false, +}; + +/** + * Factory function to create a custom mock request + */ +export const createMockClusterRequirementsRequest = ( + overrides: Partial = {}, +): ClusterRequirementsRequest => ({ + clusterId: overrides.clusterId ?? mockClusterRequirementsRequest.clusterId, + overCommitRatio: + overrides.overCommitRatio ?? mockClusterRequirementsRequest.overCommitRatio, + workerNodeCPU: + overrides.workerNodeCPU ?? mockClusterRequirementsRequest.workerNodeCPU, + workerNodeMemory: + overrides.workerNodeMemory ?? + mockClusterRequirementsRequest.workerNodeMemory, + controlPlaneSchedulable: + overrides.controlPlaneSchedulable ?? + mockClusterRequirementsRequest.controlPlaneSchedulable, +}); + +/** + * Collection of all over-commit ratio variants for testing + */ +export const mockRequestsByOvercommitRatio: Record< + ClusterRequirementsRequest['overCommitRatio'], + ClusterRequirementsRequest +> = { + '1:1': createMockClusterRequirementsRequest({ overCommitRatio: '1:1' }), + '1:2': createMockClusterRequirementsRequest({ overCommitRatio: '1:2' }), + '1:4': createMockClusterRequirementsRequest({ overCommitRatio: '1:4' }), + '1:6': createMockClusterRequirementsRequest({ overCommitRatio: '1:6' }), +}; diff --git a/src/pages/report/cluster-sizer/mocks/clusterRequirementsResponse.mock.ts b/src/pages/report/cluster-sizer/mocks/clusterRequirementsResponse.mock.ts new file mode 100644 index 00000000..7a7809cc --- /dev/null +++ b/src/pages/report/cluster-sizer/mocks/clusterRequirementsResponse.mock.ts @@ -0,0 +1,247 @@ +/** + * Mock data for ClusterRequirementsResponse payload + * + * API Endpoint: POST /api/v1/assessments/{id}/cluster-requirements + * @see PR #819 - ECOPROJECT-3719 | feat: add post endpoint for sizing calculations + */ +import type { + ClusterRequirementsResponse, + ClusterSizing, + InventoryTotals, + SizingResourceConsumption, +} from '../types'; + +/** + * Default mock response representing a typical medium-sized cluster + */ +export const mockClusterRequirementsResponse: ClusterRequirementsResponse = { + clusterSizing: { + totalNodes: 8, + workerNodes: 5, + controlPlaneNodes: 3, + totalCPU: 128, + totalMemory: 512, + }, + resourceConsumption: { + cpu: 85.5, + memory: 72.3, + limits: { + cpu: 100.0, + memory: 90.0, + }, + overCommitRatio: { + cpu: 4.0, + memory: 1.0, + }, + }, + inventoryTotals: { + totalVMs: 50, + totalCPU: 200, + totalMemory: 400, + }, +}; + +/** + * Mock response for a small development cluster + */ +export const mockSmallClusterResponse: ClusterRequirementsResponse = { + clusterSizing: { + totalNodes: 4, + workerNodes: 1, + controlPlaneNodes: 3, + totalCPU: 24, + totalMemory: 96, + }, + resourceConsumption: { + cpu: 45.2, + memory: 52.8, + limits: { + cpu: 60.0, + memory: 70.0, + }, + overCommitRatio: { + cpu: 2.0, + memory: 1.0, + }, + }, + inventoryTotals: { + totalVMs: 10, + totalCPU: 20, + totalMemory: 40, + }, +}; + +/** + * Mock response for a large enterprise cluster + */ +export const mockLargeClusterResponse: ClusterRequirementsResponse = { + clusterSizing: { + totalNodes: 25, + workerNodes: 22, + controlPlaneNodes: 3, + totalCPU: 1408, + totalMemory: 5632, + }, + resourceConsumption: { + cpu: 92.1, + memory: 88.7, + limits: { + cpu: 110.0, + memory: 105.0, + }, + overCommitRatio: { + cpu: 6.0, + memory: 1.0, + }, + }, + inventoryTotals: { + totalVMs: 500, + totalCPU: 2000, + totalMemory: 5000, + }, +}; + +/** + * Mock response with control plane schedulable enabled + */ +export const mockControlPlaneSchedulableResponse: ClusterRequirementsResponse = + { + clusterSizing: { + totalNodes: 6, + workerNodes: 3, + controlPlaneNodes: 3, + totalCPU: 192, + totalMemory: 768, + }, + resourceConsumption: { + cpu: 78.4, + memory: 65.2, + limits: { + cpu: 95.0, + memory: 85.0, + }, + overCommitRatio: { + cpu: 1.0, + memory: 1.0, + }, + }, + inventoryTotals: { + totalVMs: 75, + totalCPU: 150, + totalMemory: 500, + }, + }; + +/** + * Mock response with high over-commit ratio (1:6) + */ +export const mockHighOvercommitResponse: ClusterRequirementsResponse = { + clusterSizing: { + totalNodes: 5, + workerNodes: 2, + controlPlaneNodes: 3, + totalCPU: 96, + totalMemory: 384, + }, + resourceConsumption: { + cpu: 95.8, + memory: 80.5, + limits: { + cpu: 120.0, + memory: 100.0, + }, + overCommitRatio: { + cpu: 6.0, + memory: 1.0, + }, + }, + inventoryTotals: { + totalVMs: 100, + totalCPU: 400, + totalMemory: 300, + }, +}; + +/** + * Mock response with minimum configuration (single worker node) + */ +export const mockMinimalClusterResponse: ClusterRequirementsResponse = { + clusterSizing: { + totalNodes: 4, + workerNodes: 1, + controlPlaneNodes: 3, + totalCPU: 20, + totalMemory: 52, + }, + resourceConsumption: { + cpu: 25.0, + memory: 30.5, + limits: { + cpu: 35.0, + memory: 45.0, + }, + overCommitRatio: { + cpu: 1.0, + memory: 1.0, + }, + }, + inventoryTotals: { + totalVMs: 5, + totalCPU: 5, + totalMemory: 10, + }, +}; + +/** + * Factory function to create a custom mock cluster sizing + */ +export const createMockClusterSizing = ( + overrides: Partial = {}, +): ClusterSizing => ({ + totalNodes: overrides.totalNodes ?? 8, + workerNodes: overrides.workerNodes ?? 5, + controlPlaneNodes: overrides.controlPlaneNodes ?? 3, + totalCPU: overrides.totalCPU ?? 128, + totalMemory: overrides.totalMemory ?? 512, +}); + +/** + * Factory function to create custom mock inventory totals + */ +export const createMockInventoryTotals = ( + overrides: Partial = {}, +): InventoryTotals => ({ + totalVMs: overrides.totalVMs ?? 50, + totalCPU: overrides.totalCPU ?? 200, + totalMemory: overrides.totalMemory ?? 400, +}); + +/** + * Factory function to create custom mock resource consumption + */ +export const createMockResourceConsumption = ( + overrides: Partial = {}, +): SizingResourceConsumption => ({ + cpu: overrides.cpu ?? 85.5, + memory: overrides.memory ?? 72.3, + limits: overrides.limits ?? { + cpu: 100.0, + memory: 90.0, + }, + overCommitRatio: overrides.overCommitRatio ?? { + cpu: 4.0, + memory: 1.0, + }, +}); + +/** + * Factory function to create a custom mock response + */ +export const createMockClusterRequirementsResponse = ( + overrides: Partial = {}, +): ClusterRequirementsResponse => ({ + clusterSizing: overrides.clusterSizing ?? createMockClusterSizing(), + inventoryTotals: overrides.inventoryTotals ?? createMockInventoryTotals(), + resourceConsumption: + overrides.resourceConsumption ?? createMockResourceConsumption(), +}); diff --git a/src/pages/report/cluster-sizer/mocks/data.mock.ts b/src/pages/report/cluster-sizer/mocks/data.mock.ts new file mode 100644 index 00000000..2abcd90a --- /dev/null +++ b/src/pages/report/cluster-sizer/mocks/data.mock.ts @@ -0,0 +1,109 @@ +/** + * Mock data for cluster sizing wizard development/testing + * TODO: Remove this file when the backend API is ready + * + * @see ECOPROJECT-3631 for API specification + */ + +import { WORKER_NODE_PRESETS } from '../constants'; +import type { ClusterRequirementsResponse, SizingFormValues } from '../types'; + +/** + * Mock inventory data representing a typical VMware cluster + */ +export const MOCK_INVENTORY = { + totalVMs: 61, + totalCPU: 281, + totalMemory: 1117, +} as const; + +/** + * Default delay (in ms) to simulate API response time + */ +export const MOCK_API_DELAY = 1000; + +/** + * Generate mock cluster requirements response for development/testing + * Matches the API response structure from ECOPROJECT-3631 + * + * @param values - Form values from the sizing wizard + * @returns Mock response matching ClusterRequirementsResponse + */ +export const generateMockClusterRequirements = ( + values: SizingFormValues, +): ClusterRequirementsResponse => { + // Get worker node specs based on preset or custom values + const workerCpu = + values.workerNodePreset !== 'custom' + ? WORKER_NODE_PRESETS[values.workerNodePreset].cpu + : values.customCpu; + const workerMemory = + values.workerNodePreset !== 'custom' + ? WORKER_NODE_PRESETS[values.workerNodePreset].memoryGb + : values.customMemoryGb; + + // Use mock inventory data + const { + totalVMs, + totalCPU: inventoryCPU, + totalMemory: inventoryMemory, + } = MOCK_INVENTORY; + + // Calculate worker nodes needed based on CPU requirements and overcommit ratio + const workerNodes = Math.max( + 3, + Math.ceil(inventoryCPU / (workerCpu * values.overcommitRatio)), + ); + const controlPlaneNodes = values.haReplicas; + const totalNodes = workerNodes + controlPlaneNodes; + + // Calculate total cluster resources (control plane nodes have fixed 8 CPU / 32 GB) + const totalCPU = workerNodes * workerCpu + controlPlaneNodes * 8; + const totalMemory = workerNodes * workerMemory + controlPlaneNodes * 32; + + // Calculate resource consumption percentages + const cpuUsage = (inventoryCPU / totalCPU) * 100; + const memoryUsage = (inventoryMemory / totalMemory) * 100; + + return { + clusterSizing: { + controlPlaneNodes, + totalCPU, + totalMemory, + totalNodes, + workerNodes, + }, + inventoryTotals: { + totalCPU: inventoryCPU, + totalMemory: inventoryMemory, + totalVMs, + }, + resourceConsumption: { + cpu: cpuUsage, + memory: memoryUsage, + limits: { + cpu: inventoryCPU * values.overcommitRatio, + memory: inventoryMemory * values.overcommitRatio, + }, + overCommitRatio: { + cpu: inventoryCPU / totalCPU, + memory: inventoryMemory / totalMemory, + }, + }, + }; +}; + +/** + * Simulate an API call with mock data + * + * @param values - Form values from the sizing wizard + * @param delay - Simulated API delay in milliseconds + * @returns Promise resolving to mock ClusterRequirementsResponse + */ +export const fetchMockClusterRequirements = async ( + values: SizingFormValues, + delay: number = MOCK_API_DELAY, +): Promise => { + await new Promise((resolve) => setTimeout(resolve, delay)); + return generateMockClusterRequirements(values); +}; diff --git a/src/pages/report/cluster-sizer/mocks/index.ts b/src/pages/report/cluster-sizer/mocks/index.ts new file mode 100644 index 00000000..2b936a19 --- /dev/null +++ b/src/pages/report/cluster-sizer/mocks/index.ts @@ -0,0 +1,8 @@ +/** + * Mock data exports for Cluster Sizer API + * + * @see PR #819 - ECOPROJECT-3719 | feat: add post endpoint for sizing calculations + */ + +export * from './clusterRequirementsRequest.mock'; +export * from './clusterRequirementsResponse.mock'; diff --git a/src/pages/report/cluster-sizer/types.ts b/src/pages/report/cluster-sizer/types.ts new file mode 100644 index 00000000..f7bb4ae2 --- /dev/null +++ b/src/pages/report/cluster-sizer/types.ts @@ -0,0 +1,100 @@ +/** + * Cluster Sizer Types + * + * UI-specific types for the cluster sizing wizard. + * API types are re-exported from @migration-planner-ui/api-client. + * + * @see ECOPROJECT-3631 + */ + +import { + type ClusterRequirementsRequest, + ClusterRequirementsRequestOverCommitRatioEnum, +} from '@migration-planner-ui/api-client/models'; + +// Re-export API types from api-client +export type { + ClusterRequirementsRequest, + ClusterRequirementsResponse, + ClusterSizing, + InventoryTotals, + SizingOverCommitRatio, + SizingResourceConsumption, + SizingResourceLimits, +} from '@migration-planner-ui/api-client/models'; + +/** + * Worker node size preset options + */ +export type WorkerNodePreset = 'small' | 'medium' | 'large' | 'custom'; + +/** + * Over-commit ratio options (CPU sharing factor) - numeric value + */ +export type OvercommitRatio = 1 | 2 | 4 | 6; + +/** + * High availability replica count + */ +export type HAReplicaCount = 1 | 2 | 3; + +/** + * User input for cluster sizing configuration (form state) + */ +export interface SizingFormValues { + /** Selected worker node size preset */ + workerNodePreset: WorkerNodePreset; + /** Custom CPU cores per worker (when preset is 'custom') */ + customCpu: number; + /** Custom memory in GB per worker (when preset is 'custom') */ + customMemoryGb: number; + /** High availability replica count */ + haReplicas: HAReplicaCount; + /** Over-commit ratio for resource sharing */ + overcommitRatio: OvercommitRatio; + /** Whether to schedule VMs on control plane nodes */ + scheduleOnControlPlane: boolean; +} + +/** + * Wizard step identifiers + */ +export type WizardStep = 'input' | 'result'; + +/** + * Mapping from numeric over-commit ratio to API enum value + */ +const OVERCOMMIT_RATIO_MAP: Record< + OvercommitRatio, + ClusterRequirementsRequest['overCommitRatio'] +> = { + 1: ClusterRequirementsRequestOverCommitRatioEnum.OneToOne, + 2: ClusterRequirementsRequestOverCommitRatioEnum.OneToTwo, + 4: ClusterRequirementsRequestOverCommitRatioEnum.OneToFour, + 6: ClusterRequirementsRequestOverCommitRatioEnum.OneToSix, +}; + +/** + * Helper function to convert numeric over-commit ratio to API enum format + */ +export const overcommitRatioToApiEnum = ( + ratio: OvercommitRatio, +): ClusterRequirementsRequest['overCommitRatio'] => { + return OVERCOMMIT_RATIO_MAP[ratio]; +}; + +/** + * Helper function to convert form values to API request payload + */ +export const formValuesToRequest = ( + clusterId: string, + values: SizingFormValues, + workerCpu: number, + workerMemory: number, +): ClusterRequirementsRequest => ({ + clusterId, + overCommitRatio: overcommitRatioToApiEnum(values.overcommitRatio), + workerNodeCPU: workerCpu, + workerNodeMemory: workerMemory, + controlPlaneSchedulable: values.scheduleOnControlPlane, +});