diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx index cde6a89..414251d 100644 --- a/vps-monitor/frontend/src/App.jsx +++ b/vps-monitor/frontend/src/App.jsx @@ -1,14 +1,20 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { ServerCrash, SearchX, Plus, RefreshCw } from 'lucide-react' import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client' -import Header from './components/Header' -import VpsCard from './components/VpsCard' -import LogsModal from './components/LogsModal' -import AddVpsModal from './components/AddVpsModal' -import EditVpsModal from './components/EditVpsModal' -import StatsModal from './components/StatsModal' -import LoginPage from './components/LoginPage' -import ProfilePage from './components/ProfilePage' -import AdminPage from './components/AdminPage' +import Header from './components/Header' +import VpsCard from './components/VpsCard' +import LogsModal from './components/LogsModal' +import AddVpsModal from './components/AddVpsModal' +import EditVpsModal from './components/EditVpsModal' +import StatsModal from './components/StatsModal' +import LoginPage from './components/LoginPage' +import ProfilePage from './components/ProfilePage' +import AdminPage from './components/AdminPage' +import DashboardToolbar from './components/DashboardToolbar' +import ConfirmDialog from './components/ui/ConfirmDialog' +import { useToast } from './components/ui/Toast' +import { Button, EmptyState, Skeleton } from './components/ui/controls' +import { copyText, downloadText } from './lib/clipboard' const INTERVAL_OPTIONS = [ { label: '10 s', value: 10_000 }, @@ -19,7 +25,35 @@ const INTERVAL_OPTIONS = [ { label: 'Off', value: 0 }, ] +const ACTION_LABELS = { + start: 'démarré', + stop: 'arrêté', + restart: 'redémarré', +} + +/** Un VPS « à problème » : injoignable, conteneur non démarré ou en mauvaise santé. */ +function hasIssue(vps) { + if (!vps.online) return true + if (vps.containers.some(c => c.status !== 'running' || c.health === 'unhealthy')) return true + return (vps.services ?? []).some(s => s.active === 'failed') +} + +/** Champs pris en compte par la recherche libre. */ +function vpsMatchesQuery(vps, query) { + const haystack = [ + vps.name, + vps.host, + vps.description, + ...(vps.tags ?? []), + ...vps.containers.flatMap(c => [c.name, c.image, c.compose_project]), + ...(vps.services ?? []).map(s => s.name), + ] + return haystack.filter(Boolean).some(value => value.toLowerCase().includes(query)) +} + export default function App() { + const toast = useToast() + const [token, setTokenState] = useState(() => getToken()) const [username, setUsername] = useState(null) const [role, setRole] = useState(null) @@ -38,16 +72,46 @@ export default function App() { const [logsLoading, setLogsLoading] = useState(false) const [showAddVps, setShowAddVps] = useState(false) const [editVps, setEditVps] = useState(null) // objet vps à éditer + const [deleteTarget, setDeleteTarget] = useState(null) // vps en attente de confirmation + const [deleting, setDeleting] = useState(false) + const [refreshInterval, setRefreshInterval] = useState(() => { const stored = localStorage.getItem('refreshInterval') return stored ? parseInt(stored, 10) : 30_000 }) + // ─── Filtres du tableau de bord ───────────────────────────────────────── + const [search, setSearch] = useState('') + const [status, setStatus] = useState(() => localStorage.getItem('filterStatus') ?? 'all') + const [activeTags, setActiveTags] = useState(() => { + try { return JSON.parse(localStorage.getItem('filterTags') ?? '[]') } catch { return [] } + }) + const [sort, setSort] = useState(() => localStorage.getItem('sortBy') ?? 'name') + const searchRef = useRef(null) + const handleIntervalChange = (val) => { setRefreshInterval(val) localStorage.setItem('refreshInterval', val) } + const handleStatusChange = (val) => { + setStatus(val) + localStorage.setItem('filterStatus', val) + } + + const handleSortChange = (val) => { + setSort(val) + localStorage.setItem('sortBy', val) + } + + const handleToggleTag = (tag) => { + setActiveTags(prev => { + const next = prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag] + localStorage.setItem('filterTags', JSON.stringify(next)) + return next + }) + } + const [updateModal, setUpdateModal] = useState(null) // { vpsId, project } const [updateContent, setUpdateContent] = useState('') const [updateLoading, setUpdateLoading] = useState(false) @@ -135,6 +199,28 @@ export default function App() { } }, [token, username]) + // Raccourcis clavier : « / » cible la recherche, « r » actualise. + const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget) + useEffect(() => { + if (!token || page !== 'main' || modalOpen) return + const onKeyDown = (e) => { + if (e.metaKey || e.ctrlKey || e.altKey) return + const el = e.target + if (el instanceof HTMLElement && + (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return + + if (e.key === '/') { + e.preventDefault() + searchRef.current?.focus() + } else if (e.key.toLowerCase() === 'r') { + e.preventDefault() + refresh(true) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [token, page, modalOpen, refresh]) + const openLogs = async (vpsId, containerId, name) => { setLogsModal({ vpsId, containerId, name }) setLogsLoading(true) @@ -150,8 +236,13 @@ export default function App() { } const handleAction = async (vpsId, containerId, action) => { - await containerAction(vpsId, containerId, action) - await refresh() + try { + await containerAction(vpsId, containerId, action) + toast.success(`Conteneur ${ACTION_LABELS[action] ?? action}.`) + await refresh() + } catch (e) { + toast.error(`Action « ${action} » impossible : ${e.message}`) + } } const handleUpdate = async (vpsId, project) => { @@ -161,8 +252,10 @@ export default function App() { try { const data = await composeUpdate(vpsId, project) setUpdateContent(data.output || '(aucune sortie)') + toast.success(`Projet « ${project} » mis à jour.`) } catch (e) { setUpdateContent(`Erreur lors de la mise à jour :\n${e.message}`) + toast.error(`Mise à jour de « ${project} » échouée.`) } finally { setUpdateLoading(false) await refresh() @@ -176,8 +269,10 @@ export default function App() { try { await updateAgent(vpsId) setUpdateContent('Mise à jour lancée. L\'agent va redémarrer dans quelques secondes.\nActualisez dans un moment pour vérifier la nouvelle version.') + toast.info('Mise à jour de l\'agent lancée.') } catch (e) { setUpdateContent(`Erreur lors de la mise à jour de l'agent :\n${e.message}`) + toast.error(`Mise à jour de l'agent échouée : ${e.message}`) } finally { setUpdateLoading(false) setTimeout(() => refresh(), 8000) @@ -187,26 +282,79 @@ export default function App() { const handleAddVps = async (formData) => { await addVps(formData) setShowAddVps(false) + toast.success(`VPS « ${formData.name} » ajouté.`) await refresh(true) } - const handleDeleteVps = async (vpsId) => { - if (!window.confirm('Supprimer ce VPS de la configuration ?')) return - await deleteVps(vpsId) - await refresh(true) + const handleDeleteVps = async () => { + if (!deleteTarget) return + setDeleting(true) + try { + await deleteVps(deleteTarget.id) + toast.success(`VPS « ${deleteTarget.name} » supprimé.`) + setDeleteTarget(null) + await refresh(true) + } catch (e) { + toast.error(`Suppression impossible : ${e.message}`) + } finally { + setDeleting(false) + } } const handleEditVps = async (vpsId, data) => { await updateVps(vpsId, data) setEditVps(null) + toast.success('Configuration enregistrée.') await refresh(true) } + /** Copie la config d'un VPS ; retombe sur un téléchargement si le presse-papiers est bloqué. */ const handleExportVps = async (vpsId) => { - const config = await exportVps(vpsId) - await navigator.clipboard.writeText(JSON.stringify(config, null, 2)) + try { + const config = await exportVps(vpsId) + const json = JSON.stringify(config, null, 2) + if (await copyText(json)) { + toast.success('Configuration copiée dans le presse-papiers.') + return true + } + downloadText(`${vpsId}.json`, json, 'application/json') + toast.info('Presse-papiers indisponible (connexion non sécurisée) — configuration téléchargée.') + return false + } catch (e) { + toast.error(`Export impossible : ${e.message}`) + return false + } } + // ─── Liste filtrée et triée ───────────────────────────────────────────── + const allTags = useMemo( + () => [...new Set(vpsList.flatMap(v => v.tags ?? []))].sort((a, b) => a.localeCompare(b, 'fr')), + [vpsList], + ) + + const visibleVps = useMemo(() => { + const query = search.trim().toLowerCase() + + const filtered = vpsList.filter(vps => { + if (status === 'online' && !vps.online) return false + if (status === 'offline' && vps.online) return false + if (status === 'issues' && !hasIssue(vps)) return false + if (activeTags.length > 0 && !activeTags.every(t => (vps.tags ?? []).includes(t))) return false + if (query && !vpsMatchesQuery(vps, query)) return false + return true + }) + + const byName = (a, b) => a.name.localeCompare(b.name, 'fr') + const sorters = { + name: byName, + status: (a, b) => (Number(b.online) - Number(a.online)) || (Number(hasIssue(a)) - Number(hasIssue(b))) || byName(a, b), + cpu: (a, b) => (b.system?.cpu_percent ?? -1) - (a.system?.cpu_percent ?? -1) || byName(a, b), + ram: (a, b) => (b.system?.ram_percent ?? -1) - (a.system?.ram_percent ?? -1) || byName(a, b), + containers: (a, b) => b.containers.length - a.containers.length || byName(a, b), + } + return [...filtered].sort(sorters[sort] ?? byName) + }, [vpsList, search, status, activeTags, sort]) + // Attente vérification auth if (!authChecked) return null @@ -225,6 +373,7 @@ export default function App() { const totalOnline = vpsList.filter(v => v.online).length const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0) const totalRunning = vpsList.reduce((acc, v) => acc + v.containers.filter(c => c.status === 'running').length, 0) + const totalIssues = vpsList.filter(hasIssue).length // ─── Pages profil / admin ─────────────────────────────────────────────── if (page === 'profile') { @@ -252,65 +401,111 @@ export default function App() { intervalOptions={INTERVAL_OPTIONS} /> -
+
{/* Barre d'erreur backend */} {error && ( -
- Impossible de joindre le backend : {error} +
+ + Impossible de joindre le backend : {error} + +
)} {/* Stats globales */} {!loading && vpsList.length > 0 && ( -
+
{[ - { label: 'VPS en ligne', value: `${totalOnline}/${vpsList.length}`, color: 'text-emerald-400' }, - { label: 'Conteneurs actifs', value: `${totalRunning}/${totalContainers}`, color: 'text-indigo-400' }, + { label: 'VPS en ligne', value: `${totalOnline}/${vpsList.length}`, color: 'text-emerald-400' }, + { label: 'Conteneurs actifs', value: `${totalRunning}/${totalContainers}`, color: 'text-indigo-400' }, + { label: 'À surveiller', value: String(totalIssues), color: totalIssues > 0 ? 'text-orange-400' : 'text-gray-400' }, { label: 'Actualisation auto', value: INTERVAL_OPTIONS.find(o => o.value === refreshInterval)?.label ?? 'Off', color: 'text-gray-400' }, ].map(({ label, value, color }) => (
-

{value}

+

{value}

{label}

))}
)} - {/* Chargement initial */} - {loading && ( -
- - - - Chargement… -
+ {/* Filtres */} + {!loading && vpsList.length > 0 && ( + )} - {/* Aucun VPS */} + {/* Chargement initial — squelettes */} + {loading && ( + <> +
+ {Array.from({ length: 4 }, (_, i) => )} +
+
+ {Array.from({ length: 2 }, (_, i) => )} +
+ + )} + + {/* Aucun VPS configuré */} {!loading && vpsList.length === 0 && !error && ( -
-

Aucun VPS configuré

-

Cliquez sur Ajouter un VPS pour commencer.

- -
+ setShowAddVps(true)}> + Ajouter un VPS + + } + /> + )} + + {/* Aucun résultat après filtrage */} + {!loading && vpsList.length > 0 && visibleVps.length === 0 && ( + { setSearch(''); handleStatusChange('all'); setActiveTags([]); localStorage.setItem('filterTags', '[]') }} + > + Réinitialiser les filtres + + } + /> )} {/* Grille de VPS */} - {!loading && vpsList.length > 0 && ( -
- {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 && ( +
+ {visibleVps.map(vps => ( 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 && ( setUpdateModal(null)} @@ -367,6 +562,25 @@ export default function App() { onClose={() => setStatsModal(null)} /> )} + + {/* Confirmation de suppression */} + {deleteTarget && ( + + {deleteTarget.name}{' '} + ({deleteTarget.host}) sera retiré de la + configuration. Les conteneurs du serveur ne sont pas touchés. + + } + confirmLabel="Supprimer" + loading={deleting} + onConfirm={handleDeleteVps} + onCancel={() => setDeleteTarget(null)} + /> + )}
) } diff --git a/vps-monitor/frontend/src/components/AddVpsModal.jsx b/vps-monitor/frontend/src/components/AddVpsModal.jsx index f348fc9..404fdd7 100644 --- a/vps-monitor/frontend/src/components/AddVpsModal.jsx +++ b/vps-monitor/frontend/src/components/AddVpsModal.jsx @@ -1,16 +1,18 @@ -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: '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' }, - { key: 'description', label: 'Description', placeholder: 'Optionnel', required: false, type: 'text' }, + { key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' }, + { key: 'id', label: 'Identifiant unique', placeholder: 'vps-1', required: true, type: 'text', 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' }, + { key: 'description', label: 'Description', placeholder: 'Optionnel', required: false, type: 'text' }, ] export default function AddVpsModal({ onSave, onClose }) { @@ -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,122 +63,114 @@ export default function AddVpsModal({ onSave, onClose }) { } return ( -
{ if (e.target === e.currentTarget) onClose() }} - > -
-
-
-

Ajouter un VPS

-
- - -
-
- +
- - {mode === 'import' ? ( -
-

- Collez le JSON copié via le bouton Exporter d'une autre instance. + } + > + {mode === 'import' ? ( +

+

+ Collez le JSON copié via le bouton Exporter d'une autre instance. +

+