All checks were successful
Build and Push Docker Images / docker (push) Successful in 30s
- Implemented WebSocket endpoint for executing interactive shells in Docker containers. - Added ticket-based authentication for terminal access to enhance security. - Updated frontend to support terminal interactions, including lazy loading of terminal components. - Enhanced backend to manage terminal session tickets and settings for enabling/disabling terminal access. - Updated Nginx configuration to support WebSocket connections for terminal sessions. - Added necessary dependencies for terminal functionality in the frontend. - Improved user interface to include terminal access controls and feedback on terminal status.
613 lines
22 KiB
JavaScript
613 lines
22 KiB
JavaScript
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 (
|
|
<LoginPage
|
|
isFirstUser={isFirstUser}
|
|
passkeyEnabled={passkeyEnabled}
|
|
onAuthenticated={handleAuthenticated}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// 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 <ProfilePage username={username} onBack={() => setPage('main')} />
|
|
}
|
|
|
|
if (page === 'admin') {
|
|
return <AdminPage onBack={() => setPage('main')} />
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-950 text-gray-100">
|
|
<Header
|
|
lastUpdate={lastUpdate}
|
|
onRefresh={() => 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}
|
|
/>
|
|
|
|
<main className="max-w-7xl mx-auto px-4 py-6 sm:py-8">
|
|
|
|
{/* Barre d'erreur backend */}
|
|
{error && (
|
|
<div className="mb-6 flex flex-wrap items-center gap-3 bg-red-950/40 border border-red-800/50 rounded-xl px-4 py-3 text-sm text-red-300">
|
|
<span className="flex-1 min-w-[240px]">
|
|
Impossible de joindre le backend : <span className="font-mono text-xs">{error}</span>
|
|
</span>
|
|
<Button variant="outline" size="sm" icon={RefreshCw} onClick={() => refresh(true)} loading={refreshing}>
|
|
Réessayer
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats globales */}
|
|
{!loading && vpsList.length > 0 && (
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
|
{[
|
|
{ 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 }) => (
|
|
<div key={label} className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
|
|
<p className={`text-2xl font-bold tabular-nums ${color}`}>{value}</p>
|
|
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Filtres */}
|
|
{!loading && vpsList.length > 0 && (
|
|
<DashboardToolbar
|
|
search={search}
|
|
onSearchChange={setSearch}
|
|
searchRef={searchRef}
|
|
status={status}
|
|
onStatusChange={handleStatusChange}
|
|
tags={allTags}
|
|
activeTags={activeTags}
|
|
onToggleTag={handleToggleTag}
|
|
sort={sort}
|
|
onSortChange={handleSortChange}
|
|
shown={visibleVps.length}
|
|
total={vpsList.length}
|
|
/>
|
|
)}
|
|
|
|
{/* Chargement initial — squelettes */}
|
|
{loading && (
|
|
<>
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
|
{Array.from({ length: 4 }, (_, i) => <Skeleton key={i} className="h-[68px]" />)}
|
|
</div>
|
|
<div className="grid gap-5 lg:grid-cols-2">
|
|
{Array.from({ length: 2 }, (_, i) => <Skeleton key={i} className="h-64" />)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Aucun VPS configuré */}
|
|
{!loading && vpsList.length === 0 && !error && (
|
|
<EmptyState
|
|
icon={ServerCrash}
|
|
title="Aucun VPS configuré"
|
|
description="Ajoutez un premier serveur pour suivre ses conteneurs, ses services et sa charge en temps réel."
|
|
action={
|
|
<Button variant="primary" size="lg" icon={Plus} onClick={() => setShowAddVps(true)}>
|
|
Ajouter un VPS
|
|
</Button>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* Aucun résultat après filtrage */}
|
|
{!loading && vpsList.length > 0 && visibleVps.length === 0 && (
|
|
<EmptyState
|
|
icon={SearchX}
|
|
title="Aucun VPS ne correspond"
|
|
description="Aucun serveur ne correspond à la recherche ou aux filtres actifs."
|
|
action={
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => { setSearch(''); handleStatusChange('all'); setActiveTags([]); localStorage.setItem('filterTags', '[]') }}
|
|
>
|
|
Réinitialiser les filtres
|
|
</Button>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<div className="grid gap-5 lg:grid-cols-2 items-start">
|
|
{visibleVps.map(vps => (
|
|
<VpsCard
|
|
key={vps.id}
|
|
vps={vps}
|
|
query={search.trim().toLowerCase()}
|
|
onAction={handleAction}
|
|
onLogs={openLogs}
|
|
onDelete={() => 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}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Modal logs */}
|
|
{logsModal && (
|
|
<LogsModal
|
|
name={logsModal.name}
|
|
logs={logsContent}
|
|
loading={logsLoading}
|
|
onClose={() => setLogsModal(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Modal mise à jour compose */}
|
|
{updateModal && (
|
|
<LogsModal
|
|
name={`Mise à jour — ${updateModal.project}`}
|
|
logs={updateContent}
|
|
loading={updateLoading}
|
|
onClose={() => setUpdateModal(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Modal ajout VPS */}
|
|
{showAddVps && (
|
|
<AddVpsModal
|
|
onSave={handleAddVps}
|
|
onClose={() => setShowAddVps(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Modal édition VPS */}
|
|
{editVps && (
|
|
<EditVpsModal
|
|
vps={editVps}
|
|
onSave={handleEditVps}
|
|
onClose={() => setEditVps(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Modal statistiques */}
|
|
{statsModal && (
|
|
<StatsModal
|
|
vpsId={statsModal.vpsId}
|
|
vpsName={statsModal.vpsName}
|
|
onClose={() => setStatsModal(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Terminal interactif */}
|
|
{terminalModal && (
|
|
<Suspense fallback={
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm text-sm text-gray-400">
|
|
Chargement du terminal…
|
|
</div>
|
|
}>
|
|
<TerminalModal
|
|
vps={terminalModal.vps}
|
|
container={terminalModal.container}
|
|
onClose={() => setTerminalModal(null)}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{/* Confirmation de suppression */}
|
|
{deleteTarget && (
|
|
<ConfirmDialog
|
|
danger
|
|
title="Supprimer ce VPS ?"
|
|
message={
|
|
<>
|
|
<span className="text-gray-200 font-medium">{deleteTarget.name}</span>{' '}
|
|
(<span className="font-mono text-xs">{deleteTarget.host}</span>) sera retiré de la
|
|
configuration. Les conteneurs du serveur ne sont pas touchés.
|
|
</>
|
|
}
|
|
confirmLabel="Supprimer"
|
|
loading={deleting}
|
|
onConfirm={handleDeleteVps}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|