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 { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client'
import Header from './components/Header' import Header from './components/Header'
import VpsCard from './components/VpsCard' import VpsCard from './components/VpsCard'
import LogsModal from './components/LogsModal' import LogsModal from './components/LogsModal'
import AddVpsModal from './components/AddVpsModal' import AddVpsModal from './components/AddVpsModal'
import EditVpsModal from './components/EditVpsModal' import EditVpsModal from './components/EditVpsModal'
import StatsModal from './components/StatsModal' import StatsModal from './components/StatsModal'
import LoginPage from './components/LoginPage' import LoginPage from './components/LoginPage'
import ProfilePage from './components/ProfilePage' import ProfilePage from './components/ProfilePage'
import AdminPage from './components/AdminPage' 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 = [ const INTERVAL_OPTIONS = [
{ label: '10 s', value: 10_000 }, { label: '10 s', value: 10_000 },
@@ -19,7 +25,35 @@ const INTERVAL_OPTIONS = [
{ label: 'Off', value: 0 }, { 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() { export default function App() {
const toast = useToast()
const [token, setTokenState] = useState(() => getToken()) const [token, setTokenState] = useState(() => getToken())
const [username, setUsername] = useState(null) const [username, setUsername] = useState(null)
const [role, setRole] = useState(null) const [role, setRole] = useState(null)
@@ -38,16 +72,46 @@ export default function App() {
const [logsLoading, setLogsLoading] = useState(false) const [logsLoading, setLogsLoading] = useState(false)
const [showAddVps, setShowAddVps] = useState(false) const [showAddVps, setShowAddVps] = useState(false)
const [editVps, setEditVps] = useState(null) // objet vps à éditer 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 [refreshInterval, setRefreshInterval] = useState(() => {
const stored = localStorage.getItem('refreshInterval') const stored = localStorage.getItem('refreshInterval')
return stored ? parseInt(stored, 10) : 30_000 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) => { const handleIntervalChange = (val) => {
setRefreshInterval(val) setRefreshInterval(val)
localStorage.setItem('refreshInterval', 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 [updateModal, setUpdateModal] = useState(null) // { vpsId, project }
const [updateContent, setUpdateContent] = useState('') const [updateContent, setUpdateContent] = useState('')
const [updateLoading, setUpdateLoading] = useState(false) const [updateLoading, setUpdateLoading] = useState(false)
@@ -135,6 +199,28 @@ export default function App() {
} }
}, [token, username]) }, [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) => { const openLogs = async (vpsId, containerId, name) => {
setLogsModal({ vpsId, containerId, name }) setLogsModal({ vpsId, containerId, name })
setLogsLoading(true) setLogsLoading(true)
@@ -150,8 +236,13 @@ export default function App() {
} }
const handleAction = async (vpsId, containerId, action) => { const handleAction = async (vpsId, containerId, action) => {
await containerAction(vpsId, containerId, action) try {
await refresh() 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) => { const handleUpdate = async (vpsId, project) => {
@@ -161,8 +252,10 @@ export default function App() {
try { try {
const data = await composeUpdate(vpsId, project) const data = await composeUpdate(vpsId, project)
setUpdateContent(data.output || '(aucune sortie)') setUpdateContent(data.output || '(aucune sortie)')
toast.success(`Projet « ${project} » mis à jour.`)
} catch (e) { } catch (e) {
setUpdateContent(`Erreur lors de la mise à jour :\n${e.message}`) setUpdateContent(`Erreur lors de la mise à jour :\n${e.message}`)
toast.error(`Mise à jour de « ${project} » échouée.`)
} finally { } finally {
setUpdateLoading(false) setUpdateLoading(false)
await refresh() await refresh()
@@ -176,8 +269,10 @@ export default function App() {
try { try {
await updateAgent(vpsId) 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.') 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) { } catch (e) {
setUpdateContent(`Erreur lors de la mise à jour de l'agent :\n${e.message}`) 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 { } finally {
setUpdateLoading(false) setUpdateLoading(false)
setTimeout(() => refresh(), 8000) setTimeout(() => refresh(), 8000)
@@ -187,26 +282,79 @@ export default function App() {
const handleAddVps = async (formData) => { const handleAddVps = async (formData) => {
await addVps(formData) await addVps(formData)
setShowAddVps(false) setShowAddVps(false)
toast.success(`VPS « ${formData.name} » ajouté.`)
await refresh(true) await refresh(true)
} }
const handleDeleteVps = async (vpsId) => { const handleDeleteVps = async () => {
if (!window.confirm('Supprimer ce VPS de la configuration ?')) return if (!deleteTarget) return
await deleteVps(vpsId) setDeleting(true)
await refresh(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) => { const handleEditVps = async (vpsId, data) => {
await updateVps(vpsId, data) await updateVps(vpsId, data)
setEditVps(null) setEditVps(null)
toast.success('Configuration enregistrée.')
await refresh(true) 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 handleExportVps = async (vpsId) => {
const config = await exportVps(vpsId) try {
await navigator.clipboard.writeText(JSON.stringify(config, null, 2)) 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 // Attente vérification auth
if (!authChecked) return null if (!authChecked) return null
@@ -225,6 +373,7 @@ export default function App() {
const totalOnline = vpsList.filter(v => v.online).length const totalOnline = vpsList.filter(v => v.online).length
const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0) 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 totalRunning = vpsList.reduce((acc, v) => acc + v.containers.filter(c => c.status === 'running').length, 0)
const totalIssues = vpsList.filter(hasIssue).length
// ─── Pages profil / admin ─────────────────────────────────────────────── // ─── Pages profil / admin ───────────────────────────────────────────────
if (page === 'profile') { if (page === 'profile') {
@@ -252,65 +401,111 @@ export default function App() {
intervalOptions={INTERVAL_OPTIONS} 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 */} {/* Barre d'erreur backend */}
{error && ( {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"> <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">
Impossible de joindre le backend : <span className="font-mono">{error}</span> <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> </div>
)} )}
{/* Stats globales */} {/* Stats globales */}
{!loading && vpsList.length > 0 && ( {!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: 'VPS en ligne', value: `${totalOnline}/${vpsList.length}`, color: 'text-emerald-400' },
{ label: 'Conteneurs actifs', value: `${totalRunning}/${totalContainers}`, color: 'text-indigo-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' }, { label: 'Actualisation auto', value: INTERVAL_OPTIONS.find(o => o.value === refreshInterval)?.label ?? 'Off', color: 'text-gray-400' },
].map(({ label, value, color }) => ( ].map(({ label, value, color }) => (
<div key={label} className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3"> <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> <p className="text-xs text-gray-500 mt-0.5">{label}</p>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Chargement initial */} {/* Filtres */}
{loading && ( {!loading && vpsList.length > 0 && (
<div className="text-center py-24 text-gray-600"> <DashboardToolbar
<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"> search={search}
<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" /> onSearchChange={setSearch}
</svg> searchRef={searchRef}
Chargement status={status}
</div> 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 && ( {!loading && vpsList.length === 0 && !error && (
<div className="text-center py-24 text-gray-600"> <EmptyState
<p className="text-lg font-medium text-gray-500">Aucun VPS configuré</p> icon={ServerCrash}
<p className="text-sm mt-1">Cliquez sur <strong className="text-gray-400">Ajouter un VPS</strong> pour commencer.</p> title="Aucun VPS configuré"
<button description="Ajoutez un premier serveur pour suivre ses conteneurs, ses services et sa charge en temps réel."
onClick={() => setShowAddVps(true)} action={
className="mt-6 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-sm transition-colors" <Button variant="primary" size="lg" icon={Plus} onClick={() => setShowAddVps(true)}>
> Ajouter un VPS
Ajouter un VPS </Button>
</button> }
</div> />
)}
{/* 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 */} {/* Grille de VPS */}
{!loading && vpsList.length > 0 && ( {/* `items-start` : chaque carte garde sa hauteur naturelle plutôt que
<div className="grid gap-5 lg:grid-cols-2"> d'être étirée à celle de la plus haute de sa rangée. */}
{vpsList.map(vps => ( {!loading && visibleVps.length > 0 && (
<div className="grid gap-5 lg:grid-cols-2 items-start">
{visibleVps.map(vps => (
<VpsCard <VpsCard
key={vps.id} key={vps.id}
vps={vps} vps={vps}
query={search.trim().toLowerCase()}
onAction={handleAction} onAction={handleAction}
onLogs={openLogs} onLogs={openLogs}
onDelete={handleDeleteVps} onDelete={() => setDeleteTarget(vps)}
onUpdate={handleUpdate} onUpdate={handleUpdate}
onEdit={setEditVps} onEdit={setEditVps}
onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })} onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
@@ -335,7 +530,7 @@ export default function App() {
{/* Modal mise à jour compose */} {/* Modal mise à jour compose */}
{updateModal && ( {updateModal && (
<LogsModal <LogsModal
name={`🔄 Update${updateModal.project}`} name={`Mise à jour${updateModal.project}`}
logs={updateContent} logs={updateContent}
loading={updateLoading} loading={updateLoading}
onClose={() => setUpdateModal(null)} onClose={() => setUpdateModal(null)}
@@ -367,6 +562,25 @@ export default function App() {
onClose={() => setStatsModal(null)} 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> </div>
) )
} }

View File

@@ -1,16 +1,18 @@
import { useState, useEffect } from 'react' import { useState } from 'react'
import { X, Upload } from 'lucide-react' import { Upload, ServerCog } from 'lucide-react'
import TagInput from './TagInput' import TagInput from './TagInput'
import Modal from './ui/Modal'
import { Button, inputClass, PasswordInput } from './ui/controls'
const DEFAULTS = { id: '', name: '', host: '', port: '8001', api_key: '', description: '', tags: [] } const DEFAULTS = { id: '', name: '', host: '', port: '8001', api_key: '', description: '', tags: [] }
const FIELDS = [ const FIELDS = [
{ key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' }, { key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' },
{ key: 'id', label: 'Identifiant unique', placeholder: 'vps-1', required: true, type: 'text' }, { key: 'id', label: 'Identifiant unique', placeholder: 'vps-1', required: true, type: 'text', hint: 'Sert de clé interne — ne peut plus être modifié ensuite.' },
{ key: 'host', label: 'IP ou hostname', placeholder: '192.168.1.10', required: true, type: 'text' }, { key: 'host', label: 'IP ou hostname', placeholder: '192.168.1.10', required: true, type: 'text' },
{ key: 'port', label: 'Port agent', placeholder: '8001', required: true, type: 'number' }, { key: 'port', label: 'Port agent', placeholder: '8001', required: true, type: 'number' },
{ key: 'api_key', label: 'Clé API agent', placeholder: '••••••••', required: true, type: 'password' }, { key: 'api_key', label: 'Clé API agent', placeholder: '••••••••', required: true, type: 'password' },
{ key: 'description', label: 'Description', placeholder: 'Optionnel', required: false, type: 'text' }, { key: 'description', label: 'Description', placeholder: 'Optionnel', required: false, type: 'text' },
] ]
export default function AddVpsModal({ onSave, onClose }) { export default function AddVpsModal({ onSave, onClose }) {
@@ -21,12 +23,6 @@ export default function AddVpsModal({ onSave, onClose }) {
const [json, setJson] = useState('') const [json, setJson] = useState('')
const [jsonError, setJsonError] = useState('') const [jsonError, setJsonError] = useState('')
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.value })) const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.value }))
const handleImportJson = () => { const handleImportJson = () => {
@@ -67,122 +63,114 @@ export default function AddVpsModal({ onSave, onClose }) {
} }
return ( return (
<div <Modal
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm" size="sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }} title="Ajouter un VPS"
> subtitle={mode === 'import' ? 'Importer une configuration exportée' : 'Saisie manuelle'}
<div className="w-full max-w-md bg-gray-900 border border-gray-700 rounded-xl shadow-2xl"> icon={<ServerCog size={16} className="text-indigo-400 flex-shrink-0" />}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700"> onClose={onClose}
<div className="flex items-center gap-2"> headerRight={
<h3 className="font-semibold text-sm">Ajouter un VPS</h3> <div className="flex rounded-lg overflow-hidden border border-gray-700 text-xs" role="group" aria-label="Mode d'ajout">
<div className="flex rounded-lg overflow-hidden border border-gray-700 text-xs"> <button
<button type="button"
type="button" onClick={() => setMode('manual')}
onClick={() => setMode('manual')} aria-pressed={mode === 'manual'}
className={`px-2.5 py-1 transition-colors ${mode === 'manual' ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:bg-gray-800'}`} className={`px-2.5 py-1 transition-colors ${mode === 'manual' ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:bg-gray-800'}`}
> >
Manuel Manuel
</button> </button>
<button <button
type="button" type="button"
onClick={() => setMode('import')} onClick={() => setMode('import')}
className={`px-2.5 py-1 transition-colors flex items-center gap-1 ${mode === 'import' ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:bg-gray-800'}`} aria-pressed={mode === 'import'}
> className={`px-2.5 py-1 transition-colors flex items-center gap-1 ${mode === 'import' ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:bg-gray-800'}`}
<Upload size={11} /> >
Importer <Upload size={11} />
</button> Importer
</div>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-gray-200 transition-colors">
<X size={16} />
</button> </button>
</div> </div>
}
{mode === 'import' ? ( >
<div className="p-4 space-y-3"> {mode === 'import' ? (
<p className="text-xs text-gray-400"> <div className="space-y-3">
Collez le JSON copié via le bouton <strong className="text-gray-300">Exporter</strong> d'une autre instance. <p className="text-xs text-gray-400">
Collez le JSON copié via le bouton <strong className="text-gray-300">Exporter</strong> d'une autre instance.
</p>
<textarea
value={json}
onChange={(e) => setJson(e.target.value)}
rows={10}
placeholder='{"id": "vps-1", "name": "Mon VPS", ...}'
aria-label="Configuration JSON à importer"
className={`${inputClass} text-xs font-mono resize-none`}
/>
{jsonError && (
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2" role="alert">
{jsonError}
</p> </p>
<textarea )}
value={json} <div className="flex gap-2">
onChange={(e) => setJson(e.target.value)} <Button variant="outline" size="lg" className="flex-1" onClick={onClose}>
rows={10} Annuler
placeholder='{"id": "vps-1", "name": "Mon VPS", ...}' </Button>
className="w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-xs font-mono placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors resize-none" <Button variant="primary" size="lg" className="flex-1" icon={Upload} onClick={handleImportJson} disabled={!json.trim()}>
/> Importer
{jsonError && ( </Button>
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2">
{jsonError}
</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 rounded-lg border border-gray-700 hover:bg-gray-800 text-sm transition-colors"
>
Annuler
</button>
<button
type="button"
onClick={handleImportJson}
disabled={!json.trim()}
className="flex-1 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm transition-colors font-medium flex items-center justify-center gap-1.5"
>
<Upload size={13} />
Importer
</button>
</div>
</div> </div>
) : ( </div>
<form onSubmit={handleSubmit} className="p-4 space-y-3"> ) : (
{FIELDS.map(({ key, label, placeholder, required, type }) => ( <form onSubmit={handleSubmit} className="space-y-3">
<div key={key}> {FIELDS.map(({ key, label, placeholder, required, type, hint }) => (
<label className="block text-xs text-gray-400 mb-1"> <div key={key}>
{label} {required && <span className="text-red-400">*</span>} <label htmlFor={`add-${key}`} className="block text-xs text-gray-400 mb-1">
</label> {label} {required && <span className="text-red-400" aria-hidden="true">*</span>}
</label>
{type === 'password' ? (
<PasswordInput
id={`add-${key}`}
value={form[key]}
onChange={set(key)}
placeholder={placeholder}
required={required}
autoComplete="off"
/>
) : (
<input <input
id={`add-${key}`}
type={type} type={type}
value={form[key]} value={form[key]}
onChange={set(key)} onChange={set(key)}
placeholder={placeholder} placeholder={placeholder}
required={required} required={required}
className="w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-sm placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors" className={inputClass}
/> />
</div> )}
))} {hint && <p className="text-[11px] text-gray-600 mt-1">{hint}</p>}
{error && (
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2">
{error}
</p>
)}
<div>
<label className="block text-xs text-gray-400 mb-1">Tags</label>
<TagInput tags={form.tags} onChange={tags => setForm(f => ({ ...f, tags }))} />
<p className="text-xs text-gray-600 mt-1">Entrée ou virgule pour valider</p>
</div> </div>
))}
<div className="flex gap-2 pt-1"> {error && (
<button <p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2" role="alert">
type="button" {error}
onClick={onClose} </p>
className="flex-1 py-2 rounded-lg border border-gray-700 hover:bg-gray-800 text-sm transition-colors" )}
>
Annuler <div>
</button> <label className="block text-xs text-gray-400 mb-1">Tags</label>
<button <TagInput tags={form.tags} onChange={tags => setForm(f => ({ ...f, tags }))} />
type="submit" <p className="text-[11px] text-gray-600 mt-1">Entrée ou virgule pour valider</p>
disabled={saving} </div>
className="flex-1 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm transition-colors font-medium"
> <div className="flex gap-2 pt-1">
{saving ? 'Enregistrement' : 'Ajouter'} <Button type="button" variant="outline" size="lg" className="flex-1" onClick={onClose}>
</button> Annuler
</div> </Button>
</form> <Button type="submit" variant="primary" size="lg" className="flex-1" loading={saving}>
)} {saving ? 'Enregistrement' : 'Ajouter'}
</div> </Button>
</div> </div>
</form>
)}
</Modal>
) )
} }

View File

@@ -1,10 +1,13 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle, Fingerprint, Key, Bell, Send, Eye, EyeOff, Activity } from 'lucide-react' import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, Fingerprint, Key, Bell, Send, Eye, EyeOff, Activity } from 'lucide-react'
import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey, getContainerEvents, testPushoverNotification } from '../api/client' import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey, getContainerEvents, testPushoverNotification } from '../api/client'
import ConfirmDialog from './ui/ConfirmDialog'
import { inputClass } from './ui/controls'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
function ToggleRow({ label, description, enabled, onChange, loading }) { return ( function ToggleRow({ label, description, enabled, onChange, loading }) {
return (
<div className="flex items-center justify-between gap-4 py-3"> <div className="flex items-center justify-between gap-4 py-3">
<div> <div>
<p className="text-sm font-medium text-gray-200">{label}</p> <p className="text-sm font-medium text-gray-200">{label}</p>
@@ -13,6 +16,9 @@ function ToggleRow({ label, description, enabled, onChange, loading }) { return
<button <button
onClick={onChange} onClick={onChange}
disabled={loading} disabled={loading}
role="switch"
aria-checked={enabled}
aria-label={label}
title={enabled ? 'Désactiver' : 'Activer'} title={enabled ? 'Désactiver' : 'Activer'}
className="flex-shrink-0 disabled:opacity-50 transition-opacity" className="flex-shrink-0 disabled:opacity-50 transition-opacity"
> >
@@ -264,8 +270,9 @@ export default function AdminPage({ onBack }) {
const [purgeError, setPurgeError] = useState(null) const [purgeError, setPurgeError] = useState(null)
const [confirmState, setConfirmState] = useState(null) // { table, period, fromTs?, toTs? } const [confirmState, setConfirmState] = useState(null) // { table, period, fromTs?, toTs? }
// Custom range // Custom range
const [customFrom, setCustomFrom] = useState('') const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('') const [customTo, setCustomTo] = useState('')
const [customTable, setCustomTable] = useState('all')
const loadDbInfo = useCallback(async () => { const loadDbInfo = useCallback(async () => {
setDbInfoLoading(true) setDbInfoLoading(true)
@@ -350,7 +357,7 @@ export default function AdminPage({ onBack }) {
</div> </div>
{/* ── Tabs ── */} {/* ── Tabs ── */}
<div className="flex gap-1 mb-8 border-b border-gray-800"> <div className="flex gap-1 mb-8 border-b border-gray-800 overflow-x-auto" role="tablist">
{[ {[
{ key: 'settings', label: 'Paramètres' }, { key: 'settings', label: 'Paramètres' },
{ key: 'notifications', label: 'Notifications' }, { key: 'notifications', label: 'Notifications' },
@@ -361,7 +368,9 @@ export default function AdminPage({ onBack }) {
<button <button
key={tab.key} key={tab.key}
onClick={() => setActiveTab(tab.key)} onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${ role="tab"
aria-selected={activeTab === tab.key}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
activeTab === tab.key activeTab === tab.key
? 'border-indigo-500 text-indigo-400' ? 'border-indigo-500 text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-300' : 'border-transparent text-gray-500 hover:text-gray-300'
@@ -446,18 +455,20 @@ export default function AdminPage({ onBack }) {
{/* Credentials form */} {/* Credentials form */}
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="block text-xs text-gray-400 mb-1.5">App Token</label> <label htmlFor="pushover-token" className="block text-xs text-gray-400 mb-1.5">App Token</label>
<div className="relative"> <div className="relative">
<input <input
id="pushover-token"
type={showToken ? 'text' : 'password'} type={showToken ? 'text' : 'password'}
value={pushoverToken} value={pushoverToken}
onChange={e => setPushoverToken(e.target.value)} onChange={e => setPushoverToken(e.target.value)}
placeholder="aXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" placeholder="aXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 pr-9 text-xs font-mono focus:outline-none focus:border-indigo-500 transition-colors" className={`${inputClass} pr-9 font-mono`}
/> />
<button <button
type="button" type="button"
onClick={() => setShowToken(v => !v)} onClick={() => setShowToken(v => !v)}
aria-label={showToken ? 'Masquer le token' : 'Afficher le token'}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
> >
{showToken ? <EyeOff size={13} /> : <Eye size={13} />} {showToken ? <EyeOff size={13} /> : <Eye size={13} />}
@@ -465,18 +476,20 @@ export default function AdminPage({ onBack }) {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-gray-400 mb-1.5">User Key</label> <label htmlFor="pushover-user-key" className="block text-xs text-gray-400 mb-1.5">User Key</label>
<div className="relative"> <div className="relative">
<input <input
id="pushover-user-key"
type={showUserKey ? 'text' : 'password'} type={showUserKey ? 'text' : 'password'}
value={pushoverUserKey} value={pushoverUserKey}
onChange={e => setPushoverUserKey(e.target.value)} onChange={e => setPushoverUserKey(e.target.value)}
placeholder="uXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" placeholder="uXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 pr-9 text-xs font-mono focus:outline-none focus:border-indigo-500 transition-colors" className={`${inputClass} pr-9 font-mono`}
/> />
<button <button
type="button" type="button"
onClick={() => setShowUserKey(v => !v)} onClick={() => setShowUserKey(v => !v)}
aria-label={showUserKey ? 'Masquer la clé' : 'Afficher la clé'}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
> >
{showUserKey ? <EyeOff size={13} /> : <Eye size={13} />} {showUserKey ? <EyeOff size={13} /> : <Eye size={13} />}
@@ -908,10 +921,11 @@ export default function AdminPage({ onBack }) {
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1">Table</label> <label htmlFor="custom-table" className="block text-xs text-gray-500 mb-1">Table</label>
<select <select
id="custom-table" id="custom-table"
defaultValue="all" value={customTable}
onChange={e => setCustomTable(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-indigo-500 transition-colors" className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-indigo-500 transition-colors"
> >
<option value="all">Toutes</option> <option value="all">Toutes</option>
@@ -922,10 +936,9 @@ export default function AdminPage({ onBack }) {
<button <button
disabled={!customFrom || !customTo || purgeLoading} disabled={!customFrom || !customTo || purgeLoading}
onClick={() => { onClick={() => {
const tbl = document.getElementById('custom-table').value
const fromTs = Math.floor(new Date(customFrom).getTime() / 1000) const fromTs = Math.floor(new Date(customFrom).getTime() / 1000)
const toTs = Math.floor(new Date(customTo).getTime() / 1000) const toTs = Math.floor(new Date(customTo).getTime() / 1000)
requestPurge(tbl, 'custom', { fromTs, toTs }) requestPurge(customTable, 'custom', { fromTs, toTs })
}} }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-red-950/50 hover:bg-red-900/60 text-red-400 border border-red-800/50 transition-colors disabled:opacity-50" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-red-950/50 hover:bg-red-900/60 text-red-400 border border-red-800/50 transition-colors disabled:opacity-50"
> >
@@ -939,38 +952,22 @@ export default function AdminPage({ onBack }) {
{/* ── Modale de confirmation de purge ── */} {/* ── Modale de confirmation de purge ── */}
{confirmState && ( {confirmState && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <ConfirmDialog
<div className="bg-gray-900 border border-gray-700 rounded-2xl p-6 max-w-sm w-full shadow-2xl"> danger
<div className="flex items-center gap-3 mb-4"> title="Confirmer la suppression"
<div className="p-2 rounded-xl bg-red-500/15"> message={
<AlertTriangle size={18} className="text-red-400" /> <>
</div>
<h3 className="text-sm font-semibold">Confirmer la suppression</h3>
</div>
<p className="text-xs text-gray-400 mb-6">
Vous êtes sur le point de supprimer{' '} Vous êtes sur le point de supprimer{' '}
<span className="text-gray-200 font-medium">{periodLabel[confirmState.period]}</span>{' '} <span className="text-gray-200 font-medium">{periodLabel[confirmState.period]}</span>{' '}
dans{' '} dans <span className="text-gray-200 font-medium">{tableLabel[confirmState.table]}</span>.
<span className="text-gray-200 font-medium">{tableLabel[confirmState.table]}</span>.
Cette action est irréversible. Cette action est irréversible.
</p> </>
<div className="flex gap-3 justify-end"> }
<button confirmLabel={purgeLoading ? 'Suppression…' : 'Supprimer'}
onClick={() => setConfirmState(null)} loading={purgeLoading}
className="px-4 py-2 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 transition-colors" onConfirm={confirmPurge}
> onCancel={() => setConfirmState(null)}
Annuler />
</button>
<button
onClick={confirmPurge}
disabled={purgeLoading}
className="px-4 py-2 rounded-lg text-xs bg-red-600 hover:bg-red-700 disabled:opacity-50 transition-colors text-white font-medium"
>
{purgeLoading ? 'Suppression…' : 'Supprimer'}
</button>
</div>
</div>
</div>
)} )}
</div> </div>

View File

@@ -1,11 +1,11 @@
import { useState } from 'react' import { useMemo, useState } from 'react'
import { Play, Square, RotateCcw, FileText, Loader2, Heart } from 'lucide-react' import { Play, Square, RotateCcw, FileText, Loader2, Heart } from 'lucide-react'
import StatusBadge from './StatusBadge' import StatusBadge from './StatusBadge'
const HEALTH_STYLES = { const HEALTH_STYLES = {
healthy: { dot: 'bg-emerald-400', text: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', label: 'healthy' }, healthy: { text: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', label: 'healthy' },
unhealthy: { dot: 'bg-red-400', text: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20', label: 'unhealthy' }, unhealthy: { text: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20', label: 'unhealthy' },
starting: { dot: 'bg-yellow-400 animate-pulse', text: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20', label: 'starting' }, starting: { text: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20', label: 'starting' },
} }
function HealthBadge({ health }) { function HealthBadge({ health }) {
@@ -19,44 +19,79 @@ function HealthBadge({ health }) {
) )
} }
/** Ports hôte exposés, dédoublonnés et triés. */
function hostPorts(ports) {
const found = new Set()
Object.values(ports ?? {}).forEach(bindings => {
(bindings ?? []).forEach(b => { if (b?.HostPort) found.add(b.HostPort) })
})
return [...found].sort((a, b) => Number(a) - Number(b))
}
export default function ContainerRow({ container, onAction, onLogs }) { export default function ContainerRow({ container, onAction, onLogs }) {
const [pending, setPending] = useState(null) const [pending, setPending] = useState(null)
const isRunning = container.status === 'running' const isRunning = container.status === 'running'
const ports = useMemo(() => hostPorts(container.ports), [container.ports])
const handle = async (action) => { const handle = async (action) => {
setPending(action) setPending(action)
try { await onAction(action) } finally { setPending(null) } try { await onAction(action) } finally { setPending(null) }
} }
const createdLabel = container.created
? `Créé le ${new Date(container.created).toLocaleString('fr-FR')}`
: undefined
return ( return (
<div className="flex items-center justify-between px-4 py-2.5 hover:bg-gray-800/40 transition-colors group"> <div className="flex items-center justify-between px-4 py-2.5 hover:bg-gray-800/40 transition-colors group">
<div className="min-w-0 flex-1 pr-3"> <div className="min-w-0 flex-1 pr-3">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate max-w-[160px]">{container.name}</span> <span className="text-sm font-medium truncate max-w-[160px]" title={createdLabel}>
<StatusBadge status={container.status} /> <HealthBadge health={container.health} /> {container.compose_project && ( {container.name}
</span>
<StatusBadge status={container.status} />
<HealthBadge health={container.health} />
{container.compose_project && (
<span className="hidden sm:inline text-xs text-gray-600 bg-gray-800 px-1.5 py-0.5 rounded"> <span className="hidden sm:inline text-xs text-gray-600 bg-gray-800 px-1.5 py-0.5 rounded">
{container.compose_project} {container.compose_project}
</span> </span>
)} )}
{ports.slice(0, 3).map(port => (
<span
key={port}
className="text-[10px] font-mono text-sky-300/80 bg-sky-500/10 border border-sky-500/20 px-1.5 py-0.5 rounded"
title={`Port hôte ${port}`}
>
:{port}
</span>
))}
{ports.length > 3 && (
<span className="text-[10px] text-gray-600" title={ports.join(', ')}>
+{ports.length - 3}
</span>
)}
</div> </div>
<p className="text-xs text-gray-500 truncate mt-0.5">{container.image}</p> <p className="text-xs text-gray-500 truncate mt-0.5" title={container.image}>{container.image}</p>
</div> </div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"> {/* Toujours visibles sur mobile (pas de survol au tactile), révélées au
survol ou au focus clavier sur grand écran. */}
<div className="flex items-center gap-0.5 flex-shrink-0 transition-opacity
sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100">
{!isRunning && ( {!isRunning && (
<ActionBtn title="Démarrer" onClick={() => handle('start')} loading={pending === 'start'}> <ActionBtn title={`Démarrer ${container.name}`} onClick={() => handle('start')} loading={pending === 'start'}>
<Play size={13} /> <Play size={13} />
</ActionBtn> </ActionBtn>
)} )}
{isRunning && ( {isRunning && (
<ActionBtn title="Arrêter" onClick={() => handle('stop')} loading={pending === 'stop'} danger> <ActionBtn title={`Arrêter ${container.name}`} onClick={() => handle('stop')} loading={pending === 'stop'} danger>
<Square size={13} /> <Square size={13} />
</ActionBtn> </ActionBtn>
)} )}
<ActionBtn title="Redémarrer" onClick={() => handle('restart')} loading={pending === 'restart'}> <ActionBtn title={`Redémarrer ${container.name}`} onClick={() => handle('restart')} loading={pending === 'restart'}>
<RotateCcw size={13} /> <RotateCcw size={13} />
</ActionBtn> </ActionBtn>
<ActionBtn title="Logs" onClick={onLogs}> <ActionBtn title={`Logs de ${container.name}`} onClick={onLogs}>
<FileText size={13} /> <FileText size={13} />
</ActionBtn> </ActionBtn>
</div> </div>
@@ -69,6 +104,7 @@ function ActionBtn({ children, onClick, title, danger = false, loading = false }
<button <button
onClick={onClick} onClick={onClick}
title={title} title={title}
aria-label={title}
disabled={loading} disabled={loading}
className={`p-1.5 rounded transition-colors disabled:opacity-40 ${ className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
danger danger

View File

@@ -0,0 +1,117 @@
import { Search, X, ArrowDownUp } from 'lucide-react'
import { tagColor } from './TagInput'
export const STATUS_FILTERS = [
{ value: 'all', label: 'Tous' },
{ value: 'online', label: 'En ligne' },
{ value: 'offline', label: 'Hors ligne' },
{ value: 'issues', label: 'Problèmes' },
]
export const SORT_OPTIONS = [
{ value: 'name', label: 'Nom' },
{ value: 'status', label: 'État' },
{ value: 'cpu', label: 'CPU' },
{ value: 'ram', label: 'RAM' },
{ value: 'containers', label: 'Conteneurs' },
]
export default function DashboardToolbar({
search, onSearchChange, searchRef,
status, onStatusChange,
tags, activeTags, onToggleTag,
sort, onSortChange,
shown, total,
}) {
const filtering = search.trim() !== '' || status !== 'all' || activeTags.length > 0
return (
<div className="mb-6 flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
{/* Recherche */}
<div className="relative flex-1 min-w-[200px]">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" />
<input
ref={searchRef}
type="search"
value={search}
onChange={e => onSearchChange(e.target.value)}
placeholder="Rechercher un VPS, un conteneur, une image… ( / )"
aria-label="Rechercher un VPS ou un conteneur"
className="w-full bg-gray-900 border border-gray-800 rounded-xl pl-9 pr-9 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors"
/>
{search && (
<button
onClick={() => onSearchChange('')}
aria-label="Effacer la recherche"
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-gray-500 hover:text-gray-200 transition-colors"
>
<X size={14} />
</button>
)}
</div>
{/* Filtre d'état */}
<div className="flex rounded-xl border border-gray-800 bg-gray-900 overflow-hidden" role="group" aria-label="Filtrer par état">
{STATUS_FILTERS.map(opt => (
<button
key={opt.value}
onClick={() => onStatusChange(opt.value)}
aria-pressed={status === opt.value}
className={`px-3 py-2 text-xs font-medium transition-colors ${
status === opt.value
? 'bg-indigo-600 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Tri */}
<div className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl bg-gray-900 border border-gray-800">
<ArrowDownUp size={13} className="text-gray-500 flex-shrink-0" />
<select
value={sort}
onChange={e => onSortChange(e.target.value)}
aria-label="Trier les VPS"
className="bg-transparent text-xs text-gray-300 outline-none cursor-pointer"
>
{SORT_OPTIONS.map(o => (
<option key={o.value} value={o.value}>Trier : {o.label}</option>
))}
</select>
</div>
</div>
{/* Tags + compteur */}
{(tags.length > 0 || filtering) && (
<div className="flex flex-wrap items-center gap-1.5">
{tags.map(tag => {
const active = activeTags.includes(tag)
return (
<button
key={tag}
onClick={() => onToggleTag(tag)}
aria-pressed={active}
className={`px-2 py-0.5 rounded-full text-xs font-medium border transition-all ${
active
? tagColor(tag)
: 'bg-gray-900 border-gray-800 text-gray-500 hover:text-gray-300 hover:border-gray-700'
}`}
>
{tag}
</button>
)
})}
{filtering && (
<span className="text-xs text-gray-500 ml-auto">
{shown} / {total} VPS affiché{shown !== 1 ? 's' : ''}
</span>
)}
</div>
)}
</div>
)
}

View File

@@ -1,9 +1,11 @@
import { useState, useEffect } from 'react' import { useState } from 'react'
import { X } from 'lucide-react' import { Pencil } from 'lucide-react'
import TagInput from './TagInput' import TagInput from './TagInput'
import Modal from './ui/Modal'
import { Button, inputClass, PasswordInput } from './ui/controls'
const FIELDS = [ const FIELDS = [
{ key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' }, { key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' },
{ key: 'host', label: 'IP ou hostname', placeholder: '192.168.1.10', required: true, type: 'text' }, { key: 'host', label: 'IP ou hostname', placeholder: '192.168.1.10', required: true, type: 'text' },
{ key: 'port', label: 'Port agent', placeholder: '8001', required: true, type: 'number' }, { key: 'port', label: 'Port agent', placeholder: '8001', required: true, type: 'number' },
{ key: 'api_key', label: 'Clé API agent', placeholder: 'Laisser vide pour conserver la clé actuelle', required: false, type: 'password' }, { key: 'api_key', label: 'Clé API agent', placeholder: 'Laisser vide pour conserver la clé actuelle', required: false, type: 'password' },
@@ -11,7 +13,7 @@ const FIELDS = [
] ]
export default function EditVpsModal({ vps, onSave, onClose }) { export default function EditVpsModal({ vps, onSave, onClose }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
name: vps.name ?? '', name: vps.name ?? '',
host: vps.host ?? '', host: vps.host ?? '',
port: String(vps.port ?? 8001), port: String(vps.port ?? 8001),
@@ -22,12 +24,6 @@ export default function EditVpsModal({ vps, onSave, onClose }) {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.value })) const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.value }))
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
@@ -43,68 +39,63 @@ export default function EditVpsModal({ vps, onSave, onClose }) {
} }
return ( return (
<div <Modal
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm" size="sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }} title="Modifier le VPS"
subtitle={vps.id}
icon={<Pencil size={16} className="text-indigo-400 flex-shrink-0" />}
onClose={onClose}
> >
<div className="w-full max-w-md bg-gray-900 border border-gray-700 rounded-xl shadow-2xl"> <form onSubmit={handleSubmit} className="space-y-3">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700"> {FIELDS.map(({ key, label, placeholder, required, type }) => (
<div> <div key={key}>
<h3 className="font-semibold text-sm">Modifier le VPS</h3> <label htmlFor={`edit-${key}`} className="block text-xs text-gray-400 mb-1">
<p className="text-xs text-gray-500 mt-0.5 font-mono">{vps.id}</p> {label} {required && <span className="text-red-400" aria-hidden="true">*</span>}
</div> </label>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-gray-200 transition-colors"> {type === 'password' ? (
<X size={16} /> <PasswordInput
</button> id={`edit-${key}`}
</div> value={form[key]}
onChange={set(key)}
<form onSubmit={handleSubmit} className="p-4 space-y-3"> placeholder={placeholder}
{FIELDS.map(({ key, label, placeholder, required, type }) => ( required={required}
<div key={key}> autoComplete="off"
<label className="block text-xs text-gray-400 mb-1"> />
{label} {required && <span className="text-red-400">*</span>} ) : (
</label>
<input <input
id={`edit-${key}`}
type={type} type={type}
value={form[key]} value={form[key]}
onChange={set(key)} onChange={set(key)}
placeholder={placeholder} placeholder={placeholder}
required={required} required={required}
className="w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-sm placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors" className={inputClass}
/> />
</div> )}
))}
{error && (
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2">
{error}
</p>
)}
<div>
<label className="block text-xs text-gray-400 mb-1">Tags</label>
<TagInput tags={form.tags} onChange={tags => setForm(f => ({ ...f, tags }))} />
<p className="text-xs text-gray-600 mt-1">Entrée ou virgule pour valider</p>
</div> </div>
))}
<div className="flex gap-2 pt-1"> {error && (
<button <p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2" role="alert">
type="button" {error}
onClick={onClose} </p>
className="flex-1 py-2 rounded-lg border border-gray-700 hover:bg-gray-800 text-sm transition-colors" )}
>
Annuler <div>
</button> <label className="block text-xs text-gray-400 mb-1">Tags</label>
<button <TagInput tags={form.tags} onChange={tags => setForm(f => ({ ...f, tags }))} />
type="submit" <p className="text-[11px] text-gray-600 mt-1">Entrée ou virgule pour valider</p>
disabled={saving} </div>
className="flex-1 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm transition-colors font-medium"
> <div className="flex gap-2 pt-1">
{saving ? 'Enregistrement…' : 'Enregistrer'} <Button type="button" variant="outline" size="lg" className="flex-1" onClick={onClose}>
</button> Annuler
</div> </Button>
</form> <Button type="submit" variant="primary" size="lg" className="flex-1" loading={saving}>
</div> {saving ? 'Enregistrement…' : 'Enregistrer'}
</div> </Button>
</div>
</form>
</Modal>
) )
} }

View File

@@ -1,29 +1,44 @@
import { Monitor, LogOut, Timer, User, ShieldCheck } from 'lucide-react' import { useEffect, useState } from 'react'
import { Monitor, LogOut, Timer, User, ShieldCheck, RefreshCw, Plus } from 'lucide-react'
import { formatRelative } from '../lib/format'
export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, username, role, onLogout, onProfile, onAdmin, refreshInterval, onIntervalChange, intervalOptions }) { export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, username, role, onLogout, onProfile, onAdmin, refreshInterval, onIntervalChange, intervalOptions }) {
// Force un re-rendu périodique pour garder « il y a X » à jour
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 10_000)
return () => clearInterval(id)
}, [])
return ( return (
<header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm"> <header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-900/80 backdrop-blur-md">
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between"> <div className="max-w-7xl mx-auto px-4 py-2 flex items-center gap-2">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5 min-w-0">
<div className="p-1.5 rounded-lg bg-indigo-500/15"> <div className="p-1.5 rounded-lg bg-indigo-500/15 flex-shrink-0">
<Monitor size={18} className="text-indigo-400" /> <Monitor size={18} className="text-indigo-400" />
</div> </div>
<span className="font-semibold">VPS Monitor</span> <span className="font-semibold truncate">VPS Monitor</span>
{lastUpdate && ( {lastUpdate && (
<span className="hidden sm:block text-xs text-gray-500 ml-2"> <span
· mis à jour {lastUpdate.toLocaleTimeString('fr-FR')} className="hidden md:block text-xs text-gray-500 ml-1 whitespace-nowrap"
title={`Dernière actualisation à ${lastUpdate.toLocaleTimeString('fr-FR')}`}
>
· {formatRelative(lastUpdate)}
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex-1" />
<div className="flex items-center gap-1.5 sm:gap-2 flex-shrink-0">
{/* Sélecteur d'intervalle */} {/* Sélecteur d'intervalle */}
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-gray-800 text-sm text-gray-400"> <div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-gray-800 text-sm text-gray-400">
<Timer size={13} className="flex-shrink-0" /> <Timer size={13} className="flex-shrink-0" />
<select <select
value={refreshInterval} value={refreshInterval}
onChange={e => onIntervalChange(Number(e.target.value))} onChange={e => onIntervalChange(Number(e.target.value))}
className="bg-transparent text-gray-300 text-xs outline-none cursor-pointer" className="bg-transparent text-gray-300 text-xs outline-none cursor-pointer"
aria-label="Intervalle d'actualisation automatique"
title="Intervalle d'actualisation automatique" title="Intervalle d'actualisation automatique"
> >
{intervalOptions.map(o => ( {intervalOptions.map(o => (
@@ -35,26 +50,22 @@ export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, us
<button <button
onClick={onRefresh} onClick={onRefresh}
disabled={refreshing} disabled={refreshing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors" aria-label="Actualiser maintenant (raccourci : R)"
title="Actualiser maintenant (R)"
className="flex items-center gap-1.5 px-2.5 sm:px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
> >
<svg <RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} />
className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} <span className="hidden lg:inline">Actualiser</span>
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>
Actualiser
</button> </button>
<button <button
onClick={onAddVps} onClick={onAddVps}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-indigo-600 hover:bg-indigo-500 transition-colors" aria-label="Ajouter un VPS"
title="Ajouter un VPS"
className="flex items-center gap-1.5 px-2.5 sm:px-3 py-1.5 rounded-lg text-sm bg-indigo-600 hover:bg-indigo-500 transition-colors font-medium"
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24"> <Plus size={15} />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /> <span className="hidden lg:inline">Ajouter un VPS</span>
</svg>
Ajouter un VPS
</button> </button>
{username && ( {username && (
@@ -62,25 +73,28 @@ export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, us
{role === 'admin' && ( {role === 'admin' && (
<button <button
onClick={onAdmin} onClick={onAdmin}
aria-label="Administration"
title="Administration" title="Administration"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 transition-colors text-violet-400 hover:text-violet-300" className="flex items-center gap-1.5 px-2.5 sm:px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 transition-colors text-violet-400 hover:text-violet-300"
> >
<ShieldCheck size={14} /> <ShieldCheck size={14} />
<span className="hidden sm:inline">Admin</span> <span className="hidden lg:inline">Admin</span>
</button> </button>
)} )}
<button <button
onClick={onProfile} onClick={onProfile}
aria-label={`Profil de ${username}`}
title={`Profil (${username})`} title={`Profil (${username})`}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 transition-colors text-gray-400 hover:text-gray-200" className="flex items-center gap-1.5 px-2.5 sm:px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 transition-colors text-gray-400 hover:text-gray-200 max-w-[10rem]"
> >
<User size={14} /> <User size={14} className="flex-shrink-0" />
<span className="hidden sm:inline">{username}</span> <span className="hidden sm:inline truncate">{username}</span>
</button> </button>
<button <button
onClick={onLogout} onClick={onLogout}
aria-label="Se déconnecter"
title="Déconnexion" title="Déconnexion"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 transition-colors text-gray-400 hover:text-gray-200" className="flex items-center px-2.5 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 transition-colors text-gray-400 hover:text-gray-200"
> >
<LogOut size={14} /> <LogOut size={14} />
</button> </button>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Monitor, Fingerprint } from 'lucide-react' import { Monitor, Fingerprint } from 'lucide-react'
import { login, register, loginWithPasskey } from '../api/client' import { login, register, loginWithPasskey } from '../api/client'
import { inputClass, PasswordInput } from './ui/controls'
export default function LoginPage({ isFirstUser, passkeyEnabled, onAuthenticated }) { export default function LoginPage({ isFirstUser, passkeyEnabled, onAuthenticated }) {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
@@ -90,47 +91,46 @@ export default function LoginPage({ isFirstUser, passkeyEnabled, onAuthenticated
)} )}
{error && ( {error && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300"> <div role="alert" className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300">
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-xs text-gray-400 mb-1.5">Nom d'utilisateur</label> <label htmlFor="login-username" className="block text-xs text-gray-400 mb-1.5">Nom d'utilisateur</label>
<input <input
id="login-username"
type="text" type="text"
required required
autoFocus autoFocus
autoComplete="username" autoComplete="username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" className={inputClass}
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-400 mb-1.5">Mot de passe</label> <label htmlFor="login-password" className="block text-xs text-gray-400 mb-1.5">Mot de passe</label>
<input <PasswordInput
type="password" id="login-password"
required required
autoComplete={isRegister ? 'new-password' : 'current-password'} autoComplete={isRegister ? 'new-password' : 'current-password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/> />
</div> </div>
{isRegister && ( {isRegister && (
<div> <div>
<label className="block text-xs text-gray-400 mb-1.5">Confirmer le mot de passe</label> <label htmlFor="login-password2" className="block text-xs text-gray-400 mb-1.5">Confirmer le mot de passe</label>
<input <PasswordInput
type="password" id="login-password2"
required required
autoComplete="new-password" autoComplete="new-password"
value={password2} value={password2}
onChange={(e) => setPassword2(e.target.value)} onChange={(e) => setPassword2(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/> />
</div> </div>
)} )}

View File

@@ -1,79 +1,122 @@
import { useEffect, useRef } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { X, Download } from 'lucide-react' import { Download, FileText, Search, X, Copy, Check, WrapText } from 'lucide-react'
import Modal from './ui/Modal'
import { copyText, downloadText } from '../lib/clipboard'
export default function LogsModal({ name, logs, loading, onClose }) { export default function LogsModal({ name, logs, loading, onClose }) {
const bottomRef = useRef(null) const bottomRef = useRef(null)
const [filter, setFilter] = useState('')
const [wrap, setWrap] = useState(true)
const [copied, setCopied] = useState(false)
const lines = useMemo(() => (logs ?? '').split('\n'), [logs])
const shown = useMemo(() => {
const q = filter.trim().toLowerCase()
return q ? lines.filter(l => l.toLowerCase().includes(q)) : lines
}, [lines, filter])
// Défilement en bas à l'arrivée des logs (pas pendant un filtrage)
useEffect(() => { useEffect(() => {
if (!loading && bottomRef.current) { if (!loading && !filter && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' }) bottomRef.current.scrollIntoView({ behavior: 'smooth' })
} }
}, [logs, loading]) }, [logs, loading, filter])
const handleDownload = () => { const handleDownload = () => {
const blob = new Blob([logs], { type: 'text/plain' }) downloadText(`${name.replace(/[^a-z0-9]/gi, '_')}.log`, logs)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-z0-9]/gi, '_')}.log`
a.click()
URL.revokeObjectURL(url)
} }
// Fermeture sur Échap const handleCopy = async () => {
useEffect(() => { if (await copyText(shown.join('\n'))) {
const handler = (e) => { if (e.key === 'Escape') onClose() } setCopied(true)
window.addEventListener('keydown', handler) setTimeout(() => setCopied(false), 2000)
return () => window.removeEventListener('keydown', handler) }
}, [onClose]) }
const hasLogs = !loading && logs
return ( return (
<div <Modal
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm" size="xl"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }} title={name}
subtitle={hasLogs
? `${lines.length} ligne${lines.length > 1 ? 's' : ''}${filter ? ` · ${shown.length} correspondance${shown.length > 1 ? 's' : ''}` : ''}`
: undefined}
icon={<FileText size={16} className="text-indigo-400 flex-shrink-0" />}
onClose={onClose}
bodyClassName="flex flex-col overflow-hidden p-0"
panelClassName="max-h-[88vh]"
headerRight={hasLogs && (
<>
<button
onClick={() => setWrap(w => !w)}
aria-pressed={wrap}
title={wrap ? 'Désactiver le retour à la ligne' : 'Activer le retour à la ligne'}
aria-label="Retour à la ligne automatique"
className={`p-1.5 rounded-lg transition-colors ${wrap ? 'bg-gray-800 text-indigo-400' : 'text-gray-500 hover:text-gray-200 hover:bg-gray-800'}`}
>
<WrapText size={14} />
</button>
<button
onClick={handleCopy}
title="Copier les logs affichés"
aria-label="Copier les logs affichés"
className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-gray-800 transition-colors"
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
</button>
<button
onClick={handleDownload}
title="Télécharger le fichier de logs"
aria-label="Télécharger le fichier de logs"
className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-gray-800 transition-colors"
>
<Download size={14} />
</button>
</>
)}
> >
<div className="w-full max-w-4xl bg-gray-900 border border-gray-700 rounded-xl flex flex-col max-h-[85vh] shadow-2xl"> {/* Filtre */}
{/* Header */} {hasLogs && (
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700 flex-shrink-0"> <div className="relative px-4 py-2 border-b border-gray-800 flex-shrink-0">
<h3 className="font-mono text-sm text-gray-300 truncate">📄 {name}</h3> <Search size={13} className="absolute left-7 top-1/2 -translate-y-1/2 text-gray-600 pointer-events-none" />
<div className="flex items-center gap-2"> <input
{logs && ( type="search"
<button value={filter}
onClick={handleDownload} onChange={e => setFilter(e.target.value)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors" placeholder="Filtrer les lignes…"
> aria-label="Filtrer les lignes de log"
<Download size={12} /> className="w-full bg-gray-800/60 border border-gray-700 rounded-lg pl-8 pr-8 py-1.5 text-xs font-mono placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors"
Télécharger />
</button> {filter && (
)}
<button <button
onClick={onClose} onClick={() => setFilter('')}
className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-gray-200 transition-colors" aria-label="Effacer le filtre"
className="absolute right-7 top-1/2 -translate-y-1/2 p-0.5 rounded text-gray-500 hover:text-gray-200 transition-colors"
> >
<X size={16} /> <X size={13} />
</button> </button>
</div>
</div>
{/* Logs */}
<div className="flex-1 overflow-auto bg-gray-950 rounded-b-xl p-4">
{loading ? (
<div className="flex items-center gap-2 text-gray-500 text-sm">
<svg className="w-4 h-4 animate-spin" 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 des logs
</div>
) : (
<>
<pre className="text-xs font-mono text-gray-300 whitespace-pre-wrap leading-5 break-all">
{logs || '(aucun log disponible)'}
</pre>
<div ref={bottomRef} />
</>
)} )}
</div> </div>
)}
{/* Contenu */}
<div className="flex-1 overflow-auto bg-gray-950 p-4">
{loading ? (
<div className="space-y-2" aria-busy="true" aria-label="Chargement des logs">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="skeleton h-3 rounded" style={{ width: `${45 + ((i * 17) % 50)}%` }} />
))}
</div>
) : (
<>
<pre className={`text-xs font-mono text-gray-300 leading-5 ${wrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
{shown.length > 0 ? shown.join('\n') : (filter ? '(aucune ligne ne correspond au filtre)' : '(aucun log disponible)')}
</pre>
<div ref={bottomRef} />
</>
)}
</div> </div>
</div> </Modal>
) )
} }

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { KeyRound, ArrowLeft, Check, Fingerprint, Plus, Trash2, Key } from 'lucide-react' import { KeyRound, ArrowLeft, Check, Fingerprint, Plus, Trash2, Key } from 'lucide-react'
import { changePassword, getMyPasskeys, deleteMyPasskey, registerPasskey } from '../api/client' import { changePassword, getMyPasskeys, deleteMyPasskey, registerPasskey } from '../api/client'
import { inputClass, PasswordInput } from './ui/controls'
export default function ProfilePage({ username, onBack }) { export default function ProfilePage({ username, onBack }) {
const [oldPassword, setOldPassword] = useState('') const [oldPassword, setOldPassword] = useState('')
@@ -136,45 +137,42 @@ export default function ProfilePage({ username, onBack }) {
)} )}
{error && ( {error && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-4"> <div role="alert" className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-4">
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-xs text-gray-400 mb-1.5">Mot de passe actuel</label> <label htmlFor="current-password" className="block text-xs text-gray-400 mb-1.5">Mot de passe actuel</label>
<input <PasswordInput
type="password" id="current-password"
required required
autoComplete="current-password" autoComplete="current-password"
value={oldPassword} value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)} onChange={(e) => setOldPassword(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-400 mb-1.5">Nouveau mot de passe</label> <label htmlFor="new-password" className="block text-xs text-gray-400 mb-1.5">Nouveau mot de passe</label>
<input <PasswordInput
type="password" id="new-password"
required required
autoComplete="new-password" autoComplete="new-password"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-400 mb-1.5">Confirmer le nouveau mot de passe</label> <label htmlFor="new-password-2" className="block text-xs text-gray-400 mb-1.5">Confirmer le nouveau mot de passe</label>
<input <PasswordInput
type="password" id="new-password-2"
required required
autoComplete="new-password" autoComplete="new-password"
value={newPassword2} value={newPassword2}
onChange={(e) => setNewPassword2(e.target.value)} onChange={(e) => setNewPassword2(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/> />
</div> </div>
@@ -247,10 +245,11 @@ export default function ProfilePage({ username, onBack }) {
<input <input
type="text" type="text"
placeholder="Nom de l'appareil (ex : MacBook)" placeholder="Nom de l'appareil (ex : MacBook)"
aria-label="Nom de l'appareil pour la nouvelle passkey"
value={addName} value={addName}
onChange={e => setAddName(e.target.value)} onChange={e => setAddName(e.target.value)}
disabled={adding} disabled={adding}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors disabled:opacity-50" className={`${inputClass} flex-1`}
/> />
<button <button
type="submit" type="submit"

View File

@@ -1,29 +1,9 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback, useMemo } from 'react'
import { X, BarChart2, Cpu, MemoryStick, ArrowUp, ArrowDown, TrendingUp } from 'lucide-react' import { BarChart2, Cpu, MemoryStick, ArrowUp, ArrowDown, TrendingUp } from 'lucide-react'
import { fetchVpsStats } from '../api/client' import { fetchVpsStats } from '../api/client'
import Modal from './ui/Modal'
// ─── Formatters ─────────────────────────────────────────────────────────────── import { Skeleton } from './ui/controls'
import { formatBps, formatBytes, formatRam, formatClock } from '../lib/format'
function fmtBytes(bytes) {
if (!bytes || bytes < 1) return '0 B'
if (bytes < 1024) return `${bytes.toFixed(0)} B`
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
return `${(bytes / 1024 ** 3).toFixed(2)} GB`
}
function fmtBps(bps) {
if (bps === undefined || bps === null) return '—'
if (bps < 1024) return `${bps.toFixed(0)} B/s`
if (bps < 1024 ** 2) return `${(bps / 1024).toFixed(1)} KB/s`
return `${(bps / 1024 ** 2).toFixed(2)} MB/s`
}
function fmtRam(bytes) {
if (!bytes) return '—'
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(0)} MB`
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
}
function avg(arr) { function avg(arr) {
if (!arr.length) return '—' if (!arr.length) return '—'
@@ -48,26 +28,36 @@ function autoRange(data, absMin = 0, absMax = 100, minRange = 8) {
// ─── SVG Sparkline ──────────────────────────────────────────────────────────── // ─── SVG Sparkline ────────────────────────────────────────────────────────────
function Sparkline({ data, min = 0, max = 100, color, fill, height = 60 }) { const W = 500
if (!data.length) { const P = 3
return (
<div function Sparkline({ data, timestamps = [], min = 0, max = 100, color, fill, height = 60 }) {
className="flex items-center justify-center text-gray-700 text-xs italic" // Les hooks doivent précéder tout retour anticipé — sinon leur nombre change
style={{ height }} // entre deux rendus dès que la série passe de vide à non vide.
> const [tooltip, setTooltip] = useState(null)
En attente de données
</div>
)
}
const W = 500
const H = height const H = height
const P = 3
const range = (max - min) || 1 const range = (max - min) || 1
const sx = (i) => P + (i / Math.max(data.length - 1, 1)) * (W - P * 2) const sx = (i) => P + (i / Math.max(data.length - 1, 1)) * (W - P * 2)
const sy = (v) => H - P - (Math.max(0, Math.min(1, (v - min) / range)) * (H - P * 2)) const sy = (v) => H - P - (Math.max(0, Math.min(1, (v - min) / range)) * (H - P * 2))
const handleMouseMove = (e) => {
if (!data.length) return
const rect = e.currentTarget.getBoundingClientRect()
const xRatio = (e.clientX - rect.left) / rect.width
const idx = Math.min(data.length - 1, Math.max(0, Math.round(xRatio * (data.length - 1))))
setTooltip({ idx, value: data[idx] })
}
if (!data.length) {
return (
<div className="flex items-center justify-center text-gray-700 text-xs italic" style={{ height }}>
En attente de données
</div>
)
}
const linePts = data.map((v, i) => `${sx(i).toFixed(1)},${sy(v).toFixed(1)}`).join(' ') const linePts = data.map((v, i) => `${sx(i).toFixed(1)},${sy(v).toFixed(1)}`).join(' ')
const areaPts = [ const areaPts = [
`${sx(0).toFixed(1)},${(H - P).toFixed(1)}`, `${sx(0).toFixed(1)},${(H - P).toFixed(1)}`,
@@ -75,16 +65,6 @@ function Sparkline({ data, min = 0, max = 100, color, fill, height = 60 }) {
`${sx(data.length - 1).toFixed(1)},${(H - P).toFixed(1)}`, `${sx(data.length - 1).toFixed(1)},${(H - P).toFixed(1)}`,
].join(' ') ].join(' ')
// Hover tooltip state
const [tooltip, setTooltip] = useState(null)
const handleMouseMove = (e) => {
const rect = e.currentTarget.getBoundingClientRect()
const xRatio = (e.clientX - rect.left) / rect.width
const idx = Math.min(data.length - 1, Math.max(0, Math.round(xRatio * (data.length - 1))))
setTooltip({ idx, value: data[idx] })
}
return ( return (
<div className="relative" style={{ height }}> <div className="relative" style={{ height }}>
<svg <svg
@@ -94,41 +74,21 @@ function Sparkline({ data, min = 0, max = 100, color, fill, height = 60 }) {
style={{ height }} style={{ height }}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={() => setTooltip(null)} onMouseLeave={() => setTooltip(null)}
role="img"
aria-label={`Série de ${data.length} points, de ${min}% à ${max}%`}
> >
{/* Grid lines */}
{[25, 50, 75].map(pct => { {[25, 50, 75].map(pct => {
const y = sy(min + range * pct / 100) const y = sy(min + range * pct / 100)
return ( return (
<line <line key={pct} x1={P} y1={y.toFixed(1)} x2={W - P} y2={y.toFixed(1)} stroke="#1f2937" strokeWidth="1" />
key={pct}
x1={P} y1={y.toFixed(1)}
x2={W - P} y2={y.toFixed(1)}
stroke="#1f2937" strokeWidth="1"
/>
) )
})} })}
{/* Area fill */}
<polygon points={areaPts} fill={fill} opacity="0.2" /> <polygon points={areaPts} fill={fill} opacity="0.2" />
<polyline points={linePts} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
{/* Line */}
<polyline
points={linePts}
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Hover dot */}
{tooltip && ( {tooltip && (
<circle <circle cx={sx(tooltip.idx).toFixed(1)} cy={sy(tooltip.value).toFixed(1)} r="3" fill={color} />
cx={sx(tooltip.idx).toFixed(1)}
cy={sy(tooltip.value).toFixed(1)}
r="3"
fill={color}
/>
)} )}
</svg> </svg>
@@ -138,24 +98,26 @@ function Sparkline({ data, min = 0, max = 100, color, fill, height = 60 }) {
<span className="text-gray-600" style={{ fontSize: 9, lineHeight: '1' }}>{min}%</span> <span className="text-gray-600" style={{ fontSize: 9, lineHeight: '1' }}>{min}%</span>
</div> </div>
{/* Tooltip bubble */}
{tooltip && ( {tooltip && (
<div <div
className="absolute top-0 left-1/2 -translate-x-1/2 bg-gray-800 border border-gray-700 rounded px-2 py-0.5 text-xs text-white pointer-events-none" className="absolute top-0 left-1/2 -translate-x-1/2 bg-gray-800 border border-gray-700 rounded px-2 py-0.5 text-xs text-white pointer-events-none whitespace-nowrap"
style={{ whiteSpace: 'nowrap' }}
> >
{typeof tooltip.value === 'number' ? tooltip.value.toFixed(1) : tooltip.value}% {typeof tooltip.value === 'number' ? tooltip.value.toFixed(1) : tooltip.value}%
{timestamps[tooltip.idx] && (
<span className="text-gray-400 ml-1.5">{formatClock(timestamps[tooltip.idx])}</span>
)}
</div> </div>
)} )}
</div> </div>
) )
} }
// Dual sparkline (upload + download on same chart) // Dual sparkline (upload + download sur le même graphique)
function DualSparkline({ sentData, recvData, height = 60 }) { function DualSparkline({ sentData, recvData, timestamps = [], height = 60 }) {
const allValues = [...sentData, ...recvData] const [tooltip, setTooltip] = useState(null)
const maxVal = Math.max(...allValues, 1) * 1.15
const W = 500, H = height, P = 3 const H = height
const maxVal = Math.max(...sentData, ...recvData, 1) * 1.15
const sx = (i, len) => P + (i / Math.max(len - 1, 1)) * (W - P * 2) const sx = (i, len) => P + (i / Math.max(len - 1, 1)) * (W - P * 2)
const sy = (v) => H - P - (Math.max(0, Math.min(1, v / maxVal)) * (H - P * 2)) const sy = (v) => H - P - (Math.max(0, Math.min(1, v / maxVal)) * (H - P * 2))
@@ -167,15 +129,22 @@ function DualSparkline({ sentData, recvData, height = 60 }) {
`${sx(data.length - 1, data.length).toFixed(1)},${(H - P).toFixed(1)}`, `${sx(data.length - 1, data.length).toFixed(1)},${(H - P).toFixed(1)}`,
].join(' ') ].join(' ')
const [tooltip, setTooltip] = useState(null)
const handleMouseMove = (e) => { const handleMouseMove = (e) => {
if (!sentData.length) return
const rect = e.currentTarget.getBoundingClientRect() const rect = e.currentTarget.getBoundingClientRect()
const xRatio = (e.clientX - rect.left) / rect.width const xRatio = (e.clientX - rect.left) / rect.width
const idx = Math.min(sentData.length - 1, Math.max(0, Math.round(xRatio * (sentData.length - 1)))) const idx = Math.min(sentData.length - 1, Math.max(0, Math.round(xRatio * (sentData.length - 1))))
setTooltip({ idx, sent: sentData[idx], recv: recvData[idx] }) setTooltip({ idx, sent: sentData[idx], recv: recvData[idx] })
} }
if (!sentData.length) {
return (
<div className="flex items-center justify-center text-gray-700 text-xs italic" style={{ height }}>
En attente de données
</div>
)
}
return ( return (
<div className="relative" style={{ height }}> <div className="relative" style={{ height }}>
<svg <svg
@@ -185,6 +154,8 @@ function DualSparkline({ sentData, recvData, height = 60 }) {
style={{ height }} style={{ height }}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={() => setTooltip(null)} onMouseLeave={() => setTooltip(null)}
role="img"
aria-label="Bande passante montante et descendante"
> >
{[25, 50, 75].map(pct => ( {[25, 50, 75].map(pct => (
<line <line
@@ -198,7 +169,7 @@ function DualSparkline({ sentData, recvData, height = 60 }) {
<polyline points={mkLine(sentData)} fill="none" stroke="#38bdf8" strokeWidth="1.5" strokeLinejoin="round" /> <polyline points={mkLine(sentData)} fill="none" stroke="#38bdf8" strokeWidth="1.5" strokeLinejoin="round" />
<polygon points={mkArea(recvData)} fill="#a78bfa" opacity="0.15" /> <polygon points={mkArea(recvData)} fill="#a78bfa" opacity="0.15" />
<polyline points={mkLine(recvData)} fill="none" stroke="#a78bfa" strokeWidth="1.5" strokeLinejoin="round" /> <polyline points={mkLine(recvData)} fill="none" stroke="#a78bfa" strokeWidth="1.5" strokeLinejoin="round" />
{tooltip && sentData.length > 0 && ( {tooltip && (
<> <>
<circle cx={sx(tooltip.idx, sentData.length).toFixed(1)} cy={sy(tooltip.sent).toFixed(1)} r="3" fill="#38bdf8" /> <circle cx={sx(tooltip.idx, sentData.length).toFixed(1)} cy={sy(tooltip.sent).toFixed(1)} r="3" fill="#38bdf8" />
<circle cx={sx(tooltip.idx, recvData.length).toFixed(1)} cy={sy(tooltip.recv).toFixed(1)} r="3" fill="#a78bfa" /> <circle cx={sx(tooltip.idx, recvData.length).toFixed(1)} cy={sy(tooltip.recv).toFixed(1)} r="3" fill="#a78bfa" />
@@ -206,16 +177,28 @@ function DualSparkline({ sentData, recvData, height = 60 }) {
)} )}
</svg> </svg>
{tooltip && ( {tooltip && (
<div className="absolute top-0 left-1/2 -translate-x-1/2 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white pointer-events-none flex gap-3" style={{ whiteSpace: 'nowrap' }}> <div className="absolute top-0 left-1/2 -translate-x-1/2 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white pointer-events-none flex gap-3 whitespace-nowrap">
<span className="text-sky-400"> {fmtBps((tooltip.sent ?? 0) * 1024)}</span> <span className="text-sky-400"> {formatBps((tooltip.sent ?? 0) * 1024)}</span>
<span className="text-violet-400"> {fmtBps((tooltip.recv ?? 0) * 1024)}</span> <span className="text-violet-400"> {formatBps((tooltip.recv ?? 0) * 1024)}</span>
{timestamps[tooltip.idx] && (
<span className="text-gray-400">{formatClock(timestamps[tooltip.idx])}</span>
)}
</div> </div>
)} )}
</div> </div>
) )
} }
// ─── Carte de stat ──────────────────────────────────────────────────────────── /** Bornes temporelles sous un graphique. */
function TimeAxis({ timestamps }) {
if (timestamps.length < 2) return null
return (
<div className="flex justify-between text-[10px] text-gray-600 -mt-1">
<span>{formatClock(timestamps[0], false)}</span>
<span>{formatClock(timestamps[timestamps.length - 1], false)}</span>
</div>
)
}
function StatCard({ title, icon, current, average, unit, children }) { function StatCard({ title, icon, current, average, unit, children }) {
return ( return (
@@ -237,8 +220,6 @@ function StatCard({ title, icon, current, average, unit, children }) {
) )
} }
// ─── Options de durée ─────────────────────────────────────────────────────────
const DURATION_OPTIONS = [ const DURATION_OPTIONS = [
{ label: '10 min', value: 600 }, { label: '10 min', value: 600 },
{ label: '1 h', value: 3_600 }, { label: '1 h', value: 3_600 },
@@ -248,8 +229,6 @@ const DURATION_OPTIONS = [
{ label: '30 j', value: 2_592_000 }, { label: '30 j', value: 2_592_000 },
] ]
// ─── Modal principal ──────────────────────────────────────────────────────────────
export default function StatsModal({ vpsId, vpsName, onClose }) { export default function StatsModal({ vpsId, vpsName, onClose }) {
const [stats, setStats] = useState([]) const [stats, setStats] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -277,174 +256,180 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
const last = stats[stats.length - 1] const last = stats[stats.length - 1]
// Séries CPU / RAM const series = useMemo(() => ({
const cpuData = stats.map(s => s.cpu) timestamps: stats.map(s => s.ts),
const ramData = stats.map(s => s.ram_percent) cpu: stats.map(s => s.cpu),
ram: stats.map(s => s.ram_percent),
sentKB: stats.map(s => s.net_sent_per_sec / 1024),
recvKB: stats.map(s => s.net_recv_per_sec / 1024),
}), [stats])
// Axes auto-scalés pour voir les variations même à faible charge const cpuRange = autoRange(series.cpu, 0, 100)
const cpuRange = autoRange(cpuData, 0, 100) const ramRange = autoRange(series.ram, 0, 100)
const ramRange = autoRange(ramData, 0, 100)
// Réseau : KB/s pour l'affichage du graphique // Trafic cumulé (delta premier → dernier point)
const sentKB = stats.map(s => s.net_sent_per_sec / 1024) const sessionSent = stats.length > 1 ? Math.max(0, stats.at(-1).net_bytes_sent - stats[0].net_bytes_sent) : 0
const recvKB = stats.map(s => s.net_recv_per_sec / 1024) const sessionRecv = stats.length > 1 ? Math.max(0, stats.at(-1).net_bytes_recv - stats[0].net_bytes_recv) : 0
// Trafic cumulé (delta first → last)
const sessionSent = stats.length > 1
? Math.max(0, stats.at(-1).net_bytes_sent - stats[0].net_bytes_sent)
: 0
const sessionRecv = stats.length > 1
? Math.max(0, stats.at(-1).net_bytes_recv - stats[0].net_bytes_recv)
: 0
const durationLabel = DURATION_OPTIONS.find(o => o.value === duration)?.label ?? '' const durationLabel = DURATION_OPTIONS.find(o => o.value === duration)?.label ?? ''
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <Modal
<div className="bg-gray-950 border border-gray-800 rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl"> size="lg"
title={vpsName}
{/* En-tête */} subtitle={stats.length > 0
<div className="px-5 py-3 border-b border-gray-800 sticky top-0 bg-gray-950 z-10"> ? `${stats.length} points · fenêtre ${durationLabel} · rafraîchissement ${refreshMs / 1000} s`
<div className="flex items-center justify-between mb-3"> : 'En attente de données…'}
<div className="flex items-center gap-3"> icon={<BarChart2 size={18} className="text-indigo-400 flex-shrink-0" />}
<BarChart2 size={18} className="text-indigo-400" /> onClose={onClose}
<div> bodyClassName="overflow-y-auto p-5"
<h2 className="font-semibold text-sm">{vpsName}</h2> >
<p className="text-xs text-gray-500"> {/* Sélecteur de durée */}
{stats.length > 0 <div className="flex gap-1 flex-wrap mb-5" role="group" aria-label="Fenêtre temporelle">
? `${stats.length} points · fenêtre ${durationLabel} · rafraîchissement ${refreshMs / 1000} s` {DURATION_OPTIONS.map(opt => (
: 'En attente de données…'} <button
</p> key={opt.value}
</div> onClick={() => setDuration(opt.value)}
</div> aria-pressed={duration === opt.value}
<button className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
onClick={onClose} duration === opt.value
className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-white transition-colors" ? 'bg-indigo-600 text-white'
> : 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
<X size={16} /> }`}
</button> >
</div> {opt.label}
</button>
{/* Sélecteur de durée */} ))}
<div className="flex gap-1 flex-wrap">
{DURATION_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setDuration(opt.value)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
duration === opt.value
? 'bg-indigo-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Contenu */}
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-600 text-sm">
Chargement
</div>
) : stats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-600 text-sm">
<BarChart2 size={32} className="text-gray-700" />
Aucune donnée disponible le collecteur démarre dans quelques secondes.
</div>
) : (
<div className="p-5 flex flex-col gap-4">
{/* CPU + RAM */}
<div className="grid grid-cols-2 gap-4">
<StatCard
title="CPU"
icon={<Cpu size={12} />}
current={last ? last.cpu.toFixed(1) : '—'}
average={avg(cpuData)}
unit="%"
>
<Sparkline data={cpuData} min={cpuRange.min} max={cpuRange.max} color="#818cf8" fill="#818cf8" height={56} />
</StatCard>
<StatCard
title="RAM"
icon={<MemoryStick size={12} />}
current={last ? last.ram_percent.toFixed(1) : '—'}
average={avg(ramData)}
unit="%"
>
<Sparkline data={ramData} min={ramRange.min} max={ramRange.max} color="#34d399" fill="#34d399" height={56} />
{last && (
<p className="text-xs text-gray-600 -mt-1">
{fmtRam(last.ram_used)} / {fmtRam(last.ram_total)}
</p>
)}
</StatCard>
</div>
{/* Bande passante */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-gray-400 text-xs font-medium uppercase tracking-wide">
<TrendingUp size={12} />
Bande passante
</div>
{last && (
<div className="flex gap-4 text-xs">
<span className="flex items-center gap-1 text-sky-400">
<ArrowUp size={10} /> {fmtBps(last.net_sent_per_sec)}
</span>
<span className="flex items-center gap-1 text-violet-400">
<ArrowDown size={10} /> {fmtBps(last.net_recv_per_sec)}
</span>
</div>
)}
</div>
<DualSparkline sentData={sentKB} recvData={recvKB} height={64} />
<div className="flex gap-4 text-xs text-gray-600">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-sky-400 inline-block" />
Upload
</span>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-violet-400 inline-block" />
Download
</span>
</div>
</div>
{/* Trafic total session */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 mb-4">
Trafic total (depuis le début de la collecte)
</p>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-sky-500/10 flex-shrink-0">
<ArrowUp size={16} className="text-sky-400" />
</div>
<div>
<p className="text-lg font-bold tabular-nums">{fmtBytes(sessionSent)}</p>
<p className="text-xs text-gray-600">Envoyés</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-violet-500/10 flex-shrink-0">
<ArrowDown size={16} className="text-violet-400" />
</div>
<div>
<p className="text-lg font-bold tabular-nums">{fmtBytes(sessionRecv)}</p>
<p className="text-xs text-gray-600">Reçus</p>
</div>
</div>
</div>
</div>
</div>
)}
</div> </div>
</div>
{loading ? (
<div className="flex flex-col gap-4" aria-busy="true">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Skeleton className="h-36" />
<Skeleton className="h-36" />
</div>
<Skeleton className="h-40" />
</div>
) : stats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-600 text-sm text-center">
<BarChart2 size={32} className="text-gray-700" />
Aucune donnée disponible le collecteur démarre dans quelques secondes.
</div>
) : (
<div className="flex flex-col gap-4">
{/* CPU + RAM */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<StatCard
title="CPU"
icon={<Cpu size={12} />}
current={last ? last.cpu.toFixed(1) : '—'}
average={avg(series.cpu)}
unit="%"
>
<Sparkline
data={series.cpu}
timestamps={series.timestamps}
min={cpuRange.min}
max={cpuRange.max}
color="#818cf8"
fill="#818cf8"
height={56}
/>
<TimeAxis timestamps={series.timestamps} />
</StatCard>
<StatCard
title="RAM"
icon={<MemoryStick size={12} />}
current={last ? last.ram_percent.toFixed(1) : '—'}
average={avg(series.ram)}
unit="%"
>
<Sparkline
data={series.ram}
timestamps={series.timestamps}
min={ramRange.min}
max={ramRange.max}
color="#34d399"
fill="#34d399"
height={56}
/>
<TimeAxis timestamps={series.timestamps} />
{last && (
<p className="text-xs text-gray-600">
{formatRam(last.ram_used)} / {formatRam(last.ram_total)}
</p>
)}
</StatCard>
</div>
{/* Bande passante */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2 text-gray-400 text-xs font-medium uppercase tracking-wide">
<TrendingUp size={12} />
Bande passante
</div>
{last && (
<div className="flex gap-4 text-xs">
<span className="flex items-center gap-1 text-sky-400">
<ArrowUp size={10} /> {formatBps(last.net_sent_per_sec)}
</span>
<span className="flex items-center gap-1 text-violet-400">
<ArrowDown size={10} /> {formatBps(last.net_recv_per_sec)}
</span>
</div>
)}
</div>
<DualSparkline
sentData={series.sentKB}
recvData={series.recvKB}
timestamps={series.timestamps}
height={64}
/>
<TimeAxis timestamps={series.timestamps} />
<div className="flex gap-4 text-xs text-gray-600">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-sky-400 inline-block" />
Upload
</span>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-violet-400 inline-block" />
Download
</span>
</div>
</div>
{/* Trafic total session */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 mb-4">
Trafic total sur la fenêtre {durationLabel}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-sky-500/10 flex-shrink-0">
<ArrowUp size={16} className="text-sky-400" />
</div>
<div>
<p className="text-lg font-bold tabular-nums">{formatBytes(sessionSent)}</p>
<p className="text-xs text-gray-600">Envoyés</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-violet-500/10 flex-shrink-0">
<ArrowDown size={16} className="text-violet-400" />
</div>
<div>
<p className="text-lg font-bold tabular-nums">{formatBytes(sessionRecv)}</p>
<p className="text-xs text-gray-600">Reçus</p>
</div>
</div>
</div>
</div>
</div>
)}
</Modal>
) )
} }

View File

@@ -1,28 +1,43 @@
import { useEffect, useMemo, useState } from 'react'
import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil, BarChart2, CloudDownload, Copy, Check, Activity } from 'lucide-react' import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil, BarChart2, CloudDownload, Copy, Check, Activity } from 'lucide-react'
import { useState } from 'react'
import ContainerRow from './ContainerRow' import ContainerRow from './ContainerRow'
import { tagColor } from './TagInput' import { tagColor } from './TagInput'
import { IconButton, ProgressBar } from './ui/controls'
import { formatBps, formatRam, loadColor, loadBarColor } from '../lib/format'
function formatBytes(bps) { /** Métrique système avec barre de progression (CPU, RAM). */
if (bps < 1024) return `${bps.toFixed(0)} B/s` function Metric({ icon: Icon, label, percent: rawPercent, detail }) {
if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s` const percent = Number.isFinite(rawPercent) ? rawPercent : 0
return `${(bps / 1024 / 1024).toFixed(1)} MB/s` return (
<div className="flex-1 min-w-[130px]">
<div className="flex items-center gap-1.5 text-xs text-gray-400 mb-1">
<Icon size={11} className="text-indigo-400 flex-shrink-0" />
<span>{label}</span>
<span className={`font-medium tabular-nums ml-auto ${loadColor(percent)}`}>
{percent.toFixed(percent >= 10 ? 0 : 1)} %
</span>
</div>
<ProgressBar value={percent} barClass={loadBarColor(percent)} />
{detail && <p className="text-[11px] text-gray-600 mt-1">{detail}</p>}
</div>
)
} }
function formatRam(bytes) { export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport }) {
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(0)} MB` const storageKey = `vps:${vps.id}:collapsed`
return `${(bytes / 1024 ** 3).toFixed(1)} GB` const [collapsed, setCollapsed] = useState(() => localStorage.getItem(storageKey) === '1')
}
export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport }) {
const [collapsed, setCollapsed] = useState(false)
const [updatingProject, setUpdatingProject] = useState(null) const [updatingProject, setUpdatingProject] = useState(null)
const [updatingAgent, setUpdatingAgent] = useState(false) const [updatingAgent, setUpdatingAgent] = useState(false)
const [exported, setExported] = useState(false) const [exported, setExported] = useState(false)
const [servicesExpanded, setServicesExpanded] = useState(false) const [servicesExpanded, setServicesExpanded] = useState(false)
useEffect(() => {
localStorage.setItem(storageKey, collapsed ? '1' : '0')
}, [storageKey, collapsed])
const handleExport = async () => { const handleExport = async () => {
await onExport(vps.id) const copied = await onExport(vps.id)
if (!copied) return
setExported(true) setExported(true)
setTimeout(() => setExported(false), 2000) setTimeout(() => setExported(false), 2000)
} }
@@ -30,6 +45,18 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
const running = vps.containers.filter(c => c.status === 'running').length const running = vps.containers.filter(c => c.status === 'running').length
const total = vps.containers.length const total = vps.containers.length
// Quand une recherche est active, on n'affiche que les conteneurs concernés —
// sauf si aucun ne correspond (le VPS est alors visible via son nom ou un tag).
const visibleContainers = useMemo(() => {
if (!query) return vps.containers
const matching = vps.containers.filter(c =>
[c.name, c.image, c.compose_project].filter(Boolean).some(v => v.toLowerCase().includes(query))
)
return matching.length > 0 ? matching : vps.containers
}, [vps.containers, query])
const hiddenCount = total - visibleContainers.length
const composeProjects = [...new Set(vps.containers.map(c => c.compose_project).filter(Boolean))] const composeProjects = [...new Set(vps.containers.map(c => c.compose_project).filter(Boolean))]
const handleUpdate = async (project) => { const handleUpdate = async (project) => {
@@ -55,73 +82,57 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
<p className="text-xs text-gray-500 truncate">{vps.host}</p> <p className="text-xs text-gray-500 truncate">{vps.host}</p>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
{vps.online ? ( {vps.online ? (
<span className="flex items-center gap-1.5 text-xs text-emerald-400"> <span className="flex items-center gap-1.5 text-xs text-emerald-400 mr-1">
<Wifi size={12} /> <Wifi size={12} />
<span className="hidden sm:inline">{running}/{total} actifs</span> <span className="hidden sm:inline whitespace-nowrap">{running}/{total} actifs</span>
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-xs text-red-400"> <span className="flex items-center gap-1.5 text-xs text-red-400 mr-1">
<WifiOff size={12} /> <WifiOff size={12} />
<span className="hidden sm:inline">Hors ligne</span> <span className="hidden sm:inline">Hors ligne</span>
</span> </span>
)} )}
<button <IconButton
icon={collapsed ? ChevronDown : ChevronUp}
label={collapsed ? 'Déplier la carte' : 'Replier la carte'}
aria-expanded={!collapsed}
onClick={() => setCollapsed(c => !c)} onClick={() => setCollapsed(c => !c)}
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors" />
title={collapsed ? 'Déplier' : 'Replier'}
>
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
{vps.online && ( {vps.online && (
<button <IconButton
icon={BarChart2}
label="Graphiques de performance"
tone="accent"
onClick={() => onStats(vps.id, vps.name)} onClick={() => onStats(vps.id, vps.name)}
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-indigo-400 transition-colors" />
title="Graphiques de performance"
>
<BarChart2 size={14} />
</button>
)} )}
<button <IconButton
icon={exported ? Check : Copy}
label={exported ? 'Config copiée !' : 'Exporter la config (copier le JSON)'}
tone={exported ? 'success' : 'default'}
onClick={handleExport} onClick={handleExport}
className={`p-1.5 rounded hover:bg-gray-800 transition-colors ${exported ? 'text-emerald-400' : 'text-gray-500 hover:text-gray-300'}`} />
title={exported ? 'Config copiée !' : 'Exporter la config (copier JSON)'}
>
{exported ? <Check size={14} /> : <Copy size={14} />}
</button>
<button <IconButton icon={Pencil} label="Modifier ce VPS" onClick={() => onEdit(vps)} />
onClick={() => onEdit(vps)} <IconButton icon={Trash2} label="Supprimer ce VPS" tone="danger" onClick={() => onDelete(vps.id)} />
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors"
title="Modifier ce VPS"
>
<Pencil size={14} />
</button>
<button
onClick={() => onDelete(vps.id)}
className="p-1.5 rounded hover:bg-red-500/20 text-gray-500 hover:text-red-400 transition-colors"
title="Supprimer ce VPS"
>
<Trash2 size={14} />
</button>
</div> </div>
</div> </div>
{/* Erreur de connexion */} {/* Erreur de connexion */}
{!vps.online && vps.error && ( {!vps.online && vps.error && (
<div className="px-4 py-2.5 bg-red-950/30 border-b border-red-900/30 text-xs text-red-400 font-mono"> <div className="px-4 py-2.5 bg-red-950/30 border-b border-red-900/30 text-xs text-red-400 font-mono break-words">
{vps.error} {vps.error}
</div> </div>
)} )}
{/* Version de l'agent + bouton mise à jour */} {/* Version de l'agent + bouton mise à jour */}
{vps.online && ( {vps.online && (
<div className="px-4 py-2 border-b border-gray-800/60 flex items-center gap-3"> <div className="px-4 py-2 border-b border-gray-800/60 flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="text-xs text-gray-500">Agent&nbsp;:</span> <span className="text-xs text-gray-500">Agent&nbsp;:</span>
{vps.agent_version ? ( {vps.agent_version ? (
<span <span
@@ -173,20 +184,30 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
{/* Informations système */} {/* Informations système */}
{vps.online && vps.system && !collapsed && ( {vps.online && vps.system && !collapsed && (
<div className="px-4 py-2 border-b border-gray-800/60 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-400 bg-gray-900/50"> <div className="px-4 py-3 border-b border-gray-800/60 bg-gray-900/50 space-y-2.5">
<span className="flex items-center gap-1"> <div className="flex flex-wrap gap-4">
<Cpu size={11} className="text-indigo-400" /> <Metric
CPU <span className={`font-medium ml-0.5 ${vps.system.cpu_percent > 80 ? 'text-red-400' : vps.system.cpu_percent > 60 ? 'text-yellow-400' : 'text-emerald-400'}`}>{vps.system.cpu_percent.toFixed(1)}%</span> icon={Cpu}
</span> label="CPU"
<span className="flex items-center gap-1"> percent={vps.system.cpu_percent}
<MemoryStick size={11} className="text-indigo-400" /> />
RAM <span className={`font-medium ml-0.5 ${vps.system.ram_percent > 80 ? 'text-red-400' : vps.system.ram_percent > 60 ? 'text-yellow-400' : 'text-emerald-400'}`}>{formatRam(vps.system.ram_used)}/{formatRam(vps.system.ram_total)}</span> <Metric
<span className="text-gray-600">({vps.system.ram_percent.toFixed(0)}%)</span> icon={MemoryStick}
</span> label="RAM"
<span className="flex items-center gap-1"> percent={vps.system.ram_percent}
<ArrowUp size={11} className="text-sky-400" />{formatBytes(vps.system.net_sent_per_sec)} detail={`${formatRam(vps.system.ram_used)} / ${formatRam(vps.system.ram_total)}`}
<ArrowDown size={11} className="text-violet-400 ml-1" />{formatBytes(vps.system.net_recv_per_sec)} />
</span> </div>
<div className="flex items-center gap-4 text-xs text-gray-400">
<span className="flex items-center gap-1" title="Débit sortant">
<ArrowUp size={11} className="text-sky-400" />
<span className="tabular-nums">{formatBps(vps.system.net_sent_per_sec)}</span>
</span>
<span className="flex items-center gap-1" title="Débit entrant">
<ArrowDown size={11} className="text-violet-400" />
<span className="tabular-nums">{formatBps(vps.system.net_recv_per_sec)}</span>
</span>
</div>
</div> </div>
)} )}
@@ -214,6 +235,7 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
<div className="border-b border-gray-800/60"> <div className="border-b border-gray-800/60">
<button <button
onClick={() => setServicesExpanded(e => !e)} onClick={() => setServicesExpanded(e => !e)}
aria-expanded={servicesExpanded}
className="w-full flex items-center justify-between px-4 py-2 text-xs text-gray-400 hover:bg-gray-800/40 transition-colors" className="w-full flex items-center justify-between px-4 py-2 text-xs text-gray-400 hover:bg-gray-800/40 transition-colors"
> >
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
@@ -222,6 +244,11 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
<span className="ml-1 px-1.5 py-0.5 rounded-full bg-gray-800 text-gray-500 text-[10px]"> <span className="ml-1 px-1.5 py-0.5 rounded-full bg-gray-800 text-gray-500 text-[10px]">
{vps.services.filter(s => s.active === 'active').length}/{vps.services.length} {vps.services.filter(s => s.active === 'active').length}/{vps.services.length}
</span> </span>
{vps.services.some(s => s.active === 'failed') && (
<span className="ml-1 px-1.5 py-0.5 rounded-full bg-red-500/10 text-red-400 text-[10px]">
{vps.services.filter(s => s.active === 'failed').length} en échec
</span>
)}
</span> </span>
{servicesExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />} {servicesExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button> </button>
@@ -255,14 +282,21 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
{total === 0 ? ( {total === 0 ? (
<p className="px-4 py-6 text-sm text-gray-600 text-center">Aucun conteneur détecté.</p> <p className="px-4 py-6 text-sm text-gray-600 text-center">Aucun conteneur détecté.</p>
) : ( ) : (
vps.containers.map(c => ( <>
<ContainerRow {hiddenCount > 0 && (
key={c.id} <p className="px-4 py-1.5 text-[11px] text-gray-600 bg-gray-800/20">
container={c} {hiddenCount} conteneur{hiddenCount > 1 ? 's' : ''} masqué{hiddenCount > 1 ? 's' : ''} par la recherche
onAction={(action) => onAction(vps.id, c.id, action)} </p>
onLogs={() => onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)} )}
/> {visibleContainers.map(c => (
)) <ContainerRow
key={c.id}
container={c}
onAction={(action) => onAction(vps.id, c.id, action)}
onLogs={() => onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)}
/>
))}
</>
)} )}
</div> </div>
)} )}

View File

@@ -0,0 +1,41 @@
import { AlertTriangle } from 'lucide-react'
import Modal from './Modal'
import { Button } from './controls'
/** Confirmation destructive — remplace `window.confirm`. */
export default function ConfirmDialog({
title = 'Confirmer',
message,
confirmLabel = 'Confirmer',
cancelLabel = 'Annuler',
danger = false,
loading = false,
onConfirm,
onCancel,
}) {
return (
<Modal
size="sm"
title={title}
onClose={onCancel}
bodyClassName="overflow-y-auto px-5 py-5"
icon={
<div className={`p-2 rounded-xl flex-shrink-0 ${danger ? 'bg-red-500/15' : 'bg-indigo-500/15'}`}>
<AlertTriangle size={16} className={danger ? 'text-red-400' : 'text-indigo-400'} />
</div>
}
footer={
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={onCancel} disabled={loading}>
{cancelLabel}
</Button>
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm} loading={loading} autoFocus>
{confirmLabel}
</Button>
</div>
}
>
<div className="text-sm text-gray-400 leading-relaxed">{message}</div>
</Modal>
)
}

View File

@@ -0,0 +1,131 @@
import { useEffect, useId, useRef } from 'react'
import { X } from 'lucide-react'
const SIZES = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-3xl',
xl: 'max-w-4xl',
}
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ')
/**
* Modale de base : fermeture par Échap et clic sur le fond, piège à focus,
* restitution du focus à la fermeture, verrouillage du défilement, sémantique
* ARIA. Tous les modals de l'application passent par ici.
*/
export default function Modal({
title,
subtitle,
icon,
size = 'md',
onClose,
headerRight,
footer,
children,
bodyClassName = 'overflow-y-auto p-4',
panelClassName = '',
}) {
const panelRef = useRef(null)
const onCloseRef = useRef(onClose)
const titleId = useId()
// `onClose` est souvent une lambda recréée à chaque rendu du parent : on la
// lit via une ref pour que l'effet ne se rejoue pas (sinon le focus sauterait
// au premier champ à chaque rafraîchissement automatique).
useEffect(() => { onCloseRef.current = onClose })
useEffect(() => {
const previouslyFocused = document.activeElement
const panel = panelRef.current
const firstFocusable = panel?.querySelector(FOCUSABLE)
;(firstFocusable ?? panel)?.focus({ preventScroll: true })
const onKeyDown = (e) => {
if (e.key === 'Escape') {
e.stopPropagation()
onCloseRef.current?.()
return
}
if (e.key !== 'Tab' || !panel) return
const items = [...panel.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null)
if (items.length === 0) return
const first = items[0]
const last = items[items.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKeyDown)
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
previouslyFocused?.focus?.({ preventScroll: true })
}
}, [])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className={`w-full ${SIZES[size] ?? SIZES.md} flex flex-col max-h-[88vh] bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl outline-none animate-modal-in ${panelClassName}`}
>
<div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-800 flex-shrink-0">
<div className="flex items-center gap-2.5 min-w-0">
{icon}
<div className="min-w-0">
<h2 id={titleId} className="font-semibold text-sm truncate">{title}</h2>
{subtitle && <p className="text-xs text-gray-500 truncate mt-0.5">{subtitle}</p>}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{headerRight}
<button
onClick={onClose}
aria-label="Fermer"
className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-gray-200 transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* `min-h-0` autorise l'enfant à défiler dans un conteneur flex */}
<div className={`flex-1 min-h-0 ${bodyClassName}`}>
{children}
</div>
{footer && (
<div className="px-4 py-3 border-t border-gray-800 flex-shrink-0">
{footer}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react'
const ToastContext = createContext(null)
const TYPES = {
success: { Icon: CheckCircle2, panel: 'border-emerald-800/60 bg-emerald-950/90', icon: 'text-emerald-400' },
error: { Icon: AlertTriangle, panel: 'border-red-800/60 bg-red-950/90', icon: 'text-red-400' },
info: { Icon: Info, panel: 'border-gray-700 bg-gray-900/95', icon: 'text-indigo-400' },
}
const MAX_VISIBLE = 4
export function useToast() {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast doit être utilisé à l\'intérieur d\'un <ToastProvider>')
return ctx
}
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([])
const nextId = useRef(0)
const timers = useRef(new Map())
const dismiss = useCallback((id) => {
const timer = timers.current.get(id)
if (timer) { clearTimeout(timer); timers.current.delete(id) }
setToasts(list => list.filter(t => t.id !== id))
}, [])
const push = useCallback((message, { type = 'info', duration = 4500 } = {}) => {
const id = ++nextId.current
setToasts(list => [...list.slice(-(MAX_VISIBLE - 1)), { id, message, type }])
if (duration > 0) timers.current.set(id, setTimeout(() => dismiss(id), duration))
return id
}, [dismiss])
// Nettoyage des minuteries au démontage
useEffect(() => {
const pending = timers.current
return () => { pending.forEach(clearTimeout); pending.clear() }
}, [])
const api = useMemo(() => ({
push,
dismiss,
success: (message, opts) => push(message, { type: 'success', ...opts }),
error: (message, opts) => push(message, { type: 'error', duration: 8000, ...opts }),
info: (message, opts) => push(message, { type: 'info', ...opts }),
}), [push, dismiss])
return (
<ToastContext.Provider value={api}>
{children}
<div
className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-[min(24rem,calc(100vw-2rem))] pointer-events-none"
role="region"
aria-label="Notifications"
aria-live="polite"
>
{toasts.map(({ id, message, type }) => {
const { Icon, panel, icon } = TYPES[type] ?? TYPES.info
return (
<div
key={id}
className={`pointer-events-auto flex items-start gap-2.5 rounded-xl border px-3 py-2.5 shadow-xl backdrop-blur-sm animate-toast-in ${panel}`}
>
<Icon size={15} className={`flex-shrink-0 mt-0.5 ${icon}`} />
<p className="flex-1 text-xs text-gray-200 leading-relaxed break-words">{message}</p>
<button
onClick={() => dismiss(id)}
aria-label="Fermer la notification"
className="flex-shrink-0 p-0.5 rounded text-gray-500 hover:text-gray-200 transition-colors"
>
<X size={13} />
</button>
</div>
)
})}
</div>
</ToastContext.Provider>
)
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react'
import { Eye, EyeOff, Loader2 } from 'lucide-react'
/** Classe commune à tous les champs de saisie. */
export const inputClass =
'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm placeholder-gray-600 ' +
'focus:outline-none focus:border-indigo-500 transition-colors disabled:opacity-50'
const VARIANTS = {
primary: 'bg-indigo-600 hover:bg-indigo-500 text-white',
secondary: 'bg-gray-800 hover:bg-gray-700 text-gray-200',
outline: 'border border-gray-700 hover:bg-gray-800 text-gray-200',
ghost: 'hover:bg-gray-800 text-gray-400 hover:text-gray-200',
danger: 'bg-red-600 hover:bg-red-500 text-white',
subtle: 'bg-red-950/50 hover:bg-red-900/60 text-red-400 border border-red-800/50',
}
const BUTTON_SIZES = {
sm: 'px-2.5 py-1 text-xs gap-1.5',
md: 'px-3 py-1.5 text-sm gap-1.5',
lg: 'px-4 py-2 text-sm gap-2',
}
export function Button({
variant = 'secondary',
size = 'md',
icon: Icon,
loading = false,
disabled = false,
className = '',
children,
...props
}) {
return (
<button
disabled={disabled || loading}
className={`inline-flex items-center justify-center rounded-lg font-medium transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
${VARIANTS[variant] ?? VARIANTS.secondary} ${BUTTON_SIZES[size] ?? BUTTON_SIZES.md} ${className}`}
{...props}
>
{loading
? <Loader2 size={14} className="animate-spin flex-shrink-0" />
: Icon && <Icon size={14} className="flex-shrink-0" />}
{children}
</button>
)
}
const ICON_TONES = {
default: 'text-gray-500 hover:text-gray-200 hover:bg-gray-800',
accent: 'text-gray-500 hover:text-indigo-400 hover:bg-gray-800',
danger: 'text-gray-500 hover:text-red-400 hover:bg-red-500/20',
success: 'text-emerald-400 hover:bg-gray-800',
}
/**
* Bouton icône. `label` alimente à la fois `title` et `aria-label` : sans lui,
* ces boutons sont muets pour un lecteur d'écran.
*/
export function IconButton({ icon: Icon, label, tone = 'default', size = 14, className = '', ...props }) {
return (
<button
title={label}
aria-label={label}
className={`p-1.5 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed
${ICON_TONES[tone] ?? ICON_TONES.default} ${className}`}
{...props}
>
<Icon size={size} />
</button>
)
}
/** Champ mot de passe avec bascule d'affichage. */
export function PasswordInput({ className = '', ...props }) {
const [visible, setVisible] = useState(false)
return (
<div className="relative">
<input
type={visible ? 'text' : 'password'}
className={`${inputClass} pr-10 ${className}`}
{...props}
/>
<button
type="button"
onClick={() => setVisible(v => !v)}
aria-label={visible ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
title={visible ? 'Masquer' : 'Afficher'}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-gray-500 hover:text-gray-300 transition-colors"
>
{visible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
)
}
/** Barre de progression (CPU, RAM…). */
export function ProgressBar({ value, barClass = 'bg-indigo-500', className = '' }) {
const pct = Math.max(0, Math.min(100, value ?? 0))
return (
<div
className={`h-1.5 rounded-full bg-gray-800 overflow-hidden ${className}`}
role="progressbar"
aria-valuenow={Math.round(pct)}
aria-valuemin={0}
aria-valuemax={100}
>
<div className={`h-full rounded-full transition-[width] duration-500 ${barClass}`} style={{ width: `${pct}%` }} />
</div>
)
}
export function Skeleton({ className = '' }) {
return <div className={`skeleton rounded-lg ${className}`} />
}
/** Bloc vide illustré (aucun VPS, aucun résultat…). */
export function EmptyState({ icon: Icon, title, description, action }) {
return (
<div className="flex flex-col items-center justify-center text-center py-20 px-4">
{Icon && (
<div className="p-3 rounded-2xl bg-gray-900 border border-gray-800 mb-4">
<Icon size={24} className="text-gray-600" />
</div>
)}
<p className="text-base font-medium text-gray-300">{title}</p>
{description && <p className="text-sm text-gray-500 mt-1 max-w-sm">{description}</p>}
{action && <div className="mt-6">{action}</div>}
</div>
)
}

View File

@@ -2,8 +2,57 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* Contrôles natifs (select, datetime-local, scrollbars…) rendus en sombre */
:root { color-scheme: dark; }
/* Scrollbar minimaliste */ /* Scrollbar minimaliste */
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; } ::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #4b5563; } ::-webkit-scrollbar-thumb:hover { background: #4b5563; }
/* La croix native de WebKit ferait doublon avec notre bouton d'effacement */
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
}
/* Focus clavier visible partout.
Le double `:focus-visible` porte la spécificité à (0,2,0) pour égaler les
utilitaires `focus:outline-none` de Tailwind ; à spécificité égale, la règle
déclarée en dernier (celle-ci) l'emporte. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 2px;
}
/* ─── Animations ──────────────────────────────────────────────────────────── */
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
@keyframes toast-in { from { opacity: 0; transform: translateY(.5rem) scale(.98) } to { opacity: 1; transform: none } }
@keyframes modal-in { from { opacity: 0; transform: translateY(.75rem) scale(.98) } to { opacity: 1; transform: none } }
@keyframes shimmer { 100% { transform: translateX(100%) } }
.animate-fade-in { animation: fade-in .15s ease-out }
.animate-toast-in { animation: toast-in .18s ease-out }
.animate-modal-in { animation: modal-in .18s cubic-bezier(.16, 1, .3, 1) }
/* Squelette de chargement */
.skeleton { position: relative; overflow: hidden; background-color: rgb(31 41 55 / .6); }
.skeleton::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgb(255 255 255 / .05), transparent);
animation: shimmer 1.6s infinite;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
}
}

View File

@@ -0,0 +1,44 @@
/**
* Copie un texte dans le presse-papiers.
*
* `navigator.clipboard` n'existe que dans un contexte sécurisé (HTTPS ou
* localhost) — cas fréquemment absent d'une instance auto-hébergée servie en
* HTTP sur son IP. On retombe alors sur `document.execCommand('copy')`.
*
* @returns {Promise<boolean>} true si la copie a réussi.
*/
export async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch { /* on tente la méthode historique ci-dessous */ }
}
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
}
/** Déclenche le téléchargement d'un contenu texte — repli quand la copie échoue. */
export function downloadText(filename, content, mime = 'text/plain') {
const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}

View File

@@ -0,0 +1,61 @@
/** Formatage partagé entre la carte VPS et le modal de statistiques. */
/** Débit en octets/seconde → chaîne lisible. */
export function formatBps(bps) {
if (bps === undefined || bps === null) return '—'
if (bps < 1024) return `${bps.toFixed(0)} B/s`
if (bps < 1024 ** 2) return `${(bps / 1024).toFixed(1)} KB/s`
return `${(bps / 1024 ** 2).toFixed(1)} MB/s`
}
/** Volume d'octets → chaîne lisible. */
export function formatBytes(bytes) {
if (!bytes || bytes < 1) return '0 B'
if (bytes < 1024) return `${bytes.toFixed(0)} B`
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
return `${(bytes / 1024 ** 3).toFixed(2)} GB`
}
/** Octets de RAM → MB en dessous de 1 Go, GB au-delà. */
export function formatRam(bytes) {
if (!bytes) return '—'
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(0)} MB`
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
}
/** Date → « il y a 12 s », « il y a 3 min »… */
export function formatRelative(date) {
if (!date) return ''
const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000))
if (seconds < 5) return "à l'instant"
if (seconds < 60) return `il y a ${seconds} s`
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `il y a ${minutes} min`
return `il y a ${Math.round(minutes / 60)} h`
}
/** Heure courte d'un timestamp ISO (axes et infobulles des graphiques). */
export function formatClock(ts, withSeconds = true) {
if (!ts) return ''
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
...(withSeconds ? { second: '2-digit' } : {}),
})
}
/** Seuils partagés pour colorer CPU / RAM. */
export function loadColor(percent) {
if (percent > 85) return 'text-red-400'
if (percent > 60) return 'text-yellow-400'
return 'text-emerald-400'
}
export function loadBarColor(percent) {
if (percent > 85) return 'bg-red-500'
if (percent > 60) return 'bg-yellow-500'
return 'bg-emerald-500'
}

View File

@@ -1,10 +1,13 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import { ToastProvider } from './components/ui/Toast'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<App /> <ToastProvider>
<App />
</ToastProvider>
</React.StrictMode> </React.StrictMode>
) )