Files
ObsiGate/frontend/js/admin.js
T
bruno 46be24f6a3
CI / lint (push) Successful in 36s
CI / security (push) Successful in 23s
CI / test (push) Successful in 46s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 5m43s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
feat(admin): frontend dashboard complet pour #71
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
2026-09-07 11:14:46 -04:00

434 lines
16 KiB
JavaScript

// 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 = `<div class="admin-inline-error" style="color:var(--danger);padding:8px 12px;font-size:0.85rem;">${escapeHtml(msg)}</div>`;
}
// ── 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 = `
<div class="admin-stat-card" data-sev="${cpuSev}">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_cpu"))}</div>
<div class="admin-stat-value">${cpuPct.toFixed(1)}<span class="admin-stat-unit">%</span></div>
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(cpuPct, 100).toFixed(1)}%;background:var(--${cpuSev});"></div></div>
</div>
<div class="admin-stat-card" data-sev="${memSev}">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_memory"))}</div>
<div class="admin-stat-value">${stats.mem_used_mb?.toFixed(0) ?? "—"}<span class="admin-stat-unit"> / ${memTotal.toFixed(0)} MB</span></div>
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(memPct, 100).toFixed(1)}%;background:var(--${memSev});"></div></div>
</div>
<div class="admin-stat-card" data-sev="${diskSev}">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_disk"))}</div>
<div class="admin-stat-value">${stats.disk_used_gb?.toFixed(1) ?? "—"}<span class="admin-stat-unit"> / ${diskTotal.toFixed(1)} GB</span></div>
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(diskPct, 100).toFixed(1)}%;background:var(--${diskSev});"></div></div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_uptime"))}</div>
<div class="admin-stat-value" style="font-size:1.4rem;">${escapeHtml(formatUptime(stats.uptime_seconds))}</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_sessions"))}</div>
<div class="admin-stat-value">${stats.active_sessions ?? 0}</div>
</div>
`;
// 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<object>} 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 `<tr>
<td>${escapeHtml(ts)}</td>
<td><code>${action}</code></td>
<td>${user}</td>
<td>${vault}</td>
<td>${ip}</td>
</tr>`;
}).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 <select> with the values we know about.
* The endpoint doesn't list all possible actions, so we hardcode the
* common ones and let the user pick "All". */
function _populateAuditActions() {
const select = document.getElementById("admin-audit-action");
if (!select) return;
const ACTIONS = [
"file_save", "file_delete", "file_view", "file_rename",
"share_create", "share_revoke", "auth_login", "auth_logout",
"auth_failed", "user_create", "user_update", "user_delete",
"backup_create", "config_update",
];
select.innerHTML =
`<option value="">${escapeHtml(t("admin.audit_all_actions"))}</option>` +
ACTIONS.map((a) => `<option value="${a}">${escapeHtml(a)}</option>`).join("");
}
// ── Backups ─────────────────────────────────────────────────────────────
/**
* Render per-vault backup bars.
* @param {object} byVault { vault: {count, size_mb} }
*/
export function renderBackups(byVault) {
const tbody = document.getElementById("admin-backups-tbody");
const empty = document.getElementById("admin-backups-empty");
if (!tbody) return;
const entries = byVault ? Object.entries(byVault) : [];
if (!entries.length) {
tbody.innerHTML = "";
if (empty) empty.style.display = "";
return;
}
if (empty) empty.style.display = "none";
// Largest vault for proportional bars
const maxSize = Math.max(...entries.map(([, v]) => v.size_mb || 0), 1);
tbody.innerHTML = entries
.sort((a, b) => (b[1].size_mb || 0) - (a[1].size_mb || 0))
.map(([vault, data]) => {
const count = data.count || 0;
const size = data.size_mb || 0;
const widthPct = Math.min((size / maxSize) * 100, 100).toFixed(1);
return `<tr>
<td>${escapeHtml(vault)}</td>
<td>${count}</td>
<td>${escapeHtml(formatBytes(size))}</td>
<td>
<div class="admin-backup-bar"><div class="admin-backup-bar-fill" style="width:${widthPct}%;"></div></div>
</td>
</tr>`;
})
.join("");
}
/** Fetch /api/admin/backup-stats and paint. */
export async function loadBackupStats() {
try {
const data = await api("/api/admin/backup-stats");
const summary = document.getElementById("admin-backups-summary");
if (summary) {
summary.innerHTML = `
<div>${escapeHtml(t("admin.backups_total", { count: data.total_backups ?? 0, size: formatBytes(data.total_size_mb) }))}</div>
<div>${escapeHtml(t("admin.backups_oldest", { days: (data.oldest_age_days ?? 0).toFixed(2) }))}</div>
<div>${escapeHtml(t("admin.backups_newest", { days: (data.newest_age_days ?? 0).toFixed(2) }))}</div>
`;
}
renderBackups(data.by_vault || {});
} catch (err) {
_showInlineError("admin-backups-tbody", err.message || "backups load failed");
}
}
// ── User management integration ─────────────────────────────────────────
/** Wire the "Manage users" button to open the existing AdminPanel modal. */
function _wireUserMgmt() {
const btn = document.getElementById("admin-users-manage-btn");
if (!btn) return;
btn.addEventListener("click", () => {
try {
AdminPanel.show();
} catch (err) {
console.error("AdminPanel.show failed", err);
}
});
}
// ── Filters wiring ──────────────────────────────────────────────────────
function _wireAuditFilters() {
const userInput = document.getElementById("admin-audit-user");
const actionSelect = document.getElementById("admin-audit-action");
const refreshBtn = document.getElementById("admin-audit-refresh");
if (refreshBtn) {
refreshBtn.addEventListener("click", () => loadAuditLogs());
}
if (userInput) {
userInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") loadAuditLogs();
});
}
if (actionSelect) {
actionSelect.addEventListener("change", () => loadAuditLogs());
}
}
// ── Auth gate ────────────────────────────────────────────────────────────
/**
* Verify the current session and admin role.
* Redirects to / if not authenticated or not admin.
* Returns the user object on success, null otherwise.
*/
async function _gateAdmin() {
const container = document.getElementById("admin-main");
const forbidden = document.getElementById("admin-forbidden");
try {
const res = await fetch("/api/auth/status", { credentials: "include" });
if (!res.ok) throw new Error("HTTP " + res.status);
const status = await res.json();
if (!status.auth_enabled) {
// Auth disabled — server is wide open, treat current visitor as allowed.
return { username: "anonymous", role: "admin" };
}
if (!status.authenticated) {
window.location.href = "/";
return null;
}
if (status.user?.role !== "admin") {
if (container) container.style.display = "none";
if (forbidden) forbidden.style.display = "";
return null;
}
return status.user;
} catch (err) {
// Network error — try to use cached sessionStorage user as fallback
try {
const cached = sessionStorage.getItem("obsigate_user");
if (cached) {
const u = JSON.parse(cached);
if (u.role === "admin") return u;
}
} catch { /* */ }
window.location.href = "/";
return null;
}
}
// ── Init ─────────────────────────────────────────────────────────────────
/**
* Page entry point. Called on DOMContentLoaded by admin.html.
* Verifies auth, populates the action select, wires events, kicks off loads.
*/
export async function init() {
const user = await _gateAdmin();
if (!user) return;
// Show role badge
const badge = document.getElementById("admin-role-badge");
if (badge) {
badge.textContent = user.role || "user";
badge.dataset.role = user.role || "user";
}
_populateAuditActions();
_wireAuditFilters();
_wireUserMgmt();
// Initial loads (in parallel — none depends on another)
loadStatsOnce();
loadAuditLogs();
loadBackupStats();
// Then start the live SSE stream for stats
connectSSE();
}