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.
977 lines
43 KiB
JavaScript
977 lines
43 KiB
JavaScript
import { useState, useEffect, useCallback } from '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 (
|
|
<div className="flex items-center justify-between gap-4 py-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-200">{label}</p>
|
|
<p className="text-xs text-gray-500 mt-0.5">{description}</p>
|
|
</div>
|
|
<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"
|
|
>
|
|
{enabled
|
|
? <ToggleRight size={32} className="text-indigo-400" />
|
|
: <ToggleLeft size={32} className="text-gray-600" />
|
|
}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const STATE_COLORS = {
|
|
running: 'text-emerald-400 bg-emerald-950/40 border-emerald-800/50',
|
|
'running:healthy': 'text-emerald-400 bg-emerald-950/40 border-emerald-800/50',
|
|
'running:unhealthy': 'text-orange-400 bg-orange-950/40 border-orange-800/50',
|
|
'running:starting': 'text-yellow-400 bg-yellow-950/40 border-yellow-800/50',
|
|
exited: 'text-gray-500 bg-gray-800/40 border-gray-700/50',
|
|
stopped: 'text-gray-500 bg-gray-800/40 border-gray-700/50',
|
|
removed: 'text-red-400 bg-red-950/40 border-red-800/50',
|
|
}
|
|
|
|
function StateChip({ state, highlight = false }) {
|
|
if (!state) return <span className="text-gray-600 text-xs">—</span>
|
|
const cls = STATE_COLORS[state] ?? 'text-gray-400 bg-gray-800/40 border-gray-700/50'
|
|
return (
|
|
<span className={`inline-block px-2 py-0.5 rounded-md border text-xs font-mono ${cls} ${highlight ? 'font-semibold' : ''}`}>
|
|
{state}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export default function AdminPage({ onBack }) {
|
|
const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'passkeys' | 'notifications' | 'logs' | 'database'
|
|
|
|
// ─── Settings ────────────────────────────────────────────────────────────
|
|
const [settings, setSettings] = useState(null)
|
|
const [settingsLoading, setSettingsLoading] = useState(true)
|
|
const [settingsError, setSettingsError] = useState(null)
|
|
const [toggleLoading, setToggleLoading] = useState(false)
|
|
|
|
const loadSettings = useCallback(async () => {
|
|
setSettingsLoading(true)
|
|
setSettingsError(null)
|
|
try {
|
|
const data = await getAdminSettings()
|
|
setSettings(data)
|
|
} catch (err) {
|
|
setSettingsError(err.message)
|
|
} finally {
|
|
setSettingsLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { loadSettings() }, [loadSettings])
|
|
|
|
const toggleRegistration = async () => {
|
|
if (!settings) return
|
|
const newValue = settings.registration_open === 'true' ? 'false' : 'true'
|
|
setToggleLoading(true)
|
|
try {
|
|
await setAdminSetting('registration_open', newValue)
|
|
setSettings(prev => ({ ...prev, registration_open: newValue }))
|
|
} catch (err) {
|
|
setSettingsError(err.message)
|
|
} finally {
|
|
setToggleLoading(false)
|
|
}
|
|
}
|
|
|
|
const togglePasskeys = async () => {
|
|
if (!settings) return
|
|
const newValue = settings.passkey_enabled === 'true' ? 'false' : 'true'
|
|
setToggleLoading(true)
|
|
try {
|
|
await setAdminSetting('passkey_enabled', newValue)
|
|
setSettings(prev => ({ ...prev, passkey_enabled: newValue }))
|
|
} catch (err) {
|
|
setSettingsError(err.message)
|
|
} finally {
|
|
setToggleLoading(false)
|
|
}
|
|
}
|
|
|
|
// ─── Notifications (Pushover) ────────────────────────────────────────────
|
|
const [pushoverToken, setPushoverToken] = useState('')
|
|
const [pushoverUserKey, setPushoverUserKey] = useState('')
|
|
const [showToken, setShowToken] = useState(false)
|
|
const [showUserKey, setShowUserKey] = useState(false)
|
|
const [pushoverSaving, setPushoverSaving] = useState(false)
|
|
const [pushoverMsg, setPushoverMsg] = useState(null) // { ok, text }
|
|
const [testingNotif, setTestingNotif] = useState(false)
|
|
const [testResult, setTestResult] = useState(null) // { ok, text }
|
|
|
|
// Sync local fields from loaded settings
|
|
useEffect(() => {
|
|
if (!settings) return
|
|
setPushoverToken(settings.pushover_app_token ?? '')
|
|
setPushoverUserKey(settings.pushover_user_key ?? '')
|
|
}, [settings])
|
|
|
|
const togglePushover = async () => {
|
|
if (!settings) return
|
|
const newValue = settings.pushover_enabled === 'true' ? 'false' : 'true'
|
|
setToggleLoading(true)
|
|
try {
|
|
await setAdminSetting('pushover_enabled', newValue)
|
|
setSettings(prev => ({ ...prev, pushover_enabled: newValue }))
|
|
} catch (err) {
|
|
setSettingsError(err.message)
|
|
} finally {
|
|
setToggleLoading(false)
|
|
}
|
|
}
|
|
|
|
const savePushoverCredentials = async () => {
|
|
setPushoverSaving(true)
|
|
setPushoverMsg(null)
|
|
try {
|
|
await setAdminSetting('pushover_app_token', pushoverToken.trim())
|
|
await setAdminSetting('pushover_user_key', pushoverUserKey.trim())
|
|
setSettings(prev => ({
|
|
...prev,
|
|
pushover_app_token: pushoverToken.trim(),
|
|
pushover_user_key: pushoverUserKey.trim(),
|
|
}))
|
|
setPushoverMsg({ ok: true, text: 'Identifiants sauvegardés.' })
|
|
} catch (err) {
|
|
setPushoverMsg({ ok: false, text: err.message })
|
|
} finally {
|
|
setPushoverSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleTestNotification = async () => {
|
|
setTestingNotif(true)
|
|
setTestResult(null)
|
|
try {
|
|
await testPushoverNotification()
|
|
setTestResult({ ok: true, text: 'Notification envoyée avec succès.' })
|
|
} catch (err) {
|
|
setTestResult({ ok: false, text: err.message })
|
|
} finally {
|
|
setTestingNotif(false)
|
|
}
|
|
}
|
|
|
|
// ─── Container events ────────────────────────────────────────────────────
|
|
const [events, setEvents] = useState([])
|
|
const [eventsTotal, setEventsTotal] = useState(0)
|
|
const [eventsPage, setEventsPage] = useState(0)
|
|
const [eventsLoading, setEventsLoading] = useState(false)
|
|
const [eventsError, setEventsError] = useState(null)
|
|
|
|
const loadEvents = useCallback(async (page = 0) => {
|
|
setEventsLoading(true)
|
|
setEventsError(null)
|
|
try {
|
|
const data = await getContainerEvents(PAGE_SIZE, page * PAGE_SIZE)
|
|
setEvents(data.events)
|
|
setEventsTotal(data.total)
|
|
setEventsPage(page)
|
|
} catch (err) {
|
|
setEventsError(err.message)
|
|
} finally {
|
|
setEventsLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'notifications') loadEvents(0)
|
|
}, [activeTab, loadEvents])
|
|
|
|
// ─── Passkeys admin ──────────────────────────────────────────────────────
|
|
const [adminPasskeys, setAdminPasskeys] = useState([])
|
|
const [adminPasskeysLoading, setAdminPasskeysLoading] = useState(false)
|
|
const [adminPasskeysError, setAdminPasskeysError] = useState(null)
|
|
const [revokeLoading, setRevokeLoading] = useState(null)
|
|
|
|
const loadAdminPasskeys = useCallback(async () => {
|
|
setAdminPasskeysLoading(true)
|
|
setAdminPasskeysError(null)
|
|
try {
|
|
const data = await adminGetPasskeys()
|
|
setAdminPasskeys(data)
|
|
} catch (err) {
|
|
setAdminPasskeysError(err.message)
|
|
} finally {
|
|
setAdminPasskeysLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'passkeys') loadAdminPasskeys()
|
|
}, [activeTab, loadAdminPasskeys])
|
|
|
|
const handleRevokePasskey = async (credentialId) => {
|
|
setRevokeLoading(credentialId)
|
|
try {
|
|
await adminDeletePasskey(credentialId)
|
|
setAdminPasskeys(prev => prev.filter(p => p.credential_id !== credentialId))
|
|
} catch (err) {
|
|
setAdminPasskeysError(err.message)
|
|
} finally {
|
|
setRevokeLoading(null)
|
|
}
|
|
}
|
|
|
|
// ─── Login logs ──────────────────────────────────────────────────────────
|
|
const [logs, setLogs] = useState([])
|
|
const [logsTotal, setLogsTotal] = useState(0)
|
|
const [logsPage, setLogsPage] = useState(0)
|
|
const [logsLoading, setLogsLoading] = useState(true)
|
|
const [logsError, setLogsError] = useState(null)
|
|
const [filterUser, setFilterUser] = useState('')
|
|
const [filterSuccess, setFilterSuccess] = useState('all') // 'all' | 'true' | 'false'
|
|
|
|
const loadLogs = useCallback(async (page = 0) => {
|
|
setLogsLoading(true)
|
|
setLogsError(null)
|
|
try {
|
|
const data = await getLoginLogs(PAGE_SIZE, page * PAGE_SIZE)
|
|
setLogs(data.logs)
|
|
setLogsTotal(data.total)
|
|
setLogsPage(page)
|
|
} catch (err) {
|
|
setLogsError(err.message)
|
|
} finally {
|
|
setLogsLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { loadLogs(0) }, [loadLogs])
|
|
|
|
const filteredLogs = logs.filter(log => {
|
|
const matchUser = filterUser === '' || log.username.toLowerCase().includes(filterUser.toLowerCase())
|
|
const matchSuccess = filterSuccess === 'all' || String(log.success) === filterSuccess
|
|
return matchUser && matchSuccess
|
|
})
|
|
|
|
const totalPages = Math.ceil(logsTotal / PAGE_SIZE)
|
|
|
|
// ─── Database management ─────────────────────────────────────────────────
|
|
const [dbInfo, setDbInfo] = useState(null)
|
|
const [dbInfoLoading, setDbInfoLoading] = useState(false)
|
|
const [dbInfoError, setDbInfoError] = useState(null)
|
|
const [purgeLoading, setPurgeLoading] = useState(false)
|
|
const [purgeResult, setPurgeResult] = useState(null)
|
|
const [purgeError, setPurgeError] = useState(null)
|
|
const [confirmState, setConfirmState] = useState(null) // { table, period, fromTs?, toTs? }
|
|
// Custom range
|
|
const [customFrom, setCustomFrom] = useState('')
|
|
const [customTo, setCustomTo] = useState('')
|
|
const [customTable, setCustomTable] = useState('all')
|
|
|
|
const loadDbInfo = useCallback(async () => {
|
|
setDbInfoLoading(true)
|
|
setDbInfoError(null)
|
|
try {
|
|
const data = await getDbInfo()
|
|
setDbInfo(data)
|
|
} catch (err) {
|
|
setDbInfoError(err.message)
|
|
} finally {
|
|
setDbInfoLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'database') loadDbInfo()
|
|
}, [activeTab, loadDbInfo])
|
|
|
|
const requestPurge = (table, period, extraOpts = {}) => {
|
|
setPurgeResult(null)
|
|
setPurgeError(null)
|
|
setConfirmState({ table, period, ...extraOpts })
|
|
}
|
|
|
|
const confirmPurge = async () => {
|
|
if (!confirmState) return
|
|
setPurgeLoading(true)
|
|
setPurgeResult(null)
|
|
setPurgeError(null)
|
|
try {
|
|
const result = await purgeDb(confirmState)
|
|
const total = Object.values(result.deleted).reduce((a, b) => a + b, 0)
|
|
setPurgeResult(`${total} entrée${total !== 1 ? 's' : ''} supprimée${total !== 1 ? 's' : ''}.`)
|
|
loadDbInfo()
|
|
} catch (err) {
|
|
setPurgeError(err.message)
|
|
} finally {
|
|
setPurgeLoading(false)
|
|
setConfirmState(null)
|
|
}
|
|
}
|
|
|
|
const periodLabel = {
|
|
last_24h: '24 dernières heures',
|
|
last_7d: '7 derniers jours',
|
|
last_30d: '30 derniers jours',
|
|
all: 'toutes les entrées',
|
|
custom: 'la période personnalisée',
|
|
}
|
|
|
|
const tableLabel = {
|
|
vps_stats: 'Statistiques VPS',
|
|
login_logs: 'Logs de connexion',
|
|
all: 'toutes les tables',
|
|
}
|
|
|
|
function fmtTs(ts) {
|
|
if (!ts) return '—'
|
|
return new Date(ts * 1000).toLocaleString('fr-FR')
|
|
}
|
|
|
|
function fmtCount(n) {
|
|
return new Intl.NumberFormat('fr-FR').format(n)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-950 text-gray-100">
|
|
<div className="max-w-5xl mx-auto px-4 py-10">
|
|
<button
|
|
onClick={onBack}
|
|
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-200 mb-6 transition-colors"
|
|
>
|
|
<ArrowLeft size={15} />
|
|
Retour au tableau de bord
|
|
</button>
|
|
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<div className="p-2 rounded-xl bg-violet-500/15">
|
|
<ShieldCheck size={20} className="text-violet-400" />
|
|
</div>
|
|
<h1 className="text-lg font-semibold">Administration</h1>
|
|
</div>
|
|
|
|
{/* ── Tabs ── */}
|
|
<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' },
|
|
{ key: 'passkeys', label: 'Passkeys' },
|
|
{ key: 'logs', label: 'Connexions' },
|
|
{ key: 'database', label: 'Base de données' },
|
|
].map(tab => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveTab(tab.key)}
|
|
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'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── Tab: Paramètres ── */}
|
|
{activeTab === 'settings' && (
|
|
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
|
<h2 className="text-sm font-semibold text-gray-300 mb-1">Paramètres</h2>
|
|
<p className="text-xs text-gray-500 mb-4">Configuration globale de l'application.</p>
|
|
|
|
{settingsError && (
|
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
|
{settingsError}
|
|
</div>
|
|
)}
|
|
|
|
{settingsLoading
|
|
? <p className="text-xs text-gray-500">Chargement…</p>
|
|
: (
|
|
<div className="divide-y divide-gray-800">
|
|
<ToggleRow
|
|
label="Inscriptions ouvertes"
|
|
description="Permet à de nouveaux utilisateurs de créer un compte."
|
|
enabled={settings?.registration_open === 'true'}
|
|
onChange={toggleRegistration}
|
|
loading={toggleLoading}
|
|
/>
|
|
<ToggleRow
|
|
label="Authentification par passkey"
|
|
description="Autorise la connexion via TouchID / FaceID (WebAuthn)."
|
|
enabled={settings?.passkey_enabled === 'true'}
|
|
onChange={togglePasskeys}
|
|
loading={toggleLoading}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
</section>
|
|
)}
|
|
|
|
{/* ── Tab: Notifications ── */}
|
|
{activeTab === 'notifications' && (
|
|
<div className="space-y-6">
|
|
|
|
{/* Pushover config */}
|
|
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Bell size={15} className="text-indigo-400" />
|
|
<h2 className="text-sm font-semibold text-gray-300">Pushover</h2>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mb-5">
|
|
Recevez une notification push sur vos appareils lors de tout changement d'état d'un conteneur.
|
|
</p>
|
|
|
|
{settingsError && (
|
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
|
{settingsError}
|
|
</div>
|
|
)}
|
|
|
|
{settingsLoading
|
|
? <p className="text-xs text-gray-500">Chargement…</p>
|
|
: (
|
|
<div className="space-y-5">
|
|
{/* Toggle */}
|
|
<div className="border-b border-gray-800 pb-4">
|
|
<ToggleRow
|
|
label="Notifications activées"
|
|
description="Envoie une notification Pushover à chaque changement d'état de conteneur."
|
|
enabled={settings?.pushover_enabled === 'true'}
|
|
onChange={togglePushover}
|
|
loading={toggleLoading}
|
|
/>
|
|
</div>
|
|
|
|
{/* Credentials form */}
|
|
<div className="space-y-3">
|
|
<div>
|
|
<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={`${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} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<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={`${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} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{pushoverMsg && (
|
|
<div className={`rounded-lg px-3 py-2 text-xs ${pushoverMsg.ok ? 'bg-emerald-950/40 border border-emerald-800/50 text-emerald-300' : 'bg-red-950/40 border border-red-800/50 text-red-300'}`}>
|
|
{pushoverMsg.text}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 pt-1">
|
|
<button
|
|
onClick={savePushoverCredentials}
|
|
disabled={pushoverSaving}
|
|
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 transition-colors text-white font-medium"
|
|
>
|
|
{pushoverSaving ? <RefreshCw size={12} className="animate-spin" /> : <Check size={12} />}
|
|
Enregistrer
|
|
</button>
|
|
<button
|
|
onClick={handleTestNotification}
|
|
disabled={testingNotif || settings?.pushover_enabled !== 'true'}
|
|
title={settings?.pushover_enabled !== 'true' ? 'Activez les notifications d\'abord' : ''}
|
|
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs bg-gray-700 hover:bg-gray-600 disabled:opacity-50 transition-colors"
|
|
>
|
|
{testingNotif ? <RefreshCw size={12} className="animate-spin" /> : <Send size={12} />}
|
|
Tester
|
|
</button>
|
|
</div>
|
|
|
|
{testResult && (
|
|
<div className={`rounded-lg px-3 py-2 text-xs ${testResult.ok ? 'bg-emerald-950/40 border border-emerald-800/50 text-emerald-300' : 'bg-red-950/40 border border-red-800/50 text-red-300'}`}>
|
|
{testResult.text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
</section>
|
|
|
|
{/* Container events */}
|
|
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Activity size={15} className="text-violet-400" />
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-gray-300">Historique des événements</h2>
|
|
<p className="text-xs text-gray-500 mt-0.5">{eventsTotal} changement{eventsTotal !== 1 ? 's' : ''} enregistré{eventsTotal !== 1 ? 's' : ''}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => loadEvents(eventsPage)}
|
|
disabled={eventsLoading}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
<RefreshCw size={12} className={eventsLoading ? 'animate-spin' : ''} />
|
|
Actualiser
|
|
</button>
|
|
</div>
|
|
|
|
{eventsError && (
|
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
|
{eventsError}
|
|
</div>
|
|
)}
|
|
|
|
{eventsLoading && events.length === 0
|
|
? <p className="text-xs text-gray-500 py-8 text-center">Chargement…</p>
|
|
: events.length === 0
|
|
? (
|
|
<div className="flex flex-col items-center gap-2 py-10 text-gray-600">
|
|
<Activity size={28} />
|
|
<p className="text-xs">Aucun événement enregistré.</p>
|
|
</div>
|
|
)
|
|
: (
|
|
<div className="overflow-x-auto -mx-2">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b border-gray-800">
|
|
<th className="pb-2 px-2 font-medium">Date / Heure</th>
|
|
<th className="pb-2 px-2 font-medium">VPS</th>
|
|
<th className="pb-2 px-2 font-medium">Conteneur</th>
|
|
<th className="pb-2 px-2 font-medium">Avant</th>
|
|
<th className="pb-2 px-2 font-medium">Après</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-800/60">
|
|
{events.map(ev => (
|
|
<tr key={ev.id} className="hover:bg-gray-800/30 transition-colors">
|
|
<td className="py-2 px-2 text-gray-400 whitespace-nowrap font-mono">
|
|
{new Date(ev.ts).toLocaleString('fr-FR')}
|
|
</td>
|
|
<td className="py-2 px-2 text-gray-300">{ev.vps_name}</td>
|
|
<td className="py-2 px-2 text-gray-200 font-mono">{ev.container}</td>
|
|
<td className="py-2 px-2">
|
|
<StateChip state={ev.old_state} />
|
|
</td>
|
|
<td className="py-2 px-2">
|
|
<StateChip state={ev.new_state} highlight />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{Math.ceil(eventsTotal / PAGE_SIZE) > 1 && (
|
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-800">
|
|
<button
|
|
onClick={() => loadEvents(eventsPage - 1)}
|
|
disabled={eventsPage === 0 || eventsLoading}
|
|
className="px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
|
>
|
|
← Précédent
|
|
</button>
|
|
<span className="text-xs text-gray-500">
|
|
Page {eventsPage + 1} / {Math.ceil(eventsTotal / PAGE_SIZE)}
|
|
</span>
|
|
<button
|
|
onClick={() => loadEvents(eventsPage + 1)}
|
|
disabled={eventsPage >= Math.ceil(eventsTotal / PAGE_SIZE) - 1 || eventsLoading}
|
|
className="px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
|
>
|
|
Suivant →
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Tab: Passkeys ── */}
|
|
{activeTab === 'passkeys' && (
|
|
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-gray-300">Passkeys enregistrées</h2>
|
|
<p className="text-xs text-gray-500 mt-0.5">
|
|
Gérez les passkeys (TouchID / FaceID) de tous les utilisateurs.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={loadAdminPasskeys}
|
|
disabled={adminPasskeysLoading}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
<RefreshCw size={12} className={adminPasskeysLoading ? 'animate-spin' : ''} />
|
|
Actualiser
|
|
</button>
|
|
</div>
|
|
|
|
{adminPasskeysError && (
|
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
|
{adminPasskeysError}
|
|
</div>
|
|
)}
|
|
|
|
{adminPasskeysLoading && adminPasskeys.length === 0
|
|
? <p className="text-xs text-gray-500 py-8 text-center">Chargement…</p>
|
|
: adminPasskeys.length === 0
|
|
? (
|
|
<div className="flex flex-col items-center gap-2 py-10 text-gray-600">
|
|
<Fingerprint size={28} />
|
|
<p className="text-xs">Aucune passkey enregistrée.</p>
|
|
</div>
|
|
)
|
|
: (
|
|
<div className="overflow-x-auto -mx-2">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b border-gray-800">
|
|
<th className="pb-2 px-2 font-medium">Utilisateur</th>
|
|
<th className="pb-2 px-2 font-medium">Appareil</th>
|
|
<th className="pb-2 px-2 font-medium">Enregistrée le</th>
|
|
<th className="pb-2 px-2 font-medium"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-800/60">
|
|
{adminPasskeys.map(pk => (
|
|
<tr key={pk.credential_id} className="hover:bg-gray-800/30 transition-colors">
|
|
<td className="py-2 px-2 font-mono text-gray-200">{pk.username}</td>
|
|
<td className="py-2 px-2 text-gray-300 flex items-center gap-1.5">
|
|
<Key size={11} className="text-indigo-400 flex-shrink-0" />
|
|
{pk.name}
|
|
</td>
|
|
<td className="py-2 px-2 text-gray-500 whitespace-nowrap">
|
|
{new Date(pk.created_at).toLocaleString('fr-FR')}
|
|
</td>
|
|
<td className="py-2 px-2 text-right">
|
|
<button
|
|
onClick={() => handleRevokePasskey(pk.credential_id)}
|
|
disabled={revokeLoading === pk.credential_id}
|
|
className="px-2 py-1 rounded-md text-xs text-red-400 hover:bg-red-950/50 disabled:opacity-50 transition-colors"
|
|
>
|
|
{revokeLoading === pk.credential_id ? '…' : 'Révoquer'}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
</section>
|
|
)}
|
|
|
|
{/* ── Tab: Connexions ── */}
|
|
{activeTab === 'logs' && (
|
|
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
|
<div className="flex items-center justify-between mb-4 gap-4 flex-wrap">
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-gray-300">Tentatives de connexion</h2>
|
|
<p className="text-xs text-gray-500 mt-0.5">{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<input
|
|
type="text"
|
|
placeholder="Filtrer par utilisateur…"
|
|
value={filterUser}
|
|
onChange={(e) => setFilterUser(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 w-44"
|
|
/>
|
|
<select
|
|
value={filterSuccess}
|
|
onChange={(e) => setFilterSuccess(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">Tous</option>
|
|
<option value="true">Succès</option>
|
|
<option value="false">Échecs</option>
|
|
</select>
|
|
<button
|
|
onClick={() => loadLogs(logsPage)}
|
|
disabled={logsLoading}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
<RefreshCw size={12} className={logsLoading ? 'animate-spin' : ''} />
|
|
Actualiser
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{logsError && (
|
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
|
{logsError}
|
|
</div>
|
|
)}
|
|
|
|
{logsLoading && logs.length === 0
|
|
? <p className="text-xs text-gray-500 py-8 text-center">Chargement…</p>
|
|
: filteredLogs.length === 0
|
|
? <p className="text-xs text-gray-500 py-8 text-center">Aucune entrée.</p>
|
|
: (
|
|
<div className="overflow-x-auto -mx-2">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b border-gray-800">
|
|
<th className="pb-2 px-2 font-medium">Date / Heure</th>
|
|
<th className="pb-2 px-2 font-medium">Utilisateur</th>
|
|
<th className="pb-2 px-2 font-medium">Adresse IP</th>
|
|
<th className="pb-2 px-2 font-medium">Résultat</th>
|
|
<th className="pb-2 px-2 font-medium">Détail</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-800/60">
|
|
{filteredLogs.map(log => (
|
|
<tr key={log.id} className="hover:bg-gray-800/30 transition-colors">
|
|
<td className="py-2 px-2 text-gray-400 whitespace-nowrap font-mono">
|
|
{new Date(log.ts).toLocaleString('fr-FR')}
|
|
</td>
|
|
<td className="py-2 px-2 text-gray-200 font-mono">{log.username}</td>
|
|
<td className="py-2 px-2 text-gray-400 font-mono">{log.ip}</td>
|
|
<td className="py-2 px-2">
|
|
{log.success
|
|
? (
|
|
<span className="inline-flex items-center gap-1 text-emerald-400">
|
|
<Check size={11} /> Succès
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 text-red-400">
|
|
<X size={11} /> Échec
|
|
</span>
|
|
)
|
|
}
|
|
</td>
|
|
<td className="py-2 px-2 text-gray-500">{log.reason || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-800">
|
|
<button
|
|
onClick={() => loadLogs(logsPage - 1)}
|
|
disabled={logsPage === 0 || logsLoading}
|
|
className="px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
|
>
|
|
← Précédent
|
|
</button>
|
|
<span className="text-xs text-gray-500">
|
|
Page {logsPage + 1} / {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => loadLogs(logsPage + 1)}
|
|
disabled={logsPage >= totalPages - 1 || logsLoading}
|
|
className="px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
|
>
|
|
Suivant →
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* ── Tab: Base de données ── */}
|
|
{activeTab === 'database' && (
|
|
<div className="space-y-6">
|
|
{/* En-tête */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-gray-300">Gestion de la base de données</h2>
|
|
<p className="text-xs text-gray-500 mt-0.5">Supprimez les données historiques par table et par période.</p>
|
|
</div>
|
|
<button
|
|
onClick={loadDbInfo}
|
|
disabled={dbInfoLoading}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
<RefreshCw size={12} className={dbInfoLoading ? 'animate-spin' : ''} />
|
|
Actualiser
|
|
</button>
|
|
</div>
|
|
|
|
{dbInfoError && (
|
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300">
|
|
{dbInfoError}
|
|
</div>
|
|
)}
|
|
{purgeResult && (
|
|
<div className="bg-emerald-950/40 border border-emerald-800/50 rounded-lg px-3 py-2 text-xs text-emerald-300">
|
|
{purgeResult}
|
|
</div>
|
|
)}
|
|
{purgeError && (
|
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300">
|
|
{purgeError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Cards par table */}
|
|
{[
|
|
{ key: 'vps_stats', label: 'Statistiques VPS', icon: <Database size={15} className="text-indigo-400" /> },
|
|
{ key: 'login_logs', label: 'Logs de connexion', icon: <Database size={15} className="text-violet-400" /> },
|
|
].map(({ key, label, icon }) => {
|
|
const info = dbInfo?.[key]
|
|
return (
|
|
<section key={key} className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
{icon}
|
|
<h3 className="text-sm font-semibold text-gray-300">{label}</h3>
|
|
{info && (
|
|
<span className="ml-auto text-xs text-gray-500">
|
|
{fmtCount(info.count)} entrée{info.count !== 1 ? 's' : ''}
|
|
{info.oldest_ts && ` · du ${fmtTs(info.oldest_ts)} au ${fmtTs(info.newest_ts)}`}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{[
|
|
{ period: 'last_24h', label: '24 dernières heures' },
|
|
{ period: 'last_7d', label: '7 derniers jours' },
|
|
{ period: 'last_30d', label: '30 derniers jours' },
|
|
{ period: 'all', label: 'Tout effacer', danger: true },
|
|
].map(({ period, label: btnLabel, danger }) => (
|
|
<button
|
|
key={period}
|
|
onClick={() => requestPurge(key, period)}
|
|
disabled={purgeLoading || dbInfoLoading}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-colors disabled:opacity-50 ${
|
|
danger
|
|
? 'bg-red-950/50 hover:bg-red-900/60 text-red-400 border border-red-800/50'
|
|
: 'bg-gray-800 hover:bg-gray-700 text-gray-300'
|
|
}`}
|
|
>
|
|
<Trash2 size={11} />
|
|
{btnLabel}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
})}
|
|
|
|
{/* Période personnalisée */}
|
|
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
|
<h3 className="text-sm font-semibold text-gray-300 mb-1">Période personnalisée</h3>
|
|
<p className="text-xs text-gray-500 mb-4">Supprimez les données comprises entre deux dates précises.</p>
|
|
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-500 mb-1">Du</label>
|
|
<input
|
|
type="datetime-local"
|
|
value={customFrom}
|
|
onChange={e => setCustomFrom(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"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-500 mb-1">Au</label>
|
|
<input
|
|
type="datetime-local"
|
|
value={customTo}
|
|
onChange={e => setCustomTo(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"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="custom-table" className="block text-xs text-gray-500 mb-1">Table</label>
|
|
<select
|
|
id="custom-table"
|
|
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>
|
|
<option value="vps_stats">Statistiques VPS</option>
|
|
<option value="login_logs">Logs de connexion</option>
|
|
</select>
|
|
</div>
|
|
<button
|
|
disabled={!customFrom || !customTo || purgeLoading}
|
|
onClick={() => {
|
|
const fromTs = Math.floor(new Date(customFrom).getTime() / 1000)
|
|
const toTs = Math.floor(new Date(customTo).getTime() / 1000)
|
|
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"
|
|
>
|
|
<Trash2 size={11} />
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Modale de confirmation de purge ── */}
|
|
{confirmState && (
|
|
<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>.
|
|
Cette action est irréversible.
|
|
</>
|
|
}
|
|
confirmLabel={purgeLoading ? 'Suppression…' : 'Supprimer'}
|
|
loading={purgeLoading}
|
|
onConfirm={confirmPurge}
|
|
onCancel={() => setConfirmState(null)}
|
|
/>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|