Refacto du code
This commit is contained in:
parent
488d90880c
commit
4cff540ef7
@ -1,4 +1,21 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { ChangeEvent, ReactNode } from 'react'
|
||||
|
||||
type FieldWrapperProps = {
|
||||
label: string
|
||||
children: ReactNode
|
||||
helper?: string
|
||||
containerClassName?: string
|
||||
}
|
||||
|
||||
function FieldWrapper({ label, children, helper, containerClassName }: FieldWrapperProps) {
|
||||
return (
|
||||
<label className={`flex flex-col gap-[2px] text-left text-xs ${containerClassName ?? ''}`}>
|
||||
<span className="text-white/70 text-[11px] uppercase tracking-[0.2em]">{label}</span>
|
||||
{children}
|
||||
{helper && <span className="text-[10px] text-white/40">{helper}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
label: string
|
||||
@ -12,8 +29,7 @@ type FieldProps = {
|
||||
|
||||
export function TextField({ label, helper, value, onChange, placeholder, required, containerClassName }: FieldProps) {
|
||||
return (
|
||||
<label className={`flex flex-col gap-[2px] text-left text-xs ${containerClassName ?? ''}`}>
|
||||
<span className="text-white/70 text-[11px] uppercase tracking-[0.2em]">{label}</span>
|
||||
<FieldWrapper label={label} helper={helper} containerClassName={containerClassName}>
|
||||
<input
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
value={value}
|
||||
@ -21,8 +37,7 @@ export function TextField({ label, helper, value, onChange, placeholder, require
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
/>
|
||||
{helper && <span className="text-[10px] text-white/40">{helper}</span>}
|
||||
</label>
|
||||
</FieldWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@ -30,10 +45,9 @@ type NumberFieldProps = Omit<FieldProps, 'helper'> & { min?: number; max?: numbe
|
||||
|
||||
export function NumberField({ label, value, onChange, placeholder, required, min, max, containerClassName }: NumberFieldProps) {
|
||||
return (
|
||||
<label className={`flex flex-col gap-[2px] text-left text-xs ${containerClassName ?? ''}`}>
|
||||
<span className="text-white/70 text-[11px] uppercase tracking-[0.2em]">{label}</span>
|
||||
<FieldWrapper label={label} containerClassName={containerClassName}>
|
||||
<input
|
||||
type="text"
|
||||
type="number"
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
@ -42,7 +56,7 @@ export function NumberField({ label, value, onChange, placeholder, required, min
|
||||
min={min}
|
||||
max={max}
|
||||
/>
|
||||
</label>
|
||||
</FieldWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@ -55,8 +69,7 @@ type SelectFieldProps = {
|
||||
|
||||
export function SelectField({ label, value, onChange, options }: SelectFieldProps) {
|
||||
return (
|
||||
<label className="flex flex-col gap-[2px] text-left text-xs">
|
||||
<span className="text-white/70 text-[11px] uppercase tracking-[0.2em]">{label}</span>
|
||||
<FieldWrapper label={label}>
|
||||
<select
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
value={value}
|
||||
@ -66,7 +79,7 @@ export function SelectField({ label, value, onChange, options }: SelectFieldProp
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</FieldWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@ -77,13 +90,47 @@ type DisabledFieldProps = {
|
||||
|
||||
export function DisabledField({ label, value }: DisabledFieldProps) {
|
||||
return (
|
||||
<label className="flex flex-col gap-[2px] text-left text-xs">
|
||||
<span className="text-white/70 text-[11px] uppercase tracking-[0.2em]">{label}</span>
|
||||
<FieldWrapper label={label}>
|
||||
<input
|
||||
className="rounded-lg border border-white/10 bg-white/10 px-3 py-2 text-sm text-white/60"
|
||||
value={value}
|
||||
disabled
|
||||
/>
|
||||
</label>
|
||||
</FieldWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
type RadioOption = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type RadioGroupProps = {
|
||||
label: string
|
||||
name: string
|
||||
value: string
|
||||
options: RadioOption[]
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function RadioGroup({ label, name, value, options, onChange }: RadioGroupProps) {
|
||||
return (
|
||||
<FieldWrapper label={label}>
|
||||
<div className="flex items-center gap-4 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm">
|
||||
{options.map(option => (
|
||||
<label key={option.value} className="flex items-center gap-2 text-white/80 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={value === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
className="text-accent focus:ring-accent"
|
||||
/>
|
||||
<span className="capitalize">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
27
src/features/formations/constants.ts
Normal file
27
src/features/formations/constants.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { TemplateInfo } from './api'
|
||||
|
||||
export const FALLBACK_TEMPLATE_INFOS: TemplateInfo[] = [
|
||||
{ name: 'debian-tmp', defaultDiskSizeGb: 80, os: 'linux' },
|
||||
{ name: 'ubuntu-22-template', defaultDiskSizeGb: 64, os: 'linux' },
|
||||
{ name: 'windows-11-lab', defaultDiskSizeGb: 128, os: 'windows' },
|
||||
]
|
||||
|
||||
export const FALLBACK_TEMPLATES = FALLBACK_TEMPLATE_INFOS.map(t => t.name)
|
||||
export const MODELS = ['linux', 'windows']
|
||||
|
||||
export const INITIAL_FORM = {
|
||||
name: '',
|
||||
participants: 10,
|
||||
referent: '',
|
||||
guacamoleGroup: '',
|
||||
guacamoleUserPrefix: 'user',
|
||||
vmPrefix: 'VM-',
|
||||
ipStart: '192.168.143.10',
|
||||
template: FALLBACK_TEMPLATES[0],
|
||||
cpuCores: 4,
|
||||
ramGb: 16,
|
||||
userIdStart: 1,
|
||||
diskSizeGb: FALLBACK_TEMPLATE_INFOS[0].defaultDiskSizeGb,
|
||||
model: MODELS[0],
|
||||
networkMode: 'static' as 'static' | 'dhcp',
|
||||
}
|
||||
136
src/features/formations/hooks/useFormationForm.ts
Normal file
136
src/features/formations/hooks/useFormationForm.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { useEffect, useMemo, useState, type ChangeEvent } from 'react'
|
||||
import { fetchTemplates, type TemplateInfo } from '../api'
|
||||
import { computeIpPool } from '../utils/network'
|
||||
import { FALLBACK_TEMPLATE_INFOS, INITIAL_FORM, MODELS } from '../constants'
|
||||
|
||||
export function useFormationForm() {
|
||||
const [form, setForm] = useState(INITIAL_FORM)
|
||||
const [templateInfos, setTemplateInfos] = useState<TemplateInfo[]>(FALLBACK_TEMPLATE_INFOS)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const templateNames = useMemo(() => templateInfos.map(t => t.name), [templateInfos])
|
||||
const currentTemplateInfo = useMemo<TemplateInfo | null>(
|
||||
() => templateInfos.find(t => t.name === form.template) ?? null,
|
||||
[templateInfos, form.template],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates()
|
||||
.then((fetched: TemplateInfo[]) => {
|
||||
if (Array.isArray(fetched) && fetched.length > 0) {
|
||||
setTemplateInfos(fetched)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Impossible de charger les templates', err)
|
||||
setTemplateInfos(FALLBACK_TEMPLATE_INFOS)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const participants = useMemo(
|
||||
() => Math.max(1, Math.min(200, Number(form.participants) || 1)),
|
||||
[form.participants],
|
||||
)
|
||||
|
||||
const userIdStart = useMemo(() => {
|
||||
const raw = Number(form.userIdStart)
|
||||
if (Number.isNaN(raw)) return 1
|
||||
return Math.max(1, Math.floor(raw))
|
||||
}, [form.userIdStart])
|
||||
|
||||
const ipPool = useMemo(() => computeIpPool(form.ipStart, participants), [form.ipStart, participants])
|
||||
|
||||
const userNames = useMemo(
|
||||
() => Array.from({ length: participants }, (_, index) => `${form.guacamoleUserPrefix}${index + 1}`),
|
||||
[form.guacamoleUserPrefix, participants],
|
||||
)
|
||||
|
||||
const vmNames = useMemo(
|
||||
() => Array.from({ length: participants }, (_, index) => `${form.vmPrefix}${index + 1}`),
|
||||
[form.vmPrefix, participants],
|
||||
)
|
||||
|
||||
const userIds = useMemo(
|
||||
() => Array.from({ length: participants }, (_, index) => userIdStart + index),
|
||||
[participants, userIdStart],
|
||||
)
|
||||
|
||||
const diskSizeNumber = useMemo(
|
||||
() => Math.max(1, Number(form.diskSizeGb) || 1),
|
||||
[form.diskSizeGb],
|
||||
)
|
||||
|
||||
const diskSizeDisplay = `${diskSizeNumber}G`
|
||||
const minimumDiskSize = currentTemplateInfo?.defaultDiskSizeGb ?? null
|
||||
const diskTooSmall = minimumDiskSize !== null && diskSizeNumber < minimumDiskSize
|
||||
|
||||
useEffect(() => {
|
||||
if (templateNames.length === 0) return
|
||||
if (!templateNames.includes(form.template)) {
|
||||
setForm(prev => ({ ...prev, template: templateNames[0] }))
|
||||
}
|
||||
}, [form.template, templateNames])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTemplateInfo) return
|
||||
setForm(prev => {
|
||||
if (prev.template !== currentTemplateInfo.name) return prev
|
||||
if (prev.diskSizeGb >= currentTemplateInfo.defaultDiskSizeGb) return prev
|
||||
return { ...prev, diskSizeGb: currentTemplateInfo.defaultDiskSizeGb }
|
||||
})
|
||||
}, [currentTemplateInfo])
|
||||
|
||||
useEffect(() => {
|
||||
if (!diskTooSmall && error && error.toLowerCase().includes('disque')) {
|
||||
setError('')
|
||||
}
|
||||
}, [diskTooSmall, error])
|
||||
|
||||
const handleInputChange = (field: keyof typeof INITIAL_FORM) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.type === 'number' ? Number(event.target.value) : event.target.value
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSelectChange = (field: keyof typeof INITIAL_FORM) => (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = event.target.value
|
||||
setForm(prev => {
|
||||
if (field === 'template') {
|
||||
const info = templateInfos.find(t => t.name === value)
|
||||
return {
|
||||
...prev,
|
||||
[field]: value,
|
||||
diskSizeGb: info ? Math.max(prev.diskSizeGb, info.defaultDiskSizeGb) : prev.diskSizeGb,
|
||||
model: info && info.os && MODELS.includes(info.os.toLowerCase())
|
||||
? info.os.toLowerCase()
|
||||
: prev.model,
|
||||
}
|
||||
}
|
||||
return { ...prev, [field]: value }
|
||||
})
|
||||
}
|
||||
|
||||
const setModel = (model: string) => setForm(prev => ({ ...prev, model }))
|
||||
const setNetworkMode = (mode: string) => setForm(prev => ({ ...prev, networkMode: mode as 'static' | 'dhcp' }))
|
||||
|
||||
return {
|
||||
form,
|
||||
setForm,
|
||||
error,
|
||||
setError,
|
||||
templateNames,
|
||||
participants,
|
||||
userIdStart,
|
||||
ipPool,
|
||||
userNames,
|
||||
vmNames,
|
||||
userIds,
|
||||
diskSizeNumber,
|
||||
diskSizeDisplay,
|
||||
minimumDiskSize,
|
||||
diskTooSmall,
|
||||
handleInputChange,
|
||||
handleSelectChange,
|
||||
setModel,
|
||||
setNetworkMode,
|
||||
}
|
||||
}
|
||||
@ -1,227 +1,106 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ChangeEvent, FormEvent } from 'react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { applyTerraform, fetchTemplates, type TemplateInfo, type TerraformPayload } from '@/features/formations/api'
|
||||
import { applyTerraform, type TerraformPayload } from '@/features/formations/api'
|
||||
import { FormCard } from '@/features/formations/components/FormCard'
|
||||
import { DisabledField, NumberField, SelectField, TextField } from '@/features/formations/components/Fields'
|
||||
import { DisabledField, NumberField, RadioGroup, SelectField, TextField } from '@/features/formations/components/Fields'
|
||||
import { InfoBox } from '@/features/formations/components/InfoBox'
|
||||
import { PreviewPanel } from '@/features/formations/components/PreviewPanel'
|
||||
import { generateStrongPassword } from '@/features/formations/utils/password'
|
||||
import { downloadCredentialsPdf } from '@/features/formations/utils/pdf'
|
||||
import { computeIpPool } from '@/features/formations/utils/network'
|
||||
import { buildFormationId, buildTerraformVms } from '@/features/formations/utils/helpers'
|
||||
import { useToast } from '@/context/ToastContext'
|
||||
|
||||
const fallbackTemplateInfos: TemplateInfo[] = [
|
||||
{ name: 'debian-tmp', defaultDiskSizeGb: 80, os: 'linux' },
|
||||
{ name: 'ubuntu-22-template', defaultDiskSizeGb: 64, os: 'linux' },
|
||||
{ name: 'windows-11-lab', defaultDiskSizeGb: 128, os: 'windows' },
|
||||
]
|
||||
const fallbackTemplates = fallbackTemplateInfos.map(t => t.name)
|
||||
const models = ['linux', 'windows']
|
||||
|
||||
const INITIAL_FORM = {
|
||||
name: '',
|
||||
participants: 10,
|
||||
referent: '',
|
||||
guacamoleGroup: '',
|
||||
guacamoleUserPrefix: 'user',
|
||||
vmPrefix: 'VM-',
|
||||
ipStart: '192.168.143.10',
|
||||
template: fallbackTemplates[0],
|
||||
cpuCores: 4,
|
||||
ramGb: 16,
|
||||
userIdStart: 1,
|
||||
diskSizeGb: fallbackTemplateInfos[0].defaultDiskSizeGb,
|
||||
model: models[0],
|
||||
networkMode: 'static' as 'static' | 'dhcp',
|
||||
}
|
||||
import { useFormationForm } from '@/features/formations/hooks/useFormationForm'
|
||||
import { MODELS } from '@/features/formations/constants'
|
||||
|
||||
export default function CreateFormation() {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState(INITIAL_FORM)
|
||||
const [templateInfos, setTemplateInfos] = useState<TemplateInfo[]>(fallbackTemplateInfos)
|
||||
|
||||
const templateNames = useMemo(() => templateInfos.map(t => t.name), [templateInfos])
|
||||
const currentTemplateInfo = useMemo<TemplateInfo | null>(
|
||||
() => templateInfos.find(t => t.name === form.template) ?? null,
|
||||
[templateInfos, form.template],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates()
|
||||
.then((fetched: TemplateInfo[]) => {
|
||||
if (Array.isArray(fetched) && fetched.length > 0) {
|
||||
setTemplateInfos(fetched)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Impossible de charger les templates', err)
|
||||
setTemplateInfos(fallbackTemplateInfos)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const participants = useMemo(
|
||||
() => Math.max(1, Math.min(200, Number(form.participants) || 1)),
|
||||
[form.participants],
|
||||
)
|
||||
|
||||
const userIdStart = useMemo(() => {
|
||||
const raw = Number(form.userIdStart)
|
||||
if (Number.isNaN(raw)) return 1
|
||||
return Math.max(1, Math.floor(raw))
|
||||
}, [form.userIdStart])
|
||||
|
||||
const ipPool = useMemo(() => computeIpPool(form.ipStart, participants), [form.ipStart, participants])
|
||||
const userNames = useMemo(
|
||||
() => Array.from({ length: participants }, (_, index) => `${form.guacamoleUserPrefix}${index + 1}`),
|
||||
[form.guacamoleUserPrefix, participants],
|
||||
)
|
||||
const vmNames = useMemo(
|
||||
() => Array.from({ length: participants }, (_, index) => `${form.vmPrefix}${index + 1}`),
|
||||
[form.vmPrefix, participants],
|
||||
)
|
||||
const userIds = useMemo(
|
||||
() => Array.from({ length: participants }, (_, index) => userIdStart + index),
|
||||
[participants, userIdStart],
|
||||
)
|
||||
const diskSizeNumber = useMemo(
|
||||
() => Math.max(1, Number(form.diskSizeGb) || 1),
|
||||
[form.diskSizeGb],
|
||||
)
|
||||
const diskSizeDisplay = `${diskSizeNumber}G`
|
||||
const minimumDiskSize = currentTemplateInfo?.defaultDiskSizeGb ?? null
|
||||
const diskTooSmall = minimumDiskSize !== null && diskSizeNumber < minimumDiskSize
|
||||
|
||||
useEffect(() => {
|
||||
if (templateNames.length === 0) return
|
||||
if (!templateNames.includes(form.template)) {
|
||||
setForm(prev => ({ ...prev, template: templateNames[0] }))
|
||||
}
|
||||
}, [form.template, templateNames])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTemplateInfo) return
|
||||
setForm(prev => {
|
||||
if (prev.template !== currentTemplateInfo.name) return prev
|
||||
if (prev.diskSizeGb >= currentTemplateInfo.defaultDiskSizeGb) return prev
|
||||
return { ...prev, diskSizeGb: currentTemplateInfo.defaultDiskSizeGb }
|
||||
})
|
||||
}, [currentTemplateInfo])
|
||||
|
||||
const handleInputChange = (field: keyof typeof INITIAL_FORM) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.type === 'number' ? Number(event.target.value) : event.target.value
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSelectChange = (field: keyof typeof INITIAL_FORM) => (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = event.target.value
|
||||
setForm(prev => {
|
||||
if (field === 'template') {
|
||||
const info = templateInfos.find(t => t.name === value)
|
||||
return {
|
||||
...prev,
|
||||
[field]: value,
|
||||
diskSizeGb: info ? Math.max(prev.diskSizeGb, info.defaultDiskSizeGb) : prev.diskSizeGb,
|
||||
model: info && info.os && models.includes(info.os.toLowerCase())
|
||||
? info.os.toLowerCase()
|
||||
: prev.model,
|
||||
}
|
||||
}
|
||||
return { ...prev, [field]: value }
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!diskTooSmall && error && error.toLowerCase().includes('disque')) {
|
||||
setError('')
|
||||
}
|
||||
}, [diskTooSmall, error])
|
||||
const {
|
||||
form,
|
||||
error,
|
||||
setError,
|
||||
templateNames,
|
||||
participants,
|
||||
userIdStart,
|
||||
ipPool,
|
||||
userNames,
|
||||
vmNames,
|
||||
userIds,
|
||||
diskSizeNumber,
|
||||
diskSizeDisplay,
|
||||
minimumDiskSize,
|
||||
diskTooSmall,
|
||||
handleInputChange,
|
||||
handleSelectChange,
|
||||
setModel,
|
||||
setNetworkMode,
|
||||
} = useFormationForm()
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
setError('')
|
||||
|
||||
if (!form.name.trim()) {
|
||||
setError('Le nom de la formation est requis')
|
||||
return
|
||||
}
|
||||
if (!form.referent.trim()) {
|
||||
setError('Le référent est requis')
|
||||
return
|
||||
}
|
||||
if (!form.guacamoleGroup.trim()) {
|
||||
setError('Le groupe Guacamole est requis')
|
||||
return
|
||||
}
|
||||
if (!ipPool.valid) {
|
||||
setError("La plage IP doit rester dans 192.168.143.0/24 et disposer d'assez d'adresses")
|
||||
return
|
||||
}
|
||||
if (!form.name.trim()) return setError('Le nom de la formation est requis')
|
||||
if (!form.referent.trim()) return setError('Le référent est requis')
|
||||
if (!form.guacamoleGroup.trim()) return setError('Le groupe Guacamole est requis')
|
||||
if (!ipPool.valid) return setError("La plage IP doit rester dans 192.168.143.0/24 et disposer d'assez d'adresses")
|
||||
if (minimumDiskSize !== null && diskSizeNumber < minimumDiskSize) {
|
||||
setError(`Le disque doit être au moins de ${minimumDiskSize} Go pour le template ${form.template}`)
|
||||
return
|
||||
return setError(`Le disque doit être au moins de ${minimumDiskSize} Go pour le template ${form.template}`)
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const credentials = userNames.map((username, index) => {
|
||||
const ip = ipPool.ips[index]
|
||||
const vmName = vmNames[index]
|
||||
const id = userIds[index]
|
||||
if (!ip || !vmName) {
|
||||
throw new Error('Incohérence entre les utilisateurs, les VMs ou les adresses IP générées')
|
||||
}
|
||||
if (!id) {
|
||||
throw new Error('Incohérence entre les identifiants générés et les participants')
|
||||
}
|
||||
const password = generateStrongPassword()
|
||||
const displayName = `Utilisateur ${index + 1}`
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
password,
|
||||
displayName,
|
||||
vm: {
|
||||
name: vmName,
|
||||
ip,
|
||||
cpuCores: Number(form.cpuCores),
|
||||
ramGb: Number(form.ramGb),
|
||||
try {
|
||||
const credentials = userNames.map((username, index) => {
|
||||
const ip = ipPool.ips[index]
|
||||
const vmName = vmNames[index]
|
||||
const id = userIds[index]
|
||||
|
||||
if (!ip || !vmName) throw new Error('Incohérence entre les utilisateurs, les VMs ou les adresses IP générées')
|
||||
if (!id) throw new Error('Incohérence entre les identifiants générés et les participants')
|
||||
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
password: generateStrongPassword(),
|
||||
displayName: `Utilisateur ${index + 1}`,
|
||||
vm: {
|
||||
name: vmName,
|
||||
ip,
|
||||
cpuCores: Number(form.cpuCores),
|
||||
ramGb: Number(form.ramGb),
|
||||
template: form.template,
|
||||
diskSize: diskSizeNumber,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const terraformPayload: TerraformPayload = {
|
||||
formationId: buildFormationId(form.name),
|
||||
model: form.model,
|
||||
vms: buildTerraformVms({
|
||||
vmNames,
|
||||
userIds,
|
||||
ipAddresses: ipPool.ips,
|
||||
template: form.template,
|
||||
diskSize: diskSizeNumber,
|
||||
cores: Number(form.cpuCores),
|
||||
ramGb: Number(form.ramGb),
|
||||
diskSizeGb: diskSizeNumber,
|
||||
credentials: credentials.map(({ username, password }) => ({ username, password })),
|
||||
networkMode: form.networkMode,
|
||||
}),
|
||||
guacamole: {
|
||||
groupName: form.guacamoleGroup.trim(),
|
||||
users: credentials.map(entry => ({
|
||||
username: entry.username,
|
||||
password: entry.password,
|
||||
displayName: entry.displayName,
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const terraformPayload: TerraformPayload = {
|
||||
formationId: buildFormationId(form.name),
|
||||
model: form.model,
|
||||
vms: buildTerraformVms({
|
||||
vmNames,
|
||||
userIds,
|
||||
ipAddresses: ipPool.ips,
|
||||
template: form.template,
|
||||
cores: Number(form.cpuCores),
|
||||
ramGb: Number(form.ramGb),
|
||||
diskSizeGb: diskSizeNumber,
|
||||
credentials: credentials.map(({ username, password }) => ({ username, password })),
|
||||
networkMode: form.networkMode,
|
||||
}),
|
||||
guacamole: {
|
||||
groupName: form.guacamoleGroup.trim(),
|
||||
users: credentials.map(entry => ({
|
||||
username: entry.username,
|
||||
password: entry.password,
|
||||
displayName: entry.displayName,
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await applyTerraform(terraformPayload)
|
||||
|
||||
void downloadCredentialsPdf({
|
||||
@ -237,15 +116,16 @@ export default function CreateFormation() {
|
||||
|
||||
const friendlyName = form.name.trim() || response.formationId
|
||||
const detail = response.remotePath ? ` (dossier: ${response.remotePath})` : ''
|
||||
const baseMessage =
|
||||
response.status === 'queued'
|
||||
? `Déploiement en file d'attente pour ${friendlyName}`
|
||||
: `Déploiement ${response.status} pour ${friendlyName}`
|
||||
const baseMessage = response.status === 'queued'
|
||||
? `Déploiement en file d'attente pour ${friendlyName}`
|
||||
: `Déploiement ${response.status} pour ${friendlyName}`
|
||||
|
||||
toast.info(`${baseMessage}${detail}`)
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Erreur lors de la création de la formation')
|
||||
toast.error(err?.message || 'Erreur lors de la création de la formation')
|
||||
const msg = err?.message || 'Erreur lors de la création de la formation'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -301,23 +181,13 @@ export default function CreateFormation() {
|
||||
<FormCard title="Machines virtuelles">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<TextField label="Préfixe VM" value={form.vmPrefix} onChange={handleInputChange('vmPrefix')} placeholder="VM-" required />
|
||||
<div className="flex flex-col gap-[2px] text-left text-xs">
|
||||
<span className="text-white/70 text-[11px] uppercase tracking-[0.2em]">Modèle</span>
|
||||
<div className="flex items-center gap-4 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm">
|
||||
{models.map(option => (
|
||||
<label key={option} className="flex items-center gap-2 text-white/80">
|
||||
<input
|
||||
type="radio"
|
||||
name="model"
|
||||
value={option}
|
||||
checked={form.model === option}
|
||||
onChange={() => setForm(prev => ({ ...prev, model: option }))}
|
||||
/>
|
||||
<span className="capitalize">{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<RadioGroup
|
||||
label="Modèle"
|
||||
name="model"
|
||||
value={form.model}
|
||||
options={MODELS.map(m => ({ label: m, value: m }))}
|
||||
onChange={setModel}
|
||||
/>
|
||||
<SelectField label="Template" value={form.template} onChange={handleSelectChange('template')} options={templateNames} />
|
||||
<NumberField label="CPU (cœurs)" value={form.cpuCores} min={1} max={32} onChange={handleInputChange('cpuCores')} />
|
||||
<NumberField label="RAM (Go)" value={form.ramGb} min={1} max={256} onChange={handleInputChange('ramGb')} />
|
||||
@ -333,31 +203,16 @@ export default function CreateFormation() {
|
||||
|
||||
<FormCard title="Réseau">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-[2px] text-left text-xs">
|
||||
<span className="text-white/70 text-[11px] uppercase tracking-[0.2em]">Mode</span>
|
||||
<div className="flex items-center gap-4 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm">
|
||||
<label className="flex items-center gap-2 text-white/80">
|
||||
<input
|
||||
type="radio"
|
||||
name="networkMode"
|
||||
value="static"
|
||||
checked={form.networkMode === 'static'}
|
||||
onChange={() => setForm(prev => ({ ...prev, networkMode: 'static' }))}
|
||||
/>
|
||||
<span>Statique</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-white/80">
|
||||
<input
|
||||
type="radio"
|
||||
name="networkMode"
|
||||
value="dhcp"
|
||||
checked={form.networkMode === 'dhcp'}
|
||||
onChange={() => setForm(prev => ({ ...prev, networkMode: 'dhcp' }))}
|
||||
/>
|
||||
<span>DHCP</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<RadioGroup
|
||||
label="Mode"
|
||||
name="networkMode"
|
||||
value={form.networkMode}
|
||||
options={[
|
||||
{ label: 'Statique', value: 'static' },
|
||||
{ label: 'DHCP', value: 'dhcp' }
|
||||
]}
|
||||
onChange={setNetworkMode}
|
||||
/>
|
||||
<TextField label="IP de départ (Guacamole)" value={form.ipStart} onChange={handleInputChange('ipStart')} helper="Reste dans 192.168.143.x" required />
|
||||
{form.networkMode === 'static' && <DisabledField label="Sous-réseau" value="192.168.143.0/24" />}
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user