feat: enhance VPS management interface with search, filter, and toast notifications
All checks were successful
Build and Push Docker Images / docker (push) Successful in 42s

- Added a DashboardToolbar component for searching, filtering, and sorting VPS instances.
- Implemented a ConfirmDialog component for destructive actions confirmation.
- Introduced a Modal component for displaying modals with focus management.
- Created a Toast component for displaying notifications with different types (success, error, info).
- Refactored VpsCard to utilize new Metric component for displaying CPU and RAM usage.
- Improved user experience with local storage for collapsed state in VpsCard.
- Added clipboard utility functions for copying text and downloading content.
- Enhanced CSS styles for better dark mode support and animations.
- Updated various UI controls for consistency and improved accessibility.
This commit is contained in:
jeanotx32
2026-08-01 01:22:45 -04:00
parent 022fbabe5d
commit f37b639226
20 changed files with 1689 additions and 727 deletions

View File

@@ -1,14 +1,20 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef } 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 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'
const INTERVAL_OPTIONS = [
{ label: '10 s', value: 10_000 },
@@ -19,7 +25,35 @@ const INTERVAL_OPTIONS = [
{ 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)
@@ -38,16 +72,46 @@ export default function App() {
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)
@@ -135,6 +199,28 @@ export default function App() {
}
}, [token, username])
// Raccourcis clavier : « / » cible la recherche, « r » actualise.
const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget)
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)
@@ -150,8 +236,13 @@ export default function App() {
}
const handleAction = async (vpsId, containerId, action) => {
await containerAction(vpsId, containerId, action)
await refresh()
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) => {
@@ -161,8 +252,10 @@ export default function App() {
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()
@@ -176,8 +269,10 @@ export default function App() {
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)
@@ -187,26 +282,79 @@ export default function App() {
const handleAddVps = async (formData) => {
await addVps(formData)
setShowAddVps(false)
toast.success(`VPS « ${formData.name} » ajouté.`)
await refresh(true)
}
const handleDeleteVps = async (vpsId) => {
if (!window.confirm('Supprimer ce VPS de la configuration ?')) return
await deleteVps(vpsId)
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) => {
const config = await exportVps(vpsId)
await navigator.clipboard.writeText(JSON.stringify(config, null, 2))
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
@@ -225,6 +373,7 @@ export default function App() {
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') {
@@ -252,65 +401,111 @@ export default function App() {
intervalOptions={INTERVAL_OPTIONS}
/>
<main className="max-w-7xl mx-auto px-4 py-8">
<main className="max-w-7xl mx-auto px-4 py-6 sm:py-8">
{/* Barre d'erreur backend */}
{error && (
<div className="mb-6 bg-red-950/40 border border-red-800/50 rounded-xl px-4 py-3 text-sm text-red-300">
Impossible de joindre le backend : <span className="font-mono">{error}</span>
<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-3 gap-4 mb-8">
<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: '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 ${color}`}>{value}</p>
<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>
)}
{/* Chargement initial */}
{loading && (
<div className="text-center py-24 text-gray-600">
<svg className="w-8 h-8 animate-spin mx-auto mb-3 text-indigo-500" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Chargement
</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}
/>
)}
{/* Aucun VPS */}
{/* 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 && (
<div className="text-center py-24 text-gray-600">
<p className="text-lg font-medium text-gray-500">Aucun VPS configuré</p>
<p className="text-sm mt-1">Cliquez sur <strong className="text-gray-400">Ajouter un VPS</strong> pour commencer.</p>
<button
onClick={() => setShowAddVps(true)}
className="mt-6 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-sm transition-colors"
>
Ajouter un VPS
</button>
</div>
<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 */}
{!loading && vpsList.length > 0 && (
<div className="grid gap-5 lg:grid-cols-2">
{vpsList.map(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={handleDeleteVps}
onDelete={() => setDeleteTarget(vps)}
onUpdate={handleUpdate}
onEdit={setEditVps}
onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
@@ -335,7 +530,7 @@ export default function App() {
{/* Modal mise à jour compose */}
{updateModal && (
<LogsModal
name={`🔄 Update${updateModal.project}`}
name={`Mise à jour${updateModal.project}`}
logs={updateContent}
loading={updateLoading}
onClose={() => setUpdateModal(null)}
@@ -367,6 +562,25 @@ export default function App() {
onClose={() => setStatsModal(null)}
/>
)}
{/* 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>
)
}