// 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