Files
ScriptVPS/vps-monitor/frontend/src/App.jsx
jeanotx32 e277e99155
All checks were successful
Build and Push Docker Images / docker (push) Successful in 30s
feat: add interactive terminal support for containers via WebSocket
- 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.
2026-08-01 01:49:26 -04:00

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