import { useCallback, useEffect, useState } from 'react' import { Boxes, FilePlus2, RefreshCw, Pencil, Rocket, ArrowLeft, AlertTriangle, FolderOpen, Terminal as TerminalIcon, } from 'lucide-react' import Modal from './ui/Modal' import YamlEditor from './YamlEditor' import { Button, inputClass, Skeleton, EmptyState } from './ui/controls' import { useToast } from './ui/Toast' import { fetchApps, fetchAppCompose, saveAppCompose, createApp, deployApp } from '../api/client' import { formatBytes } from '../lib/format' const APP_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ const COMPOSE_FILENAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'] const TEMPLATE = (name) => `services: ${name}: image: nginx:alpine container_name: ${name} restart: unless-stopped ports: - "8080:80" volumes: - ./data:/usr/share/nginx/html:ro ` export default function AppsModal({ vps, onClose }) { const toast = useToast() const [view, setView] = useState('list') // 'list' | 'editor' | 'create' const [apps, setApps] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [busy, setBusy] = useState(null) // nom de l'app en cours de déploiement // Édition const [editing, setEditing] = useState(null) // { name, file, path, original } const [content, setContent] = useState('') const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) const [leaving, setLeaving] = useState(false) // demande de sortie avec modifications // Création — le modèle suit le nom saisi tant que l'utilisateur n'a pas // touché à l'éditeur ; dès qu'il y écrit, on ne réécrit plus rien. const [newName, setNewName] = useState('') const [newFilename, setNewFilename] = useState('compose.yaml') const [newContent, setNewContent] = useState('') const [templateTouched, setTemplateTouched] = useState(false) // Sortie de déploiement const [output, setOutput] = useState(null) const dirty = view === 'editor' && editing !== null && content !== editing.original const load = useCallback(async () => { setLoading(true) setError(null) try { setApps(await fetchApps(vps.id)) } catch (e) { setError(e.message) } finally { setLoading(false) } }, [vps.id]) useEffect(() => { load() }, [load]) // ─── Actions ────────────────────────────────────────────────────────────── const openEditor = async (app) => { setBusy(app.name) setSaveError(null) setOutput(null) try { const data = await fetchAppCompose(vps.id, app.name) setEditing({ name: data.name, file: data.file, path: data.path, original: data.content }) setContent(data.content) setView('editor') } catch (e) { toast.error(`Lecture de ${app.name} impossible : ${e.message}`) } finally { setBusy(null) } } const handleSave = async (deploy) => { setSaving(true) setSaveError(null) setOutput(null) try { const result = await saveAppCompose(vps.id, editing.name, content, deploy) setEditing(prev => ({ ...prev, original: content, file: result.file })) toast.success( deploy ? `${editing.name} enregistrée et déployée.` : `${editing.name} enregistrée.` ) if (result.output) setOutput(result.output) load() } catch (e) { setSaveError(e.message) } finally { setSaving(false) } } const handleCreate = async (deploy) => { setSaving(true) setSaveError(null) setOutput(null) try { const result = await createApp(vps.id, { name: newName.trim(), content: newContent, filename: newFilename, deploy, }) toast.success(`Application « ${result.name} » créée dans ${result.path}.`) if (result.output) setOutput(result.output) await load() setEditing({ name: result.name, file: result.file, path: result.path, original: newContent }) setContent(newContent) setView('editor') } catch (e) { setSaveError(e.message) } finally { setSaving(false) } } const handleDeploy = async (app) => { setBusy(app.name) setOutput(null) try { const result = await deployApp(vps.id, app.name) toast.success(`${app.name} déployée.`) setOutput(result.output) load() } catch (e) { toast.error(`Déploiement de ${app.name} impossible : ${e.message}`) } finally { setBusy(null) } } const startCreate = () => { setNewName('') setNewFilename('compose.yaml') setNewContent(TEMPLATE('mon-app')) setTemplateTouched(false) setSaveError(null) setOutput(null) setView('create') } const handleNewName = (value) => { setNewName(value) const trimmed = value.trim() if (!templateTouched) setNewContent(TEMPLATE(APP_NAME_RE.test(trimmed) ? trimmed : 'mon-app')) } const handleNewContent = (value) => { setTemplateTouched(true) setNewContent(value) } const backToList = () => { if (dirty && !leaving) { setLeaving(true); return } setLeaving(false) setEditing(null) setSaveError(null) setView('list') } const requestClose = () => { if (dirty && !leaving) { setLeaving(true); return } onClose() } const nameValid = APP_NAME_RE.test(newName.trim()) // ─── Rendu ──────────────────────────────────────────────────────────────── const title = { list: `Applications — ${vps.name}`, editor: editing ? `${editing.name} / ${editing.file}` : 'Édition', create: `Nouvelle application — ${vps.name}`, }[view] const subtitle = { list: `Fichiers compose sous /home sur ${vps.host}`, editor: editing?.path, create: `Créé dans /home/${newName.trim() || ''}/${newFilename}`, }[view] return ( } onClose={requestClose} // Tab sert à indenter le YAML ; Échap ne doit pas jeter des modifications. trapTab={false} closeOnEscape={!dirty} bodyClassName="flex flex-col overflow-hidden p-0" headerRight={view === 'list' && ( <> )} footer={
{leaving ? ( <>

Modifications non enregistrées. Quitter sans enregistrer ?

) : view === 'list' ? ( <>

{apps.length} application{apps.length > 1 ? 's' : ''} détectée{apps.length > 1 ? 's' : ''}

) : view === 'editor' ? ( <>

{dirty ? 'Modifications non enregistrées · l\'ancienne version est sauvegardée en .bak' : 'Validé par `docker compose config` avant écriture'}

) : ( <>

Le dossier est créé s'il n'existe pas encore.

)}
} > {/* ── Liste ── */} {view === 'list' && (
{error && (
{error}
)} {loading ? (
{Array.from({ length: 4 }, (_, i) => )}
) : apps.length === 0 && !error ? ( Nouvelle application} /> ) : (
    {apps.map(app => (
  • {app.name} {app.file} {app.running_containers > 0 ? ( {app.running_containers} conteneur{app.running_containers > 1 ? 's' : ''} actif{app.running_containers > 1 ? 's' : ''} ) : ( arrêtée )}

    {app.path} · {formatBytes(app.size)} · modifié le {new Date(app.modified).toLocaleString('fr-FR')}

  • ))}
)} {output && setOutput(null)} />}
)} {/* ── Édition ── */} {view === 'editor' && editing && (
{saveError && (
{saveError}
)}
{output && setOutput(null)} />}
)} {/* ── Création ── */} {view === 'create' && (
{saveError && (
{saveError}
)}
handleNewName(e.target.value)} placeholder="mon-app" autoFocus className={inputClass} />

{newName && !nameValid ? 'Lettres, chiffres, point, tiret et souligné uniquement.' : `Dossier créé : /home/${newName.trim() || ''}`}

{output && setOutput(null)} />}
)}
) } function DeployOutput({ output, onClear }) { return (
Sortie du déploiement
        {output || '(aucune sortie)'}
      
) }