Refacto du code

This commit is contained in:
corenthin-lebreton 2025-12-02 20:24:14 +01:00
parent 488d90880c
commit 4cff540ef7
4 changed files with 324 additions and 259 deletions

View File

@ -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>
)
}

View 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',
}

View 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,
}
}

View File

@ -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>