// 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 _pollTimer = null; let _auditFilters = { user: "", action: "" }; // ── Helpers ────────────────────────────────────────────────────────────── /** * BUG-034: the access token now lives in an HttpOnly cookie (and in memory * inside auth.js), never in sessionStorage. Same-origin requests carry the * cookie automatically, so no Authorization header is needed here. */ export function getAuthHeaders() { 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. * * The browser's EventSource doesn't support custom headers, so we rely on * the httpOnly cookie (set by /api/auth/login with samesite=lax) to authenticate. * `withCredentials: true` ensures the cookie is sent. * * If the connection fails to open (4xx/5xx or network), we fall back to * periodic polling of /api/admin/stats every 5s so the UI keeps updating. */ export function connectSSE() { if (_eventSource) { try { _eventSource.close(); } catch { /* */ } _eventSource = null; } if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } let sseReady = false; try { _eventSource = new EventSource("/api/admin/stream", { withCredentials: true }); _eventSource.addEventListener("stats", (ev) => { sseReady = true; try { const data = JSON.parse(ev.data); renderStatsWidget(data); } catch (err) { console.warn("admin SSE parse error", err); } }); _eventSource.onopen = () => { sseReady = true; // Stop the polling fallback once SSE works. if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } }; _eventSource.onerror = () => { // EventSource auto-reconnects. Only start polling fallback if we // never received the first event yet (e.g. 401/403/network blocked). if (!sseReady) { _startPollingFallback(); } console.warn("admin SSE error (will retry)"); }; } catch (err) { console.warn("admin SSE init failed", err); _startPollingFallback(); } // Kick off a single initial fetch right away so the UI doesn't show // zeros for 5 seconds waiting for the first SSE event. loadStatsOnce(); } function _startPollingFallback() { if (_pollTimer) return; console.warn("admin: falling back to polling /api/admin/stats every 5s"); _pollTimer = setInterval(() => { loadStatsOnce().catch(() => { /* ignore — error already shown */ }); }, 5000); } /** Close the SSE connection if any (useful before navigation). */ export function disconnectSSE() { if (_eventSource) { try { _eventSource.close(); } catch { /* */ } _eventSource = null; } if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = 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