CI / lint (push) Successful in 38s
CI / security (push) Successful in 26s
CI / test (push) Successful in 50s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m7s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Navigation/ergonomie: - barre sticky de pilules (Stats, Audit, Backups, Utilisateurs) sous le header - scrollspy (IntersectionObserver) + scroll fluide au clic dans admin.js - ancres + scroll-margin-top sur chaque section, layout responsive <600px Themes: - admin.html importe et appelle initThemes() (themes.js) au boot: la page reprend le theme/mode sauvegarde dans l'app (localStorage meme-origine) - script anti-FOUC en <head> applique vite le mode avant le CSS
545 lines
20 KiB
JavaScript
545 lines
20 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 _pollTimer = 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.
|
|
*
|
|
* 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<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());
|
|
}
|
|
}
|
|
|
|
// ── Section nav (scrollspy) ───────────────────────────────────────────
|
|
|
|
/**
|
|
* Wire the sticky section nav: smooth-scroll on click + highlight the section
|
|
* currently in view (IntersectionObserver). Falls back to default anchor jump
|
|
* if the observer is unavailable.
|
|
*/
|
|
function _wireSectionNav() {
|
|
const links = Array.from(document.querySelectorAll("#admin-subnav a"));
|
|
if (!links.length) return;
|
|
const targets = links
|
|
.map((a) => document.getElementById((a.getAttribute("href") || "#").slice(1)))
|
|
.filter(Boolean);
|
|
|
|
// Smooth scroll on click
|
|
links.forEach((a) => {
|
|
a.addEventListener("click", (e) => {
|
|
const href = a.getAttribute("href");
|
|
if (!href || !href.startsWith("#")) return;
|
|
const t = document.getElementById(href.slice(1));
|
|
if (!t) return;
|
|
e.preventDefault();
|
|
t.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
});
|
|
});
|
|
|
|
// Scrollspy: mark the section currently at the top of the viewport
|
|
let active = null;
|
|
const setActive = (id) => {
|
|
if (active === id) return;
|
|
active = id;
|
|
links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id));
|
|
};
|
|
|
|
if ("IntersectionObserver" in window) {
|
|
const io = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((en) => {
|
|
if (en.isIntersecting) setActive(en.target.id);
|
|
});
|
|
},
|
|
{ rootMargin: "-64px 0px -70% 0px", threshold: 0 }
|
|
);
|
|
targets.forEach((t) => io.observe(t));
|
|
} else {
|
|
// Fallback: highlight the first section only (no scroll observation)
|
|
links[0] && links[0].classList.add("active");
|
|
}
|
|
}
|
|
|
|
// ── Auth gate ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Verify the current session and admin role.
|
|
* Uses /api/auth/me (which returns the current user) and falls back to
|
|
* the cached user in sessionStorage if the request fails.
|
|
* 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");
|
|
// First check whether auth is even enabled (public endpoint).
|
|
let authEnabled = false;
|
|
try {
|
|
const statusRes = await fetch("/api/auth/status", { credentials: "include" });
|
|
if (statusRes.ok) {
|
|
const status = await statusRes.json();
|
|
authEnabled = !!status.auth_enabled;
|
|
}
|
|
} catch { /* network error — fall through */ }
|
|
|
|
if (!authEnabled) {
|
|
// Server is wide open, treat current visitor as allowed.
|
|
return { username: "anonymous", role: "admin" };
|
|
}
|
|
|
|
// Auth is enabled — try to load the current user.
|
|
try {
|
|
const meRes = await fetch("/api/auth/me", {
|
|
credentials: "include",
|
|
headers: getAuthHeaders(),
|
|
});
|
|
if (meRes.status === 401 || meRes.status === 403) {
|
|
window.location.href = "/";
|
|
return null;
|
|
}
|
|
if (!meRes.ok) throw new Error("HTTP " + meRes.status);
|
|
const user = await meRes.json();
|
|
// Cache for later fallback
|
|
try {
|
|
sessionStorage.setItem("obsigate_user", JSON.stringify(user));
|
|
} catch { /* */ }
|
|
if (user.role !== "admin") {
|
|
if (container) container.style.display = "none";
|
|
if (forbidden) forbidden.style.display = "";
|
|
return null;
|
|
}
|
|
return 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();
|
|
_wireSectionNav();
|
|
|
|
// Initial loads (in parallel — none depends on another)
|
|
loadStatsOnce();
|
|
loadAuditLogs();
|
|
loadBackupStats();
|
|
|
|
// Then start the live SSE stream for stats
|
|
connectSSE();
|
|
}
|