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,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"
|
||||
>
|
||||
@@ -264,8 +270,9 @@ export default function AdminPage({ onBack }) {
|
||||
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 [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>
|
||||
|
||||
Reference in New Issue
Block a user