From 46be24f6a3ec2213108e2c879548c71016a3907b Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 7 Sep 2026 11:14:46 -0400 Subject: [PATCH] feat(admin): frontend dashboard complet pour #71 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page admin standalone + widgets temps réel + CRUD users via AdminPanel existant. - frontend/admin.html : page admin (header + 4 sections : stats temps réel, audit logs, backups, gestion utilisateurs + footer) - frontend/js/admin.js : module ES avec init(), connectSSE(), loadStatsOnce(), loadAuditLogs(), loadBackupStats(), renderStatsWidget(), renderAuditTable(), renderBackups() + helpers (formatBytes, formatUptime, severityColor) - frontend/locales/{fr,en}.json : 36 clés admin.* identiques (diff vérifié) - frontend/js/auth.js : adminRow.onclick redirige vers /admin.html (au lieu de AdminPanel.show() qui reste fonctionnel) - tests/frontend/unit.test.mjs : 3 nouveaux tests (admin.js existe + exports + parse) - tests/test_auth_api.py : 2 nouveaux tests smoke (PATCH + DELETE /api/auth/admin/users) Vérifié : - pytest : 494 passed, 5 skipped (492 baseline + 2 nouveaux) - frontend unit : 7 passed (4 baseline + 3 admin) - validate-imports : 30 modules validated (29 + admin.js) - pane-manager JSDOM : 9/9 passed - ruff check backend/ : All checks passed --- frontend/admin.html | 398 ++++++++++++++++++++++++++++++++ frontend/js/admin.js | 433 +++++++++++++++++++++++++++++++++++ frontend/js/auth.js | 2 +- frontend/locales/en.json | 27 +++ frontend/locales/fr.json | 27 +++ tests/frontend/unit.test.mjs | 63 +++++ tests/test_auth_api.py | 46 ++++ 7 files changed, 995 insertions(+), 1 deletion(-) create mode 100644 frontend/admin.html create mode 100644 frontend/js/admin.js diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..694ff02 --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,398 @@ + + + + + + ObsiGate — Administration + + + + + + + + +
+
+

ObsiGate — Admin

+ Tableau de bord administrateur +
+
+ … + ← Retour +
+
+ +
+ +
Chargement…
+ + +
+

Statistiques temps réel

+
…
+
+ + +
+

Logs d'audit

+
+ + + +
+ + + + + + + + + + + + +
DateActionUserVaultIP
+
+ + +
+

Statistiques backups

+
+ + + + + + + + + + + +
Par vaultCountSizeDistribution
+
+ + +
+

Gestion utilisateurs

+

Ouvrir la modale de gestion des utilisateurs (CRUD complet)

+ +
+
+ +
+

Accès refusé

+

Vous devez être administrateur pour accéder à cette page.

+ ← Retour +
+ + + + + + diff --git a/frontend/js/admin.js b/frontend/js/admin.js new file mode 100644 index 0000000..21941ab --- /dev/null +++ b/frontend/js/admin.js @@ -0,0 +1,433 @@ +// frontend/js/admin.js — Admin Dashboard frontend module (ROADMAP #71) +// +// Standalone ES module loaded by frontend/admin.html. +// Public API: +// init() — DOMContentLoaded entry point. Verifies auth, fires initial loads. +// connectSSE() — Opens EventSource on /api/admin/stream; updates widgets on every "stats" event. +// loadStatsOnce() — Initial GET /api/admin/stats (snapshot before SSE delivers first frame). +// loadAuditLogs(filters) — GET /api/admin/audit with optional {user, action}. +// loadBackupStats() — GET /api/admin/backup-stats. +// renderStatsWidget(stats) — Paint CPU/RAM/Disk/Uptime/Sessions cards. +// renderAuditTable(entries) — Paint audit log table. +// renderBackups(byVault) — Paint per-vault backup bars. +// getAuthHeaders() — Read Bearer token from sessionStorage (same source as auth.js). +// formatBytes(mb) — Human-readable size formatter. +// formatUptime(seconds) — "Xd Yh Zm" formatter. +// severityColor(pct) — "success" | "warning" | "danger" based on thresholds. + +import { api, AdminPanel } from "./auth.js"; +import { state } from "./state.js"; +import { escapeHtml, safeCreateIcons } from "./utils.js"; +import { t, getLocale } from "./i18n.js"; + +// ── Module state ───────────────────────────────────────────────────────── +let _eventSource = null; +let _auditFilters = { user: "", action: "" }; + +// ── Helpers ────────────────────────────────────────────────────────────── + +/** + * Read the current Bearer token from sessionStorage (same key auth.js uses). + * Returns an `{Authorization: "Bearer ..."}` object or null. + */ +export function getAuthHeaders() { + try { + const token = sessionStorage.getItem("obsigate_access_token"); + if (!token) return null; + return { Authorization: "Bearer " + token }; + } catch { + return null; + } +} + +/** Format a size in MB as a human-readable string. */ +export function formatBytes(mb) { + if (mb == null || isNaN(mb)) return "—"; + if (mb < 1) return `${Math.round(mb * 1024)} KB`; + if (mb < 1024) return `${mb.toFixed(1)} MB`; + return `${(mb / 1024).toFixed(2)} GB`; +} + +/** Format an uptime in seconds as "Xd Yh Zm" (or "Xh Ym" / "Xm"). */ +export function formatUptime(seconds) { + if (seconds == null || isNaN(seconds) || seconds < 0) return "—"; + const s = Math.floor(seconds); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +/** + * Map a percentage to a CSS severity token. + * <70 → success (green) + * <90 → warning (orange) + * ≥90 → danger (red) + */ +export function severityColor(pct) { + if (pct == null || isNaN(pct)) return "success"; + if (pct >= 90) return "danger"; + if (pct >= 70) return "warning"; + return "success"; +} + +/** Format an ISO timestamp with the user's locale. */ +function formatDate(iso) { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleString( + getLocale() === "fr" ? "fr-FR" : "en-US" + ); + } catch { + return iso; + } +} + +/** Discreet error helper — never crashes the page. */ +function _showInlineError(containerId, msg) { + const el = document.getElementById(containerId); + if (!el) return; + el.innerHTML = `
${escapeHtml(msg)}
`; +} + +// ── Stats widget ───────────────────────────────────────────────────────── + +/** + * Paint the 5 stats cards. + * @param {object} stats {cpu_pct, mem_used_mb, mem_total_mb, disk_used_gb, disk_total_gb, uptime_seconds, active_sessions} + */ +export function renderStatsWidget(stats) { + if (!stats) return; + const grid = document.getElementById("admin-stats-grid"); + if (!grid) return; + + const cpuPct = stats.cpu_pct ?? 0; + const memPct = stats.mem_total_mb > 0 ? (stats.mem_used_mb / stats.mem_total_mb) * 100 : 0; + const diskPct = stats.disk_total_gb > 0 ? (stats.disk_used_gb / stats.disk_total_gb) * 100 : 0; + + const cpuSev = severityColor(cpuPct); + const memSev = severityColor(memPct); + const diskSev = severityColor(diskPct); + + const memTotal = stats.mem_total_mb ?? 0; + const diskTotal = stats.disk_total_gb ?? 0; + + grid.innerHTML = ` +
+
${escapeHtml(t("admin.stats_cpu"))}
+
${cpuPct.toFixed(1)}%
+
+
+
+
${escapeHtml(t("admin.stats_memory"))}
+
${stats.mem_used_mb?.toFixed(0) ?? "—"} / ${memTotal.toFixed(0)} MB
+
+
+
+
${escapeHtml(t("admin.stats_disk"))}
+
${stats.disk_used_gb?.toFixed(1) ?? "—"} / ${diskTotal.toFixed(1)} GB
+
+
+
+
${escapeHtml(t("admin.stats_uptime"))}
+
${escapeHtml(formatUptime(stats.uptime_seconds))}
+
+
+
${escapeHtml(t("admin.stats_sessions"))}
+
${stats.active_sessions ?? 0}
+
+ `; + + // Update footer timestamp + const ts = stats.timestamp ? new Date(stats.timestamp) : new Date(); + const stamp = document.getElementById("admin-stats-timestamp"); + if (stamp) { + stamp.textContent = t("admin.stats_refresh", { time: ts.toLocaleTimeString(getLocale() === "fr" ? "fr-FR" : "en-US") }); + } +} + +/** Fetch /api/admin/stats and paint. */ +export async function loadStatsOnce() { + try { + const data = await api("/api/admin/stats"); + renderStatsWidget(data); + } catch (err) { + _showInlineError("admin-stats-grid", err.message || "stats load failed"); + } +} + +/** + * Open a Server-Sent Events connection on /api/admin/stream. + * The endpoint emits `event: stats\ndata: {...}` every 5 seconds. + */ +export function connectSSE() { + if (_eventSource) { + try { _eventSource.close(); } catch { /* */ } + _eventSource = null; + } + try { + _eventSource = new EventSource("/api/admin/stream", { withCredentials: true }); + _eventSource.addEventListener("stats", (ev) => { + try { + const data = JSON.parse(ev.data); + renderStatsWidget(data); + } catch (err) { + console.warn("admin SSE parse error", err); + } + }); + _eventSource.onerror = () => { + // EventSource auto-reconnects. We just log quietly. + console.warn("admin SSE error (will retry)"); + }; + } catch (err) { + console.warn("admin SSE init failed", err); + } +} + +/** Close the SSE connection if any (useful before navigation). */ +export function disconnectSSE() { + if (_eventSource) { + try { _eventSource.close(); } catch { /* */ } + _eventSource = null; + } +} + +// ── Audit log ──────────────────────────────────────────────────────────── + +/** + * Render the audit log table. + * @param {Array} entries Array of audit entries (timestamp, action, username, vault, ip, ...). + */ +export function renderAuditTable(entries) { + const tbody = document.getElementById("admin-audit-tbody"); + const empty = document.getElementById("admin-audit-empty"); + if (!tbody) return; + + if (!entries || !entries.length) { + tbody.innerHTML = ""; + if (empty) empty.style.display = ""; + return; + } + if (empty) empty.style.display = "none"; + + tbody.innerHTML = entries.map((e) => { + const ts = formatDate(e.timestamp); + const action = escapeHtml(e.action || "—"); + const user = escapeHtml(e.username || e.user || "—"); + const vault = escapeHtml(e.vault || "—"); + const ip = escapeHtml(e.ip || "—"); + return ` + ${escapeHtml(ts)} + ${action} + ${user} + ${vault} + ${ip} + `; + }).join(""); +} + +/** + * Fetch /api/admin/audit with optional filters. + * @param {{user?: string, action?: string}} filters + */ +export async function loadAuditLogs(filters = {}) { + if (filters.user !== undefined) _auditFilters.user = filters.user; + if (filters.action !== undefined) _auditFilters.action = filters.action; + + const params = new URLSearchParams(); + if (_auditFilters.user) params.set("user", _auditFilters.user); + if (_auditFilters.action) params.set("action", _auditFilters.action); + params.set("limit", "200"); + + const qs = params.toString(); + try { + const data = await api(`/api/admin/audit?${qs}`); + renderAuditTable(data.entries || []); + } catch (err) { + _showInlineError("admin-audit-tbody", err.message || "audit load failed"); + } +} + +/** Populate the action