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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user