Feat : Notifications system, State log
All checks were successful
Build and Push Docker Images / docker (push) Successful in 38s
All checks were successful
Build and Push Docker Images / docker (push) Successful in 38s
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle, Fingerprint, Key } from 'lucide-react'
|
||||
import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey } from '../api/client'
|
||||
import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle, Fingerprint, Key, Bell, Send, Eye, EyeOff, Activity } from 'lucide-react'
|
||||
import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey, getContainerEvents, testPushoverNotification } from '../api/client'
|
||||
|
||||
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>
|
||||
@@ -26,8 +25,28 @@ function ToggleRow({ label, description, enabled, onChange, loading }) {
|
||||
)
|
||||
}
|
||||
|
||||
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' | 'logs' | 'database'
|
||||
const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'passkeys' | 'notifications' | 'logs' | 'database'
|
||||
|
||||
// ─── Settings ────────────────────────────────────────────────────────────
|
||||
const [settings, setSettings] = useState(null)
|
||||
@@ -78,6 +97,95 @@ export default function AdminPage({ onBack }) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
@@ -244,10 +352,11 @@ export default function AdminPage({ onBack }) {
|
||||
{/* ── Tabs ── */}
|
||||
<div className="flex gap-1 mb-8 border-b border-gray-800">
|
||||
{[
|
||||
{ key: 'settings', label: 'Paramètres' },
|
||||
{ key: 'passkeys', label: 'Passkeys' },
|
||||
{ key: 'logs', label: 'Connexions' },
|
||||
{ key: 'database', label: 'Base de données' },
|
||||
{ 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}
|
||||
@@ -299,6 +408,213 @@ export default function AdminPage({ onBack }) {
|
||||
</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 className="block text-xs text-gray-400 mb-1.5">App Token</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(v => !v)}
|
||||
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 className="block text-xs text-gray-400 mb-1.5">User Key</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUserKey(v => !v)}
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user