feat: enhance VPS management interface with search, filter, and toast notifications
All checks were successful
Build and Push Docker Images / docker (push) Successful in 42s
All checks were successful
Build and Push Docker Images / docker (push) Successful in 42s
- Added a DashboardToolbar component for searching, filtering, and sorting VPS instances. - Implemented a ConfirmDialog component for destructive actions confirmation. - Introduced a Modal component for displaying modals with focus management. - Created a Toast component for displaying notifications with different types (success, error, info). - Refactored VpsCard to utilize new Metric component for displaying CPU and RAM usage. - Improved user experience with local storage for collapsed state in VpsCard. - Added clipboard utility functions for copying text and downloading content. - Enhanced CSS styles for better dark mode support and animations. - Updated various UI controls for consistency and improved accessibility.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { ServerCrash, SearchX, Plus, RefreshCw } from 'lucide-react'
|
||||
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client'
|
||||
import Header from './components/Header'
|
||||
import VpsCard from './components/VpsCard'
|
||||
@@ -9,6 +10,11 @@ import StatsModal from './components/StatsModal'
|
||||
import LoginPage from './components/LoginPage'
|
||||
import ProfilePage from './components/ProfilePage'
|
||||
import AdminPage from './components/AdminPage'
|
||||
import DashboardToolbar from './components/DashboardToolbar'
|
||||
import ConfirmDialog from './components/ui/ConfirmDialog'
|
||||
import { useToast } from './components/ui/Toast'
|
||||
import { Button, EmptyState, Skeleton } from './components/ui/controls'
|
||||
import { copyText, downloadText } from './lib/clipboard'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ label: '10 s', value: 10_000 },
|
||||
@@ -19,7 +25,35 @@ const INTERVAL_OPTIONS = [
|
||||
{ label: 'Off', value: 0 },
|
||||
]
|
||||
|
||||
const ACTION_LABELS = {
|
||||
start: 'démarré',
|
||||
stop: 'arrêté',
|
||||
restart: 'redémarré',
|
||||
}
|
||||
|
||||
/** Un VPS « à problème » : injoignable, conteneur non démarré ou en mauvaise santé. */
|
||||
function hasIssue(vps) {
|
||||
if (!vps.online) return true
|
||||
if (vps.containers.some(c => c.status !== 'running' || c.health === 'unhealthy')) return true
|
||||
return (vps.services ?? []).some(s => s.active === 'failed')
|
||||
}
|
||||
|
||||
/** Champs pris en compte par la recherche libre. */
|
||||
function vpsMatchesQuery(vps, query) {
|
||||
const haystack = [
|
||||
vps.name,
|
||||
vps.host,
|
||||
vps.description,
|
||||
...(vps.tags ?? []),
|
||||
...vps.containers.flatMap(c => [c.name, c.image, c.compose_project]),
|
||||
...(vps.services ?? []).map(s => s.name),
|
||||
]
|
||||
return haystack.filter(Boolean).some(value => value.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const toast = useToast()
|
||||
|
||||
const [token, setTokenState] = useState(() => getToken())
|
||||
const [username, setUsername] = useState(null)
|
||||
const [role, setRole] = useState(null)
|
||||
@@ -38,16 +72,46 @@ export default function App() {
|
||||
const [logsLoading, setLogsLoading] = useState(false)
|
||||
const [showAddVps, setShowAddVps] = useState(false)
|
||||
const [editVps, setEditVps] = useState(null) // objet vps à éditer
|
||||
const [deleteTarget, setDeleteTarget] = useState(null) // vps en attente de confirmation
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const [refreshInterval, setRefreshInterval] = useState(() => {
|
||||
const stored = localStorage.getItem('refreshInterval')
|
||||
return stored ? parseInt(stored, 10) : 30_000
|
||||
})
|
||||
|
||||
// ─── Filtres du tableau de bord ─────────────────────────────────────────
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState(() => localStorage.getItem('filterStatus') ?? 'all')
|
||||
const [activeTags, setActiveTags] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem('filterTags') ?? '[]') } catch { return [] }
|
||||
})
|
||||
const [sort, setSort] = useState(() => localStorage.getItem('sortBy') ?? 'name')
|
||||
const searchRef = useRef(null)
|
||||
|
||||
const handleIntervalChange = (val) => {
|
||||
setRefreshInterval(val)
|
||||
localStorage.setItem('refreshInterval', val)
|
||||
}
|
||||
|
||||
const handleStatusChange = (val) => {
|
||||
setStatus(val)
|
||||
localStorage.setItem('filterStatus', val)
|
||||
}
|
||||
|
||||
const handleSortChange = (val) => {
|
||||
setSort(val)
|
||||
localStorage.setItem('sortBy', val)
|
||||
}
|
||||
|
||||
const handleToggleTag = (tag) => {
|
||||
setActiveTags(prev => {
|
||||
const next = prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
|
||||
localStorage.setItem('filterTags', JSON.stringify(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const [updateModal, setUpdateModal] = useState(null) // { vpsId, project }
|
||||
const [updateContent, setUpdateContent] = useState('')
|
||||
const [updateLoading, setUpdateLoading] = useState(false)
|
||||
@@ -135,6 +199,28 @@ export default function App() {
|
||||
}
|
||||
}, [token, username])
|
||||
|
||||
// Raccourcis clavier : « / » cible la recherche, « r » actualise.
|
||||
const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget)
|
||||
useEffect(() => {
|
||||
if (!token || page !== 'main' || modalOpen) return
|
||||
const onKeyDown = (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||
const el = e.target
|
||||
if (el instanceof HTMLElement &&
|
||||
(el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return
|
||||
|
||||
if (e.key === '/') {
|
||||
e.preventDefault()
|
||||
searchRef.current?.focus()
|
||||
} else if (e.key.toLowerCase() === 'r') {
|
||||
e.preventDefault()
|
||||
refresh(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [token, page, modalOpen, refresh])
|
||||
|
||||
const openLogs = async (vpsId, containerId, name) => {
|
||||
setLogsModal({ vpsId, containerId, name })
|
||||
setLogsLoading(true)
|
||||
@@ -150,8 +236,13 @@ export default function App() {
|
||||
}
|
||||
|
||||
const handleAction = async (vpsId, containerId, action) => {
|
||||
try {
|
||||
await containerAction(vpsId, containerId, action)
|
||||
toast.success(`Conteneur ${ACTION_LABELS[action] ?? action}.`)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
toast.error(`Action « ${action} » impossible : ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (vpsId, project) => {
|
||||
@@ -161,8 +252,10 @@ export default function App() {
|
||||
try {
|
||||
const data = await composeUpdate(vpsId, project)
|
||||
setUpdateContent(data.output || '(aucune sortie)')
|
||||
toast.success(`Projet « ${project} » mis à jour.`)
|
||||
} catch (e) {
|
||||
setUpdateContent(`Erreur lors de la mise à jour :\n${e.message}`)
|
||||
toast.error(`Mise à jour de « ${project} » échouée.`)
|
||||
} finally {
|
||||
setUpdateLoading(false)
|
||||
await refresh()
|
||||
@@ -176,8 +269,10 @@ export default function App() {
|
||||
try {
|
||||
await updateAgent(vpsId)
|
||||
setUpdateContent('Mise à jour lancée. L\'agent va redémarrer dans quelques secondes.\nActualisez dans un moment pour vérifier la nouvelle version.')
|
||||
toast.info('Mise à jour de l\'agent lancée.')
|
||||
} catch (e) {
|
||||
setUpdateContent(`Erreur lors de la mise à jour de l'agent :\n${e.message}`)
|
||||
toast.error(`Mise à jour de l'agent échouée : ${e.message}`)
|
||||
} finally {
|
||||
setUpdateLoading(false)
|
||||
setTimeout(() => refresh(), 8000)
|
||||
@@ -187,25 +282,78 @@ export default function App() {
|
||||
const handleAddVps = async (formData) => {
|
||||
await addVps(formData)
|
||||
setShowAddVps(false)
|
||||
toast.success(`VPS « ${formData.name} » ajouté.`)
|
||||
await refresh(true)
|
||||
}
|
||||
|
||||
const handleDeleteVps = async (vpsId) => {
|
||||
if (!window.confirm('Supprimer ce VPS de la configuration ?')) return
|
||||
await deleteVps(vpsId)
|
||||
const handleDeleteVps = async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteVps(deleteTarget.id)
|
||||
toast.success(`VPS « ${deleteTarget.name} » supprimé.`)
|
||||
setDeleteTarget(null)
|
||||
await refresh(true)
|
||||
} catch (e) {
|
||||
toast.error(`Suppression impossible : ${e.message}`)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditVps = async (vpsId, data) => {
|
||||
await updateVps(vpsId, data)
|
||||
setEditVps(null)
|
||||
toast.success('Configuration enregistrée.')
|
||||
await refresh(true)
|
||||
}
|
||||
|
||||
/** Copie la config d'un VPS ; retombe sur un téléchargement si le presse-papiers est bloqué. */
|
||||
const handleExportVps = async (vpsId) => {
|
||||
try {
|
||||
const config = await exportVps(vpsId)
|
||||
await navigator.clipboard.writeText(JSON.stringify(config, null, 2))
|
||||
const json = JSON.stringify(config, null, 2)
|
||||
if (await copyText(json)) {
|
||||
toast.success('Configuration copiée dans le presse-papiers.')
|
||||
return true
|
||||
}
|
||||
downloadText(`${vpsId}.json`, json, 'application/json')
|
||||
toast.info('Presse-papiers indisponible (connexion non sécurisée) — configuration téléchargée.')
|
||||
return false
|
||||
} catch (e) {
|
||||
toast.error(`Export impossible : ${e.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Liste filtrée et triée ─────────────────────────────────────────────
|
||||
const allTags = useMemo(
|
||||
() => [...new Set(vpsList.flatMap(v => v.tags ?? []))].sort((a, b) => a.localeCompare(b, 'fr')),
|
||||
[vpsList],
|
||||
)
|
||||
|
||||
const visibleVps = useMemo(() => {
|
||||
const query = search.trim().toLowerCase()
|
||||
|
||||
const filtered = vpsList.filter(vps => {
|
||||
if (status === 'online' && !vps.online) return false
|
||||
if (status === 'offline' && vps.online) return false
|
||||
if (status === 'issues' && !hasIssue(vps)) return false
|
||||
if (activeTags.length > 0 && !activeTags.every(t => (vps.tags ?? []).includes(t))) return false
|
||||
if (query && !vpsMatchesQuery(vps, query)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name, 'fr')
|
||||
const sorters = {
|
||||
name: byName,
|
||||
status: (a, b) => (Number(b.online) - Number(a.online)) || (Number(hasIssue(a)) - Number(hasIssue(b))) || byName(a, b),
|
||||
cpu: (a, b) => (b.system?.cpu_percent ?? -1) - (a.system?.cpu_percent ?? -1) || byName(a, b),
|
||||
ram: (a, b) => (b.system?.ram_percent ?? -1) - (a.system?.ram_percent ?? -1) || byName(a, b),
|
||||
containers: (a, b) => b.containers.length - a.containers.length || byName(a, b),
|
||||
}
|
||||
return [...filtered].sort(sorters[sort] ?? byName)
|
||||
}, [vpsList, search, status, activeTags, sort])
|
||||
|
||||
// Attente vérification auth
|
||||
if (!authChecked) return null
|
||||
@@ -225,6 +373,7 @@ export default function App() {
|
||||
const totalOnline = vpsList.filter(v => v.online).length
|
||||
const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0)
|
||||
const totalRunning = vpsList.reduce((acc, v) => acc + v.containers.filter(c => c.status === 'running').length, 0)
|
||||
const totalIssues = vpsList.filter(hasIssue).length
|
||||
|
||||
// ─── Pages profil / admin ───────────────────────────────────────────────
|
||||
if (page === 'profile') {
|
||||
@@ -252,65 +401,111 @@ export default function App() {
|
||||
intervalOptions={INTERVAL_OPTIONS}
|
||||
/>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<main className="max-w-7xl mx-auto px-4 py-6 sm:py-8">
|
||||
|
||||
{/* Barre d'erreur backend */}
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-950/40 border border-red-800/50 rounded-xl px-4 py-3 text-sm text-red-300">
|
||||
Impossible de joindre le backend : <span className="font-mono">{error}</span>
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3 bg-red-950/40 border border-red-800/50 rounded-xl px-4 py-3 text-sm text-red-300">
|
||||
<span className="flex-1 min-w-[240px]">
|
||||
Impossible de joindre le backend : <span className="font-mono text-xs">{error}</span>
|
||||
</span>
|
||||
<Button variant="outline" size="sm" icon={RefreshCw} onClick={() => refresh(true)} loading={refreshing}>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats globales */}
|
||||
{!loading && vpsList.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
||||
{[
|
||||
{ label: 'VPS en ligne', value: `${totalOnline}/${vpsList.length}`, color: 'text-emerald-400' },
|
||||
{ label: 'Conteneurs actifs', value: `${totalRunning}/${totalContainers}`, color: 'text-indigo-400' },
|
||||
{ label: 'À surveiller', value: String(totalIssues), color: totalIssues > 0 ? 'text-orange-400' : 'text-gray-400' },
|
||||
{ label: 'Actualisation auto', value: INTERVAL_OPTIONS.find(o => o.value === refreshInterval)?.label ?? 'Off', color: 'text-gray-400' },
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label} className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
|
||||
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
||||
<p className={`text-2xl font-bold tabular-nums ${color}`}>{value}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chargement initial */}
|
||||
{loading && (
|
||||
<div className="text-center py-24 text-gray-600">
|
||||
<svg className="w-8 h-8 animate-spin mx-auto mb-3 text-indigo-500" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Chargement…
|
||||
</div>
|
||||
{/* Filtres */}
|
||||
{!loading && vpsList.length > 0 && (
|
||||
<DashboardToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchRef={searchRef}
|
||||
status={status}
|
||||
onStatusChange={handleStatusChange}
|
||||
tags={allTags}
|
||||
activeTags={activeTags}
|
||||
onToggleTag={handleToggleTag}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
shown={visibleVps.length}
|
||||
total={vpsList.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Aucun VPS */}
|
||||
{!loading && vpsList.length === 0 && !error && (
|
||||
<div className="text-center py-24 text-gray-600">
|
||||
<p className="text-lg font-medium text-gray-500">Aucun VPS configuré</p>
|
||||
<p className="text-sm mt-1">Cliquez sur <strong className="text-gray-400">Ajouter un VPS</strong> pour commencer.</p>
|
||||
<button
|
||||
onClick={() => setShowAddVps(true)}
|
||||
className="mt-6 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-sm transition-colors"
|
||||
>
|
||||
Ajouter un VPS
|
||||
</button>
|
||||
{/* Chargement initial — squelettes */}
|
||||
{loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
||||
{Array.from({ length: 4 }, (_, i) => <Skeleton key={i} className="h-[68px]" />)}
|
||||
</div>
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{Array.from({ length: 2 }, (_, i) => <Skeleton key={i} className="h-64" />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Aucun VPS configuré */}
|
||||
{!loading && vpsList.length === 0 && !error && (
|
||||
<EmptyState
|
||||
icon={ServerCrash}
|
||||
title="Aucun VPS configuré"
|
||||
description="Ajoutez un premier serveur pour suivre ses conteneurs, ses services et sa charge en temps réel."
|
||||
action={
|
||||
<Button variant="primary" size="lg" icon={Plus} onClick={() => setShowAddVps(true)}>
|
||||
Ajouter un VPS
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Aucun résultat après filtrage */}
|
||||
{!loading && vpsList.length > 0 && visibleVps.length === 0 && (
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="Aucun VPS ne correspond"
|
||||
description="Aucun serveur ne correspond à la recherche ou aux filtres actifs."
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setSearch(''); handleStatusChange('all'); setActiveTags([]); localStorage.setItem('filterTags', '[]') }}
|
||||
>
|
||||
Réinitialiser les filtres
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Grille de VPS */}
|
||||
{!loading && vpsList.length > 0 && (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{vpsList.map(vps => (
|
||||
{/* `items-start` : chaque carte garde sa hauteur naturelle plutôt que
|
||||
d'être étirée à celle de la plus haute de sa rangée. */}
|
||||
{!loading && visibleVps.length > 0 && (
|
||||
<div className="grid gap-5 lg:grid-cols-2 items-start">
|
||||
{visibleVps.map(vps => (
|
||||
<VpsCard
|
||||
key={vps.id}
|
||||
vps={vps}
|
||||
query={search.trim().toLowerCase()}
|
||||
onAction={handleAction}
|
||||
onLogs={openLogs}
|
||||
onDelete={handleDeleteVps}
|
||||
onDelete={() => setDeleteTarget(vps)}
|
||||
onUpdate={handleUpdate}
|
||||
onEdit={setEditVps}
|
||||
onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
|
||||
@@ -335,7 +530,7 @@ export default function App() {
|
||||
{/* Modal mise à jour compose */}
|
||||
{updateModal && (
|
||||
<LogsModal
|
||||
name={`🔄 Update — ${updateModal.project}`}
|
||||
name={`Mise à jour — ${updateModal.project}`}
|
||||
logs={updateContent}
|
||||
loading={updateLoading}
|
||||
onClose={() => setUpdateModal(null)}
|
||||
@@ -367,6 +562,25 @@ export default function App() {
|
||||
onClose={() => setStatsModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirmation de suppression */}
|
||||
{deleteTarget && (
|
||||
<ConfirmDialog
|
||||
danger
|
||||
title="Supprimer ce VPS ?"
|
||||
message={
|
||||
<>
|
||||
<span className="text-gray-200 font-medium">{deleteTarget.name}</span>{' '}
|
||||
(<span className="font-mono text-xs">{deleteTarget.host}</span>) sera retiré de la
|
||||
configuration. Les conteneurs du serveur ne sont pas touchés.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Supprimer"
|
||||
loading={deleting}
|
||||
onConfirm={handleDeleteVps}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Upload } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Upload, ServerCog } from 'lucide-react'
|
||||
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 FIELDS = [
|
||||
{ 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: 'port', label: 'Port agent', placeholder: '8001', required: true, type: 'number' },
|
||||
{ key: 'api_key', label: 'Clé API agent', placeholder: '••••••••', required: true, type: 'password' },
|
||||
@@ -21,12 +23,6 @@ export default function AddVpsModal({ onSave, onClose }) {
|
||||
const [json, setJson] = 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 handleImportJson = () => {
|
||||
@@ -67,18 +63,18 @@ export default function AddVpsModal({ onSave, onClose }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="w-full max-w-md bg-gray-900 border border-gray-700 rounded-xl shadow-2xl">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-sm">Ajouter un VPS</h3>
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-700 text-xs">
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Ajouter un VPS"
|
||||
subtitle={mode === 'import' ? 'Importer une configuration exportée' : 'Saisie manuelle'}
|
||||
icon={<ServerCog size={16} className="text-indigo-400 flex-shrink-0" />}
|
||||
onClose={onClose}
|
||||
headerRight={
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-700 text-xs" role="group" aria-label="Mode d'ajout">
|
||||
<button
|
||||
type="button"
|
||||
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'}`}
|
||||
>
|
||||
Manuel
|
||||
@@ -86,20 +82,17 @@ export default function AddVpsModal({ onSave, onClose }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('import')}
|
||||
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
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
}
|
||||
>
|
||||
{mode === 'import' ? (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
@@ -108,52 +101,56 @@ export default function AddVpsModal({ onSave, onClose }) {
|
||||
onChange={(e) => setJson(e.target.value)}
|
||||
rows={10}
|
||||
placeholder='{"id": "vps-1", "name": "Mon VPS", ...}'
|
||||
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"
|
||||
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">
|
||||
<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>
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
<Button variant="outline" size="lg" className="flex-1" onClick={onClose}>
|
||||
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} />
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" className="flex-1" icon={Upload} onClick={handleImportJson} disabled={!json.trim()}>
|
||||
Importer
|
||||
</button>
|
||||
</Button>
|
||||
</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">
|
||||
{FIELDS.map(({ key, label, placeholder, required, type, hint }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-gray-400 mb-1">
|
||||
{label} {required && <span className="text-red-400">*</span>}
|
||||
<label htmlFor={`add-${key}`} className="block text-xs text-gray-400 mb-1">
|
||||
{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
|
||||
id={`add-${key}`}
|
||||
type={type}
|
||||
value={form[key]}
|
||||
onChange={set(key)}
|
||||
placeholder={placeholder}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
{hint && <p className="text-[11px] text-gray-600 mt-1">{hint}</p>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2">
|
||||
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
@@ -161,28 +158,19 @@ export default function AddVpsModal({ onSave, onClose }) {
|
||||
<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>
|
||||
<p className="text-[11px] text-gray-600 mt-1">Entrée ou virgule pour valider</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 rounded-lg border border-gray-700 hover:bg-gray-800 text-sm transition-colors"
|
||||
>
|
||||
<Button type="button" variant="outline" size="lg" className="flex-1" onClick={onClose}>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm transition-colors font-medium"
|
||||
>
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="lg" className="flex-1" loading={saving}>
|
||||
{saving ? 'Enregistrement…' : 'Ajouter'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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 ConfirmDialog from './ui/ConfirmDialog'
|
||||
import { inputClass } from './ui/controls'
|
||||
|
||||
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>
|
||||
<p className="text-sm font-medium text-gray-200">{label}</p>
|
||||
@@ -13,6 +16,9 @@ function ToggleRow({ label, description, enabled, onChange, loading }) { return
|
||||
<button
|
||||
onClick={onChange}
|
||||
disabled={loading}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={label}
|
||||
title={enabled ? 'Désactiver' : 'Activer'}
|
||||
className="flex-shrink-0 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
@@ -266,6 +272,7 @@ export default function AdminPage({ onBack }) {
|
||||
// Custom range
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [customTable, setCustomTable] = useState('all')
|
||||
|
||||
const loadDbInfo = useCallback(async () => {
|
||||
setDbInfoLoading(true)
|
||||
@@ -350,7 +357,7 @@ export default function AdminPage({ onBack }) {
|
||||
</div>
|
||||
|
||||
{/* ── 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: 'notifications', label: 'Notifications' },
|
||||
@@ -361,7 +368,9 @@ export default function AdminPage({ onBack }) {
|
||||
<button
|
||||
key={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
|
||||
? 'border-indigo-500 text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||
@@ -446,18 +455,20 @@ export default function AdminPage({ onBack }) {
|
||||
{/* Credentials form */}
|
||||
<div className="space-y-3">
|
||||
<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">
|
||||
<input
|
||||
id="pushover-token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={pushoverToken}
|
||||
onChange={e => setPushoverToken(e.target.value)}
|
||||
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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{showToken ? <EyeOff size={13} /> : <Eye size={13} />}
|
||||
@@ -465,18 +476,20 @@ export default function AdminPage({ onBack }) {
|
||||
</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">
|
||||
<input
|
||||
id="pushover-user-key"
|
||||
type={showUserKey ? 'text' : 'password'}
|
||||
value={pushoverUserKey}
|
||||
onChange={e => setPushoverUserKey(e.target.value)}
|
||||
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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{showUserKey ? <EyeOff size={13} /> : <Eye size={13} />}
|
||||
@@ -908,10 +921,11 @@ export default function AdminPage({ onBack }) {
|
||||
/>
|
||||
</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
|
||||
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"
|
||||
>
|
||||
<option value="all">Toutes</option>
|
||||
@@ -922,10 +936,9 @@ export default function AdminPage({ onBack }) {
|
||||
<button
|
||||
disabled={!customFrom || !customTo || purgeLoading}
|
||||
onClick={() => {
|
||||
const tbl = document.getElementById('custom-table').value
|
||||
const fromTs = Math.floor(new Date(customFrom).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"
|
||||
>
|
||||
@@ -939,38 +952,22 @@ export default function AdminPage({ onBack }) {
|
||||
|
||||
{/* ── Modale de confirmation de purge ── */}
|
||||
{confirmState && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-2xl p-6 max-w-sm w-full shadow-2xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-xl bg-red-500/15">
|
||||
<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">
|
||||
<ConfirmDialog
|
||||
danger
|
||||
title="Confirmer la suppression"
|
||||
message={
|
||||
<>
|
||||
Vous êtes sur le point de supprimer{' '}
|
||||
<span className="text-gray-200 font-medium">{periodLabel[confirmState.period]}</span>{' '}
|
||||
dans{' '}
|
||||
<span className="text-gray-200 font-medium">{tableLabel[confirmState.table]}</span>.
|
||||
dans <span className="text-gray-200 font-medium">{tableLabel[confirmState.table]}</span>.
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setConfirmState(null)}
|
||||
className="px-4 py-2 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
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>
|
||||
</>
|
||||
}
|
||||
confirmLabel={purgeLoading ? 'Suppression…' : 'Supprimer'}
|
||||
loading={purgeLoading}
|
||||
onConfirm={confirmPurge}
|
||||
onCancel={() => setConfirmState(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Play, Square, RotateCcw, FileText, Loader2, Heart } from 'lucide-react'
|
||||
import StatusBadge from './StatusBadge'
|
||||
|
||||
const HEALTH_STYLES = {
|
||||
healthy: { dot: 'bg-emerald-400', 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' },
|
||||
starting: { dot: 'bg-yellow-400 animate-pulse', text: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20', label: 'starting' },
|
||||
healthy: { text: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', label: 'healthy' },
|
||||
unhealthy: { text: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20', label: 'unhealthy' },
|
||||
starting: { text: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20', label: 'starting' },
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const [pending, setPending] = useState(null)
|
||||
const isRunning = container.status === 'running'
|
||||
const ports = useMemo(() => hostPorts(container.ports), [container.ports])
|
||||
|
||||
const handle = async (action) => {
|
||||
setPending(action)
|
||||
try { await onAction(action) } finally { setPending(null) }
|
||||
}
|
||||
|
||||
const createdLabel = container.created
|
||||
? `Créé le ${new Date(container.created).toLocaleString('fr-FR')}`
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<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="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium truncate max-w-[160px]">{container.name}</span>
|
||||
<StatusBadge status={container.status} /> <HealthBadge health={container.health} /> {container.compose_project && (
|
||||
<span className="text-sm font-medium truncate max-w-[160px]" title={createdLabel}>
|
||||
{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">
|
||||
{container.compose_project}
|
||||
</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>
|
||||
<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 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 && (
|
||||
<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} />
|
||||
</ActionBtn>
|
||||
)}
|
||||
{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} />
|
||||
</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} />
|
||||
</ActionBtn>
|
||||
<ActionBtn title="Logs" onClick={onLogs}>
|
||||
<ActionBtn title={`Logs de ${container.name}`} onClick={onLogs}>
|
||||
<FileText size={13} />
|
||||
</ActionBtn>
|
||||
</div>
|
||||
@@ -69,6 +104,7 @@ function ActionBtn({ children, onClick, title, danger = false, loading = false }
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
disabled={loading}
|
||||
className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
|
||||
danger
|
||||
|
||||
117
vps-monitor/frontend/src/components/DashboardToolbar.jsx
Normal file
117
vps-monitor/frontend/src/components/DashboardToolbar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import TagInput from './TagInput'
|
||||
import Modal from './ui/Modal'
|
||||
import { Button, inputClass, PasswordInput } from './ui/controls'
|
||||
|
||||
const FIELDS = [
|
||||
{ key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' },
|
||||
@@ -22,12 +24,6 @@ export default function EditVpsModal({ vps, onSave, onClose }) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
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 handleSubmit = async (e) => {
|
||||
@@ -43,40 +39,44 @@ export default function EditVpsModal({ vps, onSave, onClose }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
<Modal
|
||||
size="sm"
|
||||
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">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Modifier le VPS</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5 font-mono">{vps.id}</p>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-3">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{FIELDS.map(({ key, label, placeholder, required, type }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-gray-400 mb-1">
|
||||
{label} {required && <span className="text-red-400">*</span>}
|
||||
<label htmlFor={`edit-${key}`} className="block text-xs text-gray-400 mb-1">
|
||||
{label} {required && <span className="text-red-400" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
{type === 'password' ? (
|
||||
<PasswordInput
|
||||
id={`edit-${key}`}
|
||||
value={form[key]}
|
||||
onChange={set(key)}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
autoComplete="off"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`edit-${key}`}
|
||||
type={type}
|
||||
value={form[key]}
|
||||
onChange={set(key)}
|
||||
placeholder={placeholder}
|
||||
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">
|
||||
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
@@ -84,27 +84,18 @@ export default function EditVpsModal({ vps, onSave, onClose }) {
|
||||
<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>
|
||||
<p className="text-[11px] text-gray-600 mt-1">Entrée ou virgule pour valider</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 rounded-lg border border-gray-700 hover:bg-gray-800 text-sm transition-colors"
|
||||
>
|
||||
<Button type="button" variant="outline" size="lg" className="flex-1" onClick={onClose}>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm transition-colors font-medium"
|
||||
>
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="lg" className="flex-1" loading={saving}>
|
||||
{saving ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
// 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 (
|
||||
<header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/15">
|
||||
<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 py-2 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/15 flex-shrink-0">
|
||||
<Monitor size={18} className="text-indigo-400" />
|
||||
</div>
|
||||
<span className="font-semibold">VPS Monitor</span>
|
||||
<span className="font-semibold truncate">VPS Monitor</span>
|
||||
{lastUpdate && (
|
||||
<span className="hidden sm:block text-xs text-gray-500 ml-2">
|
||||
· mis à jour {lastUpdate.toLocaleTimeString('fr-FR')}
|
||||
<span
|
||||
className="hidden md:block text-xs text-gray-500 ml-1 whitespace-nowrap"
|
||||
title={`Dernière actualisation à ${lastUpdate.toLocaleTimeString('fr-FR')}`}
|
||||
>
|
||||
· {formatRelative(lastUpdate)}
|
||||
</span>
|
||||
)}
|
||||
</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 */}
|
||||
<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" />
|
||||
<select
|
||||
value={refreshInterval}
|
||||
onChange={e => onIntervalChange(Number(e.target.value))}
|
||||
className="bg-transparent text-gray-300 text-xs outline-none cursor-pointer"
|
||||
aria-label="Intervalle d'actualisation automatique"
|
||||
title="Intervalle d'actualisation automatique"
|
||||
>
|
||||
{intervalOptions.map(o => (
|
||||
@@ -35,26 +50,22 @@ export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, us
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
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
|
||||
className={`w-3.5 h-3.5 ${refreshing ? '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>
|
||||
Actualiser
|
||||
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} />
|
||||
<span className="hidden lg:inline">Actualiser</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Ajouter un VPS
|
||||
<Plus size={15} />
|
||||
<span className="hidden lg:inline">Ajouter un VPS</span>
|
||||
</button>
|
||||
|
||||
{username && (
|
||||
@@ -62,25 +73,28 @@ export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, us
|
||||
{role === 'admin' && (
|
||||
<button
|
||||
onClick={onAdmin}
|
||||
aria-label="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} />
|
||||
<span className="hidden sm:inline">Admin</span>
|
||||
<span className="hidden lg:inline">Admin</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onProfile}
|
||||
aria-label={`Profil de ${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} />
|
||||
<span className="hidden sm:inline">{username}</span>
|
||||
<User size={14} className="flex-shrink-0" />
|
||||
<span className="hidden sm:inline truncate">{username}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
aria-label="Se déconnecter"
|
||||
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} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Monitor, Fingerprint } from 'lucide-react'
|
||||
import { login, register, loginWithPasskey } from '../api/client'
|
||||
import { inputClass, PasswordInput } from './ui/controls'
|
||||
|
||||
export default function LoginPage({ isFirstUser, passkeyEnabled, onAuthenticated }) {
|
||||
const [username, setUsername] = useState('')
|
||||
@@ -90,47 +91,46 @@ export default function LoginPage({ isFirstUser, passkeyEnabled, onAuthenticated
|
||||
)}
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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
|
||||
id="login-username"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
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>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Mot de passe</label>
|
||||
<input
|
||||
type="password"
|
||||
<label htmlFor="login-password" className="block text-xs text-gray-400 mb-1.5">Mot de passe</label>
|
||||
<PasswordInput
|
||||
id="login-password"
|
||||
required
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
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>
|
||||
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Confirmer le mot de passe</label>
|
||||
<input
|
||||
type="password"
|
||||
<label htmlFor="login-password2" className="block text-xs text-gray-400 mb-1.5">Confirmer le mot de passe</label>
|
||||
<PasswordInput
|
||||
id="login-password2"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
value={password2}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -1,79 +1,122 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { X, Download } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from '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 }) {
|
||||
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(() => {
|
||||
if (!loading && bottomRef.current) {
|
||||
if (!loading && !filter && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [logs, loading])
|
||||
}, [logs, loading, filter])
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([logs], { type: 'text/plain' })
|
||||
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)
|
||||
downloadText(`${name.replace(/[^a-z0-9]/gi, '_')}.log`, logs)
|
||||
}
|
||||
|
||||
// Fermeture sur Échap
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
const handleCopy = async () => {
|
||||
if (await copyText(shown.join('\n'))) {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const hasLogs = !loading && logs
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
<Modal
|
||||
size="xl"
|
||||
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'}`}
|
||||
>
|
||||
<div className="w-full max-w-4xl bg-gray-900 border border-gray-700 rounded-xl flex flex-col max-h-[85vh] shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700 flex-shrink-0">
|
||||
<h3 className="font-mono text-sm text-gray-300 truncate">📄 {name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{logs && (
|
||||
<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}
|
||||
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"
|
||||
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={12} />
|
||||
Télécharger
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{/* Filtre */}
|
||||
{hasLogs && (
|
||||
<div className="relative px-4 py-2 border-b border-gray-800 flex-shrink-0">
|
||||
<Search size={13} className="absolute left-7 top-1/2 -translate-y-1/2 text-gray-600 pointer-events-none" />
|
||||
<input
|
||||
type="search"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
placeholder="Filtrer les lignes…"
|
||||
aria-label="Filtrer les lignes de log"
|
||||
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"
|
||||
/>
|
||||
{filter && (
|
||||
<button
|
||||
onClick={() => setFilter('')}
|
||||
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={13} />
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs */}
|
||||
<div className="flex-1 overflow-auto bg-gray-950 rounded-b-xl p-4">
|
||||
{/* Contenu */}
|
||||
<div className="flex-1 overflow-auto bg-gray-950 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 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 whitespace-pre-wrap leading-5 break-all">
|
||||
{logs || '(aucun log disponible)'}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { KeyRound, ArrowLeft, Check, Fingerprint, Plus, Trash2, Key } from 'lucide-react'
|
||||
import { changePassword, getMyPasskeys, deleteMyPasskey, registerPasskey } from '../api/client'
|
||||
import { inputClass, PasswordInput } from './ui/controls'
|
||||
|
||||
export default function ProfilePage({ username, onBack }) {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
@@ -136,45 +137,42 @@ export default function ProfilePage({ username, onBack }) {
|
||||
)}
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Mot de passe actuel</label>
|
||||
<input
|
||||
type="password"
|
||||
<label htmlFor="current-password" className="block text-xs text-gray-400 mb-1.5">Mot de passe actuel</label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={oldPassword}
|
||||
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>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Nouveau mot de passe</label>
|
||||
<input
|
||||
type="password"
|
||||
<label htmlFor="new-password" className="block text-xs text-gray-400 mb-1.5">Nouveau mot de passe</label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
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>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Confirmer le nouveau mot de passe</label>
|
||||
<input
|
||||
type="password"
|
||||
<label htmlFor="new-password-2" className="block text-xs text-gray-400 mb-1.5">Confirmer le nouveau mot de passe</label>
|
||||
<PasswordInput
|
||||
id="new-password-2"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
value={newPassword2}
|
||||
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>
|
||||
|
||||
@@ -247,10 +245,11 @@ export default function ProfilePage({ username, onBack }) {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nom de l'appareil (ex : MacBook)"
|
||||
aria-label="Nom de l'appareil pour la nouvelle passkey"
|
||||
value={addName}
|
||||
onChange={e => setAddName(e.target.value)}
|
||||
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
|
||||
type="submit"
|
||||
|
||||
@@ -1,29 +1,9 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { X, BarChart2, Cpu, MemoryStick, ArrowUp, ArrowDown, TrendingUp } from 'lucide-react'
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { BarChart2, Cpu, MemoryStick, ArrowUp, ArrowDown, TrendingUp } from 'lucide-react'
|
||||
import { fetchVpsStats } from '../api/client'
|
||||
|
||||
// ─── Formatters ───────────────────────────────────────────────────────────────
|
||||
|
||||
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`
|
||||
}
|
||||
import Modal from './ui/Modal'
|
||||
import { Skeleton } from './ui/controls'
|
||||
import { formatBps, formatBytes, formatRam, formatClock } from '../lib/format'
|
||||
|
||||
function avg(arr) {
|
||||
if (!arr.length) return '—'
|
||||
@@ -48,26 +28,36 @@ function autoRange(data, absMin = 0, absMax = 100, minRange = 8) {
|
||||
|
||||
// ─── SVG Sparkline ────────────────────────────────────────────────────────────
|
||||
|
||||
function Sparkline({ data, min = 0, max = 100, color, fill, height = 60 }) {
|
||||
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 W = 500
|
||||
const P = 3
|
||||
|
||||
function Sparkline({ data, timestamps = [], min = 0, max = 100, color, fill, height = 60 }) {
|
||||
// Les hooks doivent précéder tout retour anticipé — sinon leur nombre change
|
||||
// entre deux rendus dès que la série passe de vide à non vide.
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
|
||||
const W = 500
|
||||
const H = height
|
||||
const P = 3
|
||||
const range = (max - min) || 1
|
||||
|
||||
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 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 areaPts = [
|
||||
`${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)}`,
|
||||
].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 (
|
||||
<div className="relative" style={{ height }}>
|
||||
<svg
|
||||
@@ -94,41 +74,21 @@ function Sparkline({ data, min = 0, max = 100, color, fill, height = 60 }) {
|
||||
style={{ height }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
role="img"
|
||||
aria-label={`Série de ${data.length} points, de ${min}% à ${max}%`}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{[25, 50, 75].map(pct => {
|
||||
const y = sy(min + range * pct / 100)
|
||||
return (
|
||||
<line
|
||||
key={pct}
|
||||
x1={P} y1={y.toFixed(1)}
|
||||
x2={W - P} y2={y.toFixed(1)}
|
||||
stroke="#1f2937" strokeWidth="1"
|
||||
/>
|
||||
<line 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" />
|
||||
<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 && (
|
||||
<circle
|
||||
cx={sx(tooltip.idx).toFixed(1)}
|
||||
cy={sy(tooltip.value).toFixed(1)}
|
||||
r="3"
|
||||
fill={color}
|
||||
/>
|
||||
<circle cx={sx(tooltip.idx).toFixed(1)} cy={sy(tooltip.value).toFixed(1)} r="3" fill={color} />
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Tooltip bubble */}
|
||||
{tooltip && (
|
||||
<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"
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
// Dual sparkline (upload + download on same chart)
|
||||
function DualSparkline({ sentData, recvData, height = 60 }) {
|
||||
const allValues = [...sentData, ...recvData]
|
||||
const maxVal = Math.max(...allValues, 1) * 1.15
|
||||
const W = 500, H = height, P = 3
|
||||
// Dual sparkline (upload + download sur le même graphique)
|
||||
function DualSparkline({ sentData, recvData, timestamps = [], height = 60 }) {
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
|
||||
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 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)}`,
|
||||
].join(' ')
|
||||
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!sentData.length) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const xRatio = (e.clientX - rect.left) / rect.width
|
||||
const idx = Math.min(sentData.length - 1, Math.max(0, Math.round(xRatio * (sentData.length - 1))))
|
||||
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 (
|
||||
<div className="relative" style={{ height }}>
|
||||
<svg
|
||||
@@ -185,6 +154,8 @@ function DualSparkline({ sentData, recvData, height = 60 }) {
|
||||
style={{ height }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
role="img"
|
||||
aria-label="Bande passante montante et descendante"
|
||||
>
|
||||
{[25, 50, 75].map(pct => (
|
||||
<line
|
||||
@@ -198,7 +169,7 @@ function DualSparkline({ sentData, recvData, height = 60 }) {
|
||||
<polyline points={mkLine(sentData)} fill="none" stroke="#38bdf8" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<polygon points={mkArea(recvData)} fill="#a78bfa" opacity="0.15" />
|
||||
<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, 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>
|
||||
{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' }}>
|
||||
<span className="text-sky-400">↑ {fmtBps((tooltip.sent ?? 0) * 1024)}</span>
|
||||
<span className="text-violet-400">↓ {fmtBps((tooltip.recv ?? 0) * 1024)}</span>
|
||||
<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">↑ {formatBps((tooltip.sent ?? 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 }) {
|
||||
return (
|
||||
@@ -237,8 +220,6 @@ function StatCard({ title, icon, current, average, unit, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Options de durée ─────────────────────────────────────────────────────────
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ label: '10 min', value: 600 },
|
||||
{ label: '1 h', value: 3_600 },
|
||||
@@ -248,8 +229,6 @@ const DURATION_OPTIONS = [
|
||||
{ label: '30 j', value: 2_592_000 },
|
||||
]
|
||||
|
||||
// ─── Modal principal ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function StatsModal({ vpsId, vpsName, onClose }) {
|
||||
const [stats, setStats] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -277,60 +256,41 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
||||
|
||||
const last = stats[stats.length - 1]
|
||||
|
||||
// Séries CPU / RAM
|
||||
const cpuData = stats.map(s => s.cpu)
|
||||
const ramData = stats.map(s => s.ram_percent)
|
||||
const series = useMemo(() => ({
|
||||
timestamps: stats.map(s => s.ts),
|
||||
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(cpuData, 0, 100)
|
||||
const ramRange = autoRange(ramData, 0, 100)
|
||||
const cpuRange = autoRange(series.cpu, 0, 100)
|
||||
const ramRange = autoRange(series.ram, 0, 100)
|
||||
|
||||
// Réseau : KB/s pour l'affichage du graphique
|
||||
const sentKB = stats.map(s => s.net_sent_per_sec / 1024)
|
||||
const recvKB = stats.map(s => s.net_recv_per_sec / 1024)
|
||||
|
||||
// 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
|
||||
// Trafic cumulé (delta premier → dernier point)
|
||||
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 ?? ''
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-gray-950 border border-gray-800 rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl">
|
||||
|
||||
{/* En-tête */}
|
||||
<div className="px-5 py-3 border-b border-gray-800 sticky top-0 bg-gray-950 z-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart2 size={18} className="text-indigo-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-sm">{vpsName}</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
{stats.length > 0
|
||||
<Modal
|
||||
size="lg"
|
||||
title={vpsName}
|
||||
subtitle={stats.length > 0
|
||||
? `${stats.length} points · fenêtre ${durationLabel} · rafraîchissement ${refreshMs / 1000} s`
|
||||
: 'En attente de données…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-white transition-colors"
|
||||
icon={<BarChart2 size={18} className="text-indigo-400 flex-shrink-0" />}
|
||||
onClose={onClose}
|
||||
bodyClassName="overflow-y-auto p-5"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sélecteur de durée */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<div className="flex gap-1 flex-wrap mb-5" role="group" aria-label="Fenêtre temporelle">
|
||||
{DURATION_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setDuration(opt.value)}
|
||||
aria-pressed={duration === opt.value}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
duration === opt.value
|
||||
? 'bg-indigo-600 text-white'
|
||||
@@ -341,44 +301,64 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-600 text-sm">
|
||||
Chargement…
|
||||
<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">
|
||||
<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="p-5 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* CPU + RAM */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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(cpuData)}
|
||||
average={avg(series.cpu)}
|
||||
unit="%"
|
||||
>
|
||||
<Sparkline data={cpuData} min={cpuRange.min} max={cpuRange.max} color="#818cf8" fill="#818cf8" height={56} />
|
||||
<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(ramData)}
|
||||
average={avg(series.ram)}
|
||||
unit="%"
|
||||
>
|
||||
<Sparkline data={ramData} min={ramRange.min} max={ramRange.max} color="#34d399" fill="#34d399" height={56} />
|
||||
<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 -mt-1">
|
||||
{fmtRam(last.ram_used)} / {fmtRam(last.ram_total)}
|
||||
<p className="text-xs text-gray-600">
|
||||
{formatRam(last.ram_used)} / {formatRam(last.ram_total)}
|
||||
</p>
|
||||
)}
|
||||
</StatCard>
|
||||
@@ -386,7 +366,7 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
||||
|
||||
{/* 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 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
|
||||
@@ -394,15 +374,21 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
||||
{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)}
|
||||
<ArrowUp size={10} /> {formatBps(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)}
|
||||
<ArrowDown size={10} /> {formatBps(last.net_recv_per_sec)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DualSparkline sentData={sentKB} recvData={recvKB} height={64} />
|
||||
<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" />
|
||||
@@ -418,15 +404,15 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
||||
{/* 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)
|
||||
Trafic total sur la fenêtre {durationLabel}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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">{fmtBytes(sessionSent)}</p>
|
||||
<p className="text-lg font-bold tabular-nums">{formatBytes(sessionSent)}</p>
|
||||
<p className="text-xs text-gray-600">Envoyés</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,7 +421,7 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
||||
<ArrowDown size={16} className="text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold tabular-nums">{fmtBytes(sessionRecv)}</p>
|
||||
<p className="text-lg font-bold tabular-nums">{formatBytes(sessionRecv)}</p>
|
||||
<p className="text-xs text-gray-600">Reçus</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -444,7 +430,6 @@ export default function StatsModal({ vpsId, vpsName, onClose }) {
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { useState } from 'react'
|
||||
import ContainerRow from './ContainerRow'
|
||||
import { tagColor } from './TagInput'
|
||||
import { IconButton, ProgressBar } from './ui/controls'
|
||||
import { formatBps, formatRam, loadColor, loadBarColor } from '../lib/format'
|
||||
|
||||
function formatBytes(bps) {
|
||||
if (bps < 1024) return `${bps.toFixed(0)} B/s`
|
||||
if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`
|
||||
return `${(bps / 1024 / 1024).toFixed(1)} MB/s`
|
||||
/** Métrique système avec barre de progression (CPU, RAM). */
|
||||
function Metric({ icon: Icon, label, percent: rawPercent, detail }) {
|
||||
const percent = Number.isFinite(rawPercent) ? rawPercent : 0
|
||||
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) {
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(0)} MB`
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport }) {
|
||||
const storageKey = `vps:${vps.id}:collapsed`
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(storageKey) === '1')
|
||||
const [updatingProject, setUpdatingProject] = useState(null)
|
||||
const [updatingAgent, setUpdatingAgent] = useState(false)
|
||||
const [exported, setExported] = useState(false)
|
||||
const [servicesExpanded, setServicesExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey, collapsed ? '1' : '0')
|
||||
}, [storageKey, collapsed])
|
||||
|
||||
const handleExport = async () => {
|
||||
await onExport(vps.id)
|
||||
const copied = await onExport(vps.id)
|
||||
if (!copied) return
|
||||
setExported(true)
|
||||
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 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 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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{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} />
|
||||
<span className="hidden sm:inline">{running}/{total} actifs</span>
|
||||
<span className="hidden sm:inline whitespace-nowrap">{running}/{total} actifs</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} />
|
||||
<span className="hidden sm:inline">Hors ligne</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
<IconButton
|
||||
icon={collapsed ? ChevronDown : ChevronUp}
|
||||
label={collapsed ? 'Déplier la carte' : 'Replier la carte'}
|
||||
aria-expanded={!collapsed}
|
||||
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 && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={BarChart2}
|
||||
label="Graphiques de performance"
|
||||
tone="accent"
|
||||
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}
|
||||
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
|
||||
onClick={() => onEdit(vps)}
|
||||
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>
|
||||
<IconButton icon={Pencil} label="Modifier ce VPS" onClick={() => onEdit(vps)} />
|
||||
<IconButton icon={Trash2} label="Supprimer ce VPS" tone="danger" onClick={() => onDelete(vps.id)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Erreur de connexion */}
|
||||
{!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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version de l'agent + bouton mise à jour */}
|
||||
{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 :</span>
|
||||
{vps.agent_version ? (
|
||||
<span
|
||||
@@ -173,21 +184,31 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
|
||||
|
||||
{/* Informations système */}
|
||||
{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">
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu size={11} className="text-indigo-400" />
|
||||
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>
|
||||
<div className="px-4 py-3 border-b border-gray-800/60 bg-gray-900/50 space-y-2.5">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Metric
|
||||
icon={Cpu}
|
||||
label="CPU"
|
||||
percent={vps.system.cpu_percent}
|
||||
/>
|
||||
<Metric
|
||||
icon={MemoryStick}
|
||||
label="RAM"
|
||||
percent={vps.system.ram_percent}
|
||||
detail={`${formatRam(vps.system.ram_used)} / ${formatRam(vps.system.ram_total)}`}
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
<span className="text-gray-600">({vps.system.ram_percent.toFixed(0)}%)</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ArrowUp size={11} className="text-sky-400" />{formatBytes(vps.system.net_sent_per_sec)}
|
||||
<ArrowDown size={11} className="text-violet-400 ml-1" />{formatBytes(vps.system.net_recv_per_sec)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
@@ -214,6 +235,7 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
|
||||
<div className="border-b border-gray-800/60">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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]">
|
||||
{vps.services.filter(s => s.active === 'active').length}/{vps.services.length}
|
||||
</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>
|
||||
{servicesExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
@@ -255,14 +282,21 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
|
||||
{total === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-gray-600 text-center">Aucun conteneur détecté.</p>
|
||||
) : (
|
||||
vps.containers.map(c => (
|
||||
<>
|
||||
{hiddenCount > 0 && (
|
||||
<p className="px-4 py-1.5 text-[11px] text-gray-600 bg-gray-800/20">
|
||||
{hiddenCount} conteneur{hiddenCount > 1 ? 's' : ''} masqué{hiddenCount > 1 ? 's' : ''} par la recherche
|
||||
</p>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
|
||||
41
vps-monitor/frontend/src/components/ui/ConfirmDialog.jsx
Normal file
41
vps-monitor/frontend/src/components/ui/ConfirmDialog.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
vps-monitor/frontend/src/components/ui/Modal.jsx
Normal file
131
vps-monitor/frontend/src/components/ui/Modal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
vps-monitor/frontend/src/components/ui/Toast.jsx
Normal file
83
vps-monitor/frontend/src/components/ui/Toast.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
vps-monitor/frontend/src/components/ui/controls.jsx
Normal file
132
vps-monitor/frontend/src/components/ui/controls.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,57 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Contrôles natifs (select, datetime-local, scrollbars…) rendus en sombre */
|
||||
:root { color-scheme: dark; }
|
||||
|
||||
/* Scrollbar minimaliste */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
|
||||
::-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;
|
||||
}
|
||||
}
|
||||
|
||||
44
vps-monitor/frontend/src/lib/clipboard.js
Normal file
44
vps-monitor/frontend/src/lib/clipboard.js
Normal 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)
|
||||
}
|
||||
61
vps-monitor/frontend/src/lib/format.js
Normal file
61
vps-monitor/frontend/src/lib/format.js
Normal 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'
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { ToastProvider } from './components/ui/Toast'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user