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:
@@ -64,6 +64,10 @@ def _get_expected_version() -> str:
|
|||||||
# ─── Ring buffer de stats (en mémoire) ───────────────────────────────────────
|
# ─── Ring buffer de stats (en mémoire) ───────────────────────────────────────
|
||||||
_STATS_MAX_POINTS = 120 # 10 min à 5 s d'intervalle
|
_STATS_MAX_POINTS = 120 # 10 min à 5 s d'intervalle
|
||||||
_stats_history: dict[str, deque] = {}
|
_stats_history: dict[str, deque] = {}
|
||||||
|
|
||||||
|
# État courant des conteneurs : vps_id → {container_name: effective_state}
|
||||||
|
_container_states: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
SECRET_FILE = Path(os.getenv("SECRET_FILE", "data/.jwt_secret"))
|
SECRET_FILE = Path(os.getenv("SECRET_FILE", "data/.jwt_secret"))
|
||||||
AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "5"))
|
AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "5"))
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
@@ -252,6 +256,31 @@ def init_db() -> None:
|
|||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('passkey_enabled', 'true')
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('passkey_enabled', 'true')
|
||||||
""")
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_enabled', 'false')
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_app_token', '')
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_user_key', '')
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS container_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
vps_id TEXT NOT NULL,
|
||||||
|
vps_name TEXT NOT NULL,
|
||||||
|
container TEXT NOT NULL,
|
||||||
|
image TEXT NOT NULL DEFAULT '',
|
||||||
|
old_state TEXT NOT NULL DEFAULT '',
|
||||||
|
new_state TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_container_events_ts
|
||||||
|
ON container_events(ts DESC)
|
||||||
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS passkeys (
|
CREATE TABLE IF NOT EXISTS passkeys (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -327,6 +356,32 @@ def _get_setting(key: str) -> str:
|
|||||||
return row["value"] if row else ""
|
return row["value"] if row else ""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Pushover notifications ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _effective_state(status: str, health: str) -> str:
|
||||||
|
if health and health not in ("none", ""):
|
||||||
|
return f"{status}:{health}"
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_pushover(title: str, message: str) -> bool:
|
||||||
|
token = _get_setting("pushover_app_token")
|
||||||
|
user = _get_setting("pushover_user_key")
|
||||||
|
if not token or not user:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
"https://api.pushover.net/1/messages.json",
|
||||||
|
data={"token": token, "user": user, "title": title, "message": message},
|
||||||
|
timeout=timeout,
|
||||||
|
) as r:
|
||||||
|
return r.status == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ─── WebAuthn / Passkey helpers ───────────────────────────────────────────────
|
# ─── WebAuthn / Passkey helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
def _b64url_encode(b: bytes) -> str:
|
def _b64url_encode(b: bytes) -> str:
|
||||||
@@ -626,6 +681,76 @@ async def _stats_collector() -> None:
|
|||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Collecteur d'états des conteneurs ───────────────────────────────────────
|
||||||
|
|
||||||
|
async def _check_container_states(vps: dict) -> None:
|
||||||
|
try:
|
||||||
|
containers = await agent_get(vps, "/containers")
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
vps_id = vps["id"]
|
||||||
|
vps_name = vps["name"]
|
||||||
|
current = {
|
||||||
|
c["name"]: _effective_state(c["status"], c.get("health", "none"))
|
||||||
|
for c in containers
|
||||||
|
}
|
||||||
|
|
||||||
|
prev = _container_states.get(vps_id)
|
||||||
|
if prev is None:
|
||||||
|
_container_states[vps_id] = current
|
||||||
|
return
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
notify = _get_setting("pushover_enabled") == "true"
|
||||||
|
events: list[dict] = []
|
||||||
|
|
||||||
|
for name, state in current.items():
|
||||||
|
prev_state = prev.get(name, "")
|
||||||
|
if state != prev_state:
|
||||||
|
image = next((c["image"] for c in containers if c["name"] == name), "")
|
||||||
|
events.append({
|
||||||
|
"ts": now, "vps_id": vps_id, "vps_name": vps_name,
|
||||||
|
"container": name, "image": image,
|
||||||
|
"old_state": prev_state, "new_state": state,
|
||||||
|
})
|
||||||
|
|
||||||
|
for name, state in prev.items():
|
||||||
|
if name not in current:
|
||||||
|
events.append({
|
||||||
|
"ts": now, "vps_id": vps_id, "vps_name": vps_name,
|
||||||
|
"container": name, "image": "",
|
||||||
|
"old_state": state, "new_state": "removed",
|
||||||
|
})
|
||||||
|
|
||||||
|
if events:
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.executemany("""
|
||||||
|
INSERT INTO container_events
|
||||||
|
(ts, vps_id, vps_name, container, image, old_state, new_state)
|
||||||
|
VALUES (:ts, :vps_id, :vps_name, :container, :image, :old_state, :new_state)
|
||||||
|
""", events)
|
||||||
|
if notify:
|
||||||
|
for ev in events:
|
||||||
|
title = f"[{ev['vps_name']}] {ev['container']}"
|
||||||
|
msg = f"Etat : {ev['old_state'] or 'nouveau'} -> {ev['new_state']}"
|
||||||
|
asyncio.create_task(_send_pushover(title, msg))
|
||||||
|
|
||||||
|
_container_states[vps_id] = current
|
||||||
|
|
||||||
|
|
||||||
|
async def _container_state_collector() -> None:
|
||||||
|
"""Tache de fond : verifie les etats des conteneurs toutes les 15 secondes."""
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
while True:
|
||||||
|
vps_list = load_vps()
|
||||||
|
await asyncio.gather(
|
||||||
|
*[_check_container_states(v) for v in vps_list],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
|
||||||
|
|
||||||
async def _refresh_latest_agent_version() -> None:
|
async def _refresh_latest_agent_version() -> None:
|
||||||
"""Récupère AGENT_VERSION depuis le dépôt Gitea toutes les heures.
|
"""Récupère AGENT_VERSION depuis le dépôt Gitea toutes les heures.
|
||||||
Si EXPECTED_AGENT_VERSION est défini en env var, cette tâche n'écrase pas la valeur forcée.
|
Si EXPECTED_AGENT_VERSION est défini en env var, cette tâche n'écrase pas la valeur forcée.
|
||||||
@@ -655,17 +780,19 @@ async def _refresh_latest_agent_version() -> None:
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event() -> None:
|
async def startup_event() -> None:
|
||||||
asyncio.create_task(_stats_collector())
|
asyncio.create_task(_stats_collector())
|
||||||
|
asyncio.create_task(_container_state_collector())
|
||||||
asyncio.create_task(_cleanup_old_stats())
|
asyncio.create_task(_cleanup_old_stats())
|
||||||
asyncio.create_task(_refresh_latest_agent_version())
|
asyncio.create_task(_refresh_latest_agent_version())
|
||||||
|
|
||||||
|
|
||||||
async def _cleanup_old_stats() -> None:
|
async def _cleanup_old_stats() -> None:
|
||||||
"""Supprime les statistiques de plus de 31 jours (s'exécute toutes les heures)."""
|
"""Supprime les statistiques et evenements de plus de 31 jours (toutes les heures)."""
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(3600)
|
await asyncio.sleep(3600)
|
||||||
cutoff = int(time.time()) - 31 * 24 * 3600
|
cutoff = int(time.time()) - 31 * 24 * 3600
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute("DELETE FROM vps_stats WHERE ts < ?", (cutoff,))
|
conn.execute("DELETE FROM vps_stats WHERE ts < ?", (cutoff,))
|
||||||
|
conn.execute("DELETE FROM container_events WHERE ts < ?", (cutoff,))
|
||||||
|
|
||||||
|
|
||||||
# ─── Routes Auth ──────────────────────────────────────────────────────────────
|
# ─── Routes Auth ──────────────────────────────────────────────────────────────
|
||||||
@@ -760,7 +887,7 @@ def admin_update_setting(
|
|||||||
_: Annotated[dict, Depends(require_admin)],
|
_: Annotated[dict, Depends(require_admin)],
|
||||||
):
|
):
|
||||||
"""Met à jour un paramètre d'administration."""
|
"""Met à jour un paramètre d'administration."""
|
||||||
allowed_keys = {"registration_open", "passkey_enabled"}
|
allowed_keys = {"registration_open", "passkey_enabled", "pushover_enabled", "pushover_app_token", "pushover_user_key"}
|
||||||
if key not in allowed_keys:
|
if key not in allowed_keys:
|
||||||
raise HTTPException(status_code=400, detail="Clé de paramètre inconnue")
|
raise HTTPException(status_code=400, detail="Clé de paramètre inconnue")
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
@@ -825,12 +952,13 @@ def admin_db_info(_: Annotated[dict, Depends(require_admin)]):
|
|||||||
"newest_ts": row["newest"],
|
"newest_ts": row["newest"],
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"vps_stats": _table_info("vps_stats"),
|
"vps_stats": _table_info("vps_stats"),
|
||||||
"login_logs": _table_info("login_logs"),
|
"login_logs": _table_info("login_logs"),
|
||||||
|
"container_events": _table_info("container_events"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_ALLOWED_TABLES = frozenset({"vps_stats", "login_logs"})
|
_ALLOWED_TABLES = frozenset({"vps_stats", "login_logs", "container_events"})
|
||||||
_ALLOWED_PERIODS = frozenset({"last_24h", "last_7d", "last_30d", "all", "custom"})
|
_ALLOWED_PERIODS = frozenset({"last_24h", "last_7d", "last_30d", "all", "custom"})
|
||||||
|
|
||||||
|
|
||||||
@@ -879,6 +1007,52 @@ def admin_db_purge(body: PurgeRequest, _: Annotated[dict, Depends(require_admin)
|
|||||||
return {"deleted": deleted, "status": "ok"}
|
return {"deleted": deleted, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/container-events")
|
||||||
|
def admin_container_events(
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
_: Annotated[dict, Depends(require_admin)] = None,
|
||||||
|
):
|
||||||
|
"""Retourne l'historique des changements d'etat des conteneurs."""
|
||||||
|
limit = max(1, min(limit, 500))
|
||||||
|
offset = max(0, offset)
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM container_events ORDER BY ts DESC LIMIT ? OFFSET ?",
|
||||||
|
(limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM container_events").fetchone()[0]
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"ts": datetime.fromtimestamp(row["ts"], tz=timezone.utc).isoformat(),
|
||||||
|
"vps_name": row["vps_name"],
|
||||||
|
"container": row["container"],
|
||||||
|
"image": row["image"],
|
||||||
|
"old_state": row["old_state"],
|
||||||
|
"new_state": row["new_state"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/notifications/test")
|
||||||
|
async def admin_test_notification(_: Annotated[dict, Depends(require_admin)]):
|
||||||
|
"""Envoie une notification Pushover de test."""
|
||||||
|
if _get_setting("pushover_enabled") != "true":
|
||||||
|
raise HTTPException(status_code=400, detail="Les notifications Pushover sont desactivees")
|
||||||
|
ok = await _send_pushover("VPS Monitor - Test", "La notification de test a bien ete recue.")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="Echec de l'envoi - verifiez le token et la cle utilisateur",
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
# ─── Routes Passkeys ──────────────────────────────────────────────────────────
|
# ─── Routes Passkeys ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.post("/api/auth/passkey/register/begin")
|
@app.post("/api/auth/passkey/register/begin")
|
||||||
|
|||||||
@@ -321,3 +321,20 @@ export async function adminDeletePasskey(credentialId) {
|
|||||||
})
|
})
|
||||||
return handleResponse(res)
|
return handleResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Notifications / Container events ────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getContainerEvents(limit = 100, offset = 0) {
|
||||||
|
const res = await fetch(`${BASE}/admin/container-events?limit=${limit}&offset=${offset}`, {
|
||||||
|
headers: authHeaders(),
|
||||||
|
})
|
||||||
|
return handleResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testPushoverNotification() {
|
||||||
|
const res = await fetch(`${BASE}/admin/notifications/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
})
|
||||||
|
return handleResponse(res)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle, Fingerprint, Key } from 'lucide-react'
|
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 } from '../api/client'
|
import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey, getContainerEvents, testPushoverNotification } from '../api/client'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
function ToggleRow({ label, description, enabled, onChange, loading }) {
|
function ToggleRow({ label, description, enabled, onChange, loading }) { return (
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-4 py-3">
|
<div className="flex items-center justify-between gap-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-200">{label}</p>
|
<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 }) {
|
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 ────────────────────────────────────────────────────────────
|
// ─── Settings ────────────────────────────────────────────────────────────
|
||||||
const [settings, setSettings] = useState(null)
|
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 ──────────────────────────────────────────────────────
|
// ─── Passkeys admin ──────────────────────────────────────────────────────
|
||||||
const [adminPasskeys, setAdminPasskeys] = useState([])
|
const [adminPasskeys, setAdminPasskeys] = useState([])
|
||||||
const [adminPasskeysLoading, setAdminPasskeysLoading] = useState(false)
|
const [adminPasskeysLoading, setAdminPasskeysLoading] = useState(false)
|
||||||
@@ -244,10 +352,11 @@ export default function AdminPage({ onBack }) {
|
|||||||
{/* ── Tabs ── */}
|
{/* ── 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">
|
||||||
{[
|
{[
|
||||||
{ key: 'settings', label: 'Paramètres' },
|
{ key: 'settings', label: 'Paramètres' },
|
||||||
{ key: 'passkeys', label: 'Passkeys' },
|
{ key: 'notifications', label: 'Notifications' },
|
||||||
{ key: 'logs', label: 'Connexions' },
|
{ key: 'passkeys', label: 'Passkeys' },
|
||||||
{ key: 'database', label: 'Base de données' },
|
{ key: 'logs', label: 'Connexions' },
|
||||||
|
{ key: 'database', label: 'Base de données' },
|
||||||
].map(tab => (
|
].map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
@@ -299,6 +408,213 @@ export default function AdminPage({ onBack }) {
|
|||||||
</section>
|
</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 ── */}
|
{/* ── Tab: Passkeys ── */}
|
||||||
{activeTab === 'passkeys' && (
|
{activeTab === 'passkeys' && (
|
||||||
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
||||||
|
|||||||
Reference in New Issue
Block a user