import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react' import { ServerCrash, SearchX, Plus, RefreshCw } from 'lucide-react' import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client' import Header from './components/Header' import VpsCard from './components/VpsCard' import LogsModal from './components/LogsModal' import AddVpsModal from './components/AddVpsModal' import EditVpsModal from './components/EditVpsModal' import StatsModal from './components/StatsModal' import LoginPage from './components/LoginPage' import ProfilePage from './components/ProfilePage' import AdminPage from './components/AdminPage' import DashboardToolbar from './components/DashboardToolbar' import ConfirmDialog from './components/ui/ConfirmDialog' import { useToast } from './components/ui/Toast' import { Button, EmptyState, Skeleton } from './components/ui/controls' import { copyText, downloadText } from './lib/clipboard' // xterm.js pèse ~300 kB : chargé seulement à l'ouverture d'un terminal. const TerminalModal = lazy(() => import('./components/TerminalModal')) const INTERVAL_OPTIONS = [ { label: '10 s', value: 10_000 }, { label: '30 s', value: 30_000 }, { label: '1 min', value: 60_000 }, { label: '2 min', value: 120_000 }, { label: '5 min', value: 300_000 }, { label: 'Off', value: 0 }, ] const ACTION_LABELS = { start: 'démarré', stop: 'arrêté', restart: 'redémarré', } /** Un VPS « à problème » : injoignable, conteneur non démarré ou en mauvaise santé. */ function hasIssue(vps) { if (!vps.online) return true if (vps.containers.some(c => c.status !== 'running' || c.health === 'unhealthy')) return true return (vps.services ?? []).some(s => s.active === 'failed') } /** Champs pris en compte par la recherche libre. */ function vpsMatchesQuery(vps, query) { const haystack = [ vps.name, vps.host, vps.description, ...(vps.tags ?? []), ...vps.containers.flatMap(c => [c.name, c.image, c.compose_project]), ...(vps.services ?? []).map(s => s.name), ] return haystack.filter(Boolean).some(value => value.toLowerCase().includes(query)) } export default function App() { const toast = useToast() const [token, setTokenState] = useState(() => getToken()) const [username, setUsername] = useState(null) const [role, setRole] = useState(null) const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin' const [isFirstUser, setIsFirstUser] = useState(false) const [passkeyEnabled, setPasskeyEnabled] = useState(false) const [terminalEnabled, setTerminalEnabled] = useState(true) const [authChecked, setAuthChecked] = useState(false) const [vpsList, setVpsList] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) const [lastUpdate, setLastUpdate] = useState(null) const [logsModal, setLogsModal] = useState(null) const [logsContent, setLogsContent] = useState('') const [logsLoading, setLogsLoading] = useState(false) const [showAddVps, setShowAddVps] = useState(false) const [editVps, setEditVps] = useState(null) // objet vps à éditer const [deleteTarget, setDeleteTarget] = useState(null) // vps en attente de confirmation const [deleting, setDeleting] = useState(false) const [refreshInterval, setRefreshInterval] = useState(() => { const stored = localStorage.getItem('refreshInterval') return stored ? parseInt(stored, 10) : 30_000 }) // ─── Filtres du tableau de bord ───────────────────────────────────────── const [search, setSearch] = useState('') const [status, setStatus] = useState(() => localStorage.getItem('filterStatus') ?? 'all') const [activeTags, setActiveTags] = useState(() => { try { return JSON.parse(localStorage.getItem('filterTags') ?? '[]') } catch { return [] } }) const [sort, setSort] = useState(() => localStorage.getItem('sortBy') ?? 'name') const searchRef = useRef(null) const handleIntervalChange = (val) => { setRefreshInterval(val) localStorage.setItem('refreshInterval', val) } const handleStatusChange = (val) => { setStatus(val) localStorage.setItem('filterStatus', val) } const handleSortChange = (val) => { setSort(val) localStorage.setItem('sortBy', val) } const handleToggleTag = (tag) => { setActiveTags(prev => { const next = prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag] localStorage.setItem('filterTags', JSON.stringify(next)) return next }) } const [updateModal, setUpdateModal] = useState(null) // { vpsId, project } const [updateContent, setUpdateContent] = useState('') const [updateLoading, setUpdateLoading] = useState(false) const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName } const [terminalModal, setTerminalModal] = useState(null) // { vps, container } // Vérifie si des utilisateurs existent (pour afficher login ou register) useEffect(() => { authStatus() .then(({ has_users, passkey_enabled, terminal_enabled }) => { setIsFirstUser(!has_users) setPasskeyEnabled(!!passkey_enabled) setTerminalEnabled(terminal_enabled !== false) }) .catch(() => setIsFirstUser(false)) .finally(() => setAuthChecked(true)) }, []) // Écoute l'événement d'expiration de token émis par client.js useEffect(() => { const onExpired = () => { setToken(null) setTokenState(null) setUsername(null) setRole(null) setPage('main') } window.addEventListener('auth:expired', onExpired) return () => window.removeEventListener('auth:expired', onExpired) }, []) const handleAuthenticated = (accessToken, role, user) => { setToken(accessToken) setTokenState(accessToken) try { const payload = JSON.parse(atob(accessToken.split('.')[1])) setUsername(payload.sub) setRole(payload.role ?? role ?? 'user') } catch { setUsername(user ?? 'user') setRole(role ?? 'user') } } const handleLogout = () => { setToken(null) setTokenState(null) setUsername(null) setRole(null) setPage('main') setVpsList([]) setLoading(true) } const refresh = useCallback(async (showSpinner = false) => { if (showSpinner) setRefreshing(true) try { const data = await fetchAllStatus() setVpsList(data) setLastUpdate(new Date()) setError(null) } catch (e) { setError(e.message) } finally { setLoading(false) setRefreshing(false) } }, []) useEffect(() => { if (!token) return refresh() if (!refreshInterval) return const id = setInterval(() => refresh(), refreshInterval) return () => clearInterval(id) }, [refresh, token, refreshInterval]) // Extrait le username et le rôle du token stocké au rechargement de page useEffect(() => { if (token && !username) { try { const payload = JSON.parse(atob(token.split('.')[1])) setUsername(payload.sub) setRole(payload.role ?? 'user') } catch { /* ignore */ } } }, [token, username]) // Raccourcis clavier : « / » cible la recherche, « r » actualise. const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget || terminalModal) useEffect(() => { if (!token || page !== 'main' || modalOpen) return const onKeyDown = (e) => { if (e.metaKey || e.ctrlKey || e.altKey) return const el = e.target if (el instanceof HTMLElement && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return if (e.key === '/') { e.preventDefault() searchRef.current?.focus() } else if (e.key.toLowerCase() === 'r') { e.preventDefault() refresh(true) } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [token, page, modalOpen, refresh]) const openLogs = async (vpsId, containerId, name) => { setLogsModal({ vpsId, containerId, name }) setLogsLoading(true) setLogsContent('') try { const data = await fetchLogs(vpsId, containerId) setLogsContent(data.logs) } catch (e) { setLogsContent(`Erreur lors de la récupération des logs :\n${e.message}`) } finally { setLogsLoading(false) } } const handleAction = async (vpsId, containerId, action) => { try { await containerAction(vpsId, containerId, action) toast.success(`Conteneur ${ACTION_LABELS[action] ?? action}.`) await refresh() } catch (e) { toast.error(`Action « ${action} » impossible : ${e.message}`) } } const handleUpdate = async (vpsId, project) => { setUpdateModal({ vpsId, project }) setUpdateLoading(true) setUpdateContent('') try { const data = await composeUpdate(vpsId, project) setUpdateContent(data.output || '(aucune sortie)') toast.success(`Projet « ${project} » mis à jour.`) } catch (e) { setUpdateContent(`Erreur lors de la mise à jour :\n${e.message}`) toast.error(`Mise à jour de « ${project} » échouée.`) } finally { setUpdateLoading(false) await refresh() } } const handleUpdateAgent = async (vpsId) => { setUpdateModal({ vpsId, project: 'agent' }) setUpdateLoading(true) setUpdateContent('Lancement de la mise à jour de l\'agent…\n') try { await updateAgent(vpsId) setUpdateContent('Mise à jour lancée. L\'agent va redémarrer dans quelques secondes.\nActualisez dans un moment pour vérifier la nouvelle version.') toast.info('Mise à jour de l\'agent lancée.') } catch (e) { setUpdateContent(`Erreur lors de la mise à jour de l'agent :\n${e.message}`) toast.error(`Mise à jour de l'agent échouée : ${e.message}`) } finally { setUpdateLoading(false) setTimeout(() => refresh(), 8000) } } const handleAddVps = async (formData) => { await addVps(formData) setShowAddVps(false) toast.success(`VPS « ${formData.name} » ajouté.`) await refresh(true) } const handleDeleteVps = async () => { if (!deleteTarget) return setDeleting(true) try { await deleteVps(deleteTarget.id) toast.success(`VPS « ${deleteTarget.name} » supprimé.`) setDeleteTarget(null) await refresh(true) } catch (e) { toast.error(`Suppression impossible : ${e.message}`) } finally { setDeleting(false) } } const handleEditVps = async (vpsId, data) => { await updateVps(vpsId, data) setEditVps(null) toast.success('Configuration enregistrée.') await refresh(true) } /** Copie la config d'un VPS ; retombe sur un téléchargement si le presse-papiers est bloqué. */ const handleExportVps = async (vpsId) => { try { const config = await exportVps(vpsId) const json = JSON.stringify(config, null, 2) if (await copyText(json)) { toast.success('Configuration copiée dans le presse-papiers.') return true } downloadText(`${vpsId}.json`, json, 'application/json') toast.info('Presse-papiers indisponible (connexion non sécurisée) — configuration téléchargée.') return false } catch (e) { toast.error(`Export impossible : ${e.message}`) return false } } // ─── Liste filtrée et triée ───────────────────────────────────────────── const allTags = useMemo( () => [...new Set(vpsList.flatMap(v => v.tags ?? []))].sort((a, b) => a.localeCompare(b, 'fr')), [vpsList], ) const visibleVps = useMemo(() => { const query = search.trim().toLowerCase() const filtered = vpsList.filter(vps => { if (status === 'online' && !vps.online) return false if (status === 'offline' && vps.online) return false if (status === 'issues' && !hasIssue(vps)) return false if (activeTags.length > 0 && !activeTags.every(t => (vps.tags ?? []).includes(t))) return false if (query && !vpsMatchesQuery(vps, query)) return false return true }) const byName = (a, b) => a.name.localeCompare(b.name, 'fr') const sorters = { name: byName, status: (a, b) => (Number(b.online) - Number(a.online)) || (Number(hasIssue(a)) - Number(hasIssue(b))) || byName(a, b), cpu: (a, b) => (b.system?.cpu_percent ?? -1) - (a.system?.cpu_percent ?? -1) || byName(a, b), ram: (a, b) => (b.system?.ram_percent ?? -1) - (a.system?.ram_percent ?? -1) || byName(a, b), containers: (a, b) => b.containers.length - a.containers.length || byName(a, b), } return [...filtered].sort(sorters[sort] ?? byName) }, [vpsList, search, status, activeTags, sort]) // Attente vérification auth if (!authChecked) return null // Pas de token → login / register if (!token) { return ( ) } // Le terminal donne un shell root dans le conteneur : réservé aux admins, et // désactivable globalement depuis la page d'administration. const canUseTerminal = role === 'admin' && terminalEnabled // Statistiques globales const totalOnline = vpsList.filter(v => v.online).length const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0) const totalRunning = vpsList.reduce((acc, v) => acc + v.containers.filter(c => c.status === 'running').length, 0) const totalIssues = vpsList.filter(hasIssue).length // ─── Pages profil / admin ─────────────────────────────────────────────── if (page === 'profile') { return setPage('main')} /> } if (page === 'admin') { return setPage('main')} /> } return (
refresh(true)} onAddVps={() => setShowAddVps(true)} refreshing={refreshing} username={username} role={role} onLogout={handleLogout} onProfile={() => setPage('profile')} onAdmin={() => setPage('admin')} refreshInterval={refreshInterval} onIntervalChange={handleIntervalChange} intervalOptions={INTERVAL_OPTIONS} />
{/* Barre d'erreur backend */} {error && (
Impossible de joindre le backend : {error}
)} {/* Stats globales */} {!loading && vpsList.length > 0 && (
{[ { label: 'VPS en ligne', value: `${totalOnline}/${vpsList.length}`, color: 'text-emerald-400' }, { label: 'Conteneurs actifs', value: `${totalRunning}/${totalContainers}`, color: 'text-indigo-400' }, { label: 'À surveiller', value: String(totalIssues), color: totalIssues > 0 ? 'text-orange-400' : 'text-gray-400' }, { label: 'Actualisation auto', value: INTERVAL_OPTIONS.find(o => o.value === refreshInterval)?.label ?? 'Off', color: 'text-gray-400' }, ].map(({ label, value, color }) => (

{value}

{label}

))}
)} {/* Filtres */} {!loading && vpsList.length > 0 && ( )} {/* Chargement initial — squelettes */} {loading && ( <>
{Array.from({ length: 4 }, (_, i) => )}
{Array.from({ length: 2 }, (_, i) => )}
)} {/* Aucun VPS configuré */} {!loading && vpsList.length === 0 && !error && ( setShowAddVps(true)}> Ajouter un VPS } /> )} {/* Aucun résultat après filtrage */} {!loading && vpsList.length > 0 && visibleVps.length === 0 && ( { setSearch(''); handleStatusChange('all'); setActiveTags([]); localStorage.setItem('filterTags', '[]') }} > Réinitialiser les filtres } /> )} {/* Grille de VPS */} {/* `items-start` : chaque carte garde sa hauteur naturelle plutôt que d'être étirée à celle de la plus haute de sa rangée. */} {!loading && visibleVps.length > 0 && (
{visibleVps.map(vps => ( setDeleteTarget(vps)} onUpdate={handleUpdate} onEdit={setEditVps} onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })} onUpdateAgent={handleUpdateAgent} onExport={handleExportVps} onTerminal={canUseTerminal ? (target, containerRow) => setTerminalModal({ vps: target, container: containerRow }) : undefined} /> ))}
)}
{/* Modal logs */} {logsModal && ( setLogsModal(null)} /> )} {/* Modal mise à jour compose */} {updateModal && ( setUpdateModal(null)} /> )} {/* Modal ajout VPS */} {showAddVps && ( setShowAddVps(false)} /> )} {/* Modal édition VPS */} {editVps && ( setEditVps(null)} /> )} {/* Modal statistiques */} {statsModal && ( setStatsModal(null)} /> )} {/* Terminal interactif */} {terminalModal && ( Chargement du terminal…
}> setTerminalModal(null)} /> )} {/* Confirmation de suppression */} {deleteTarget && ( {deleteTarget.name}{' '} ({deleteTarget.host}) sera retiré de la configuration. Les conteneurs du serveur ne sont pas touchés. } confirmLabel="Supprimer" loading={deleting} onConfirm={handleDeleteVps} onCancel={() => setDeleteTarget(null)} /> )} ) }