feat(admin): frontend dashboard complet pour #71
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
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
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
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
<!doctype html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title data-i18n="admin.page_title">ObsiGate — Administration</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon-72x72.svg" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
|
||||
<style>
|
||||
/* ── Admin page — local styles (kept inline to limit impact on /static/style.css) ── */
|
||||
:root {
|
||||
--admin-card-bg: var(--bg-secondary, #161b22);
|
||||
--admin-card-border: var(--border, #21262d);
|
||||
--admin-success: var(--success, #3fb950);
|
||||
--admin-warning: var(--warning, #f59e0b);
|
||||
--admin-danger: var(--danger, #ff7b72);
|
||||
}
|
||||
body.admin-page {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 24px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
border-bottom: 1px solid var(--admin-card-border);
|
||||
}
|
||||
.admin-header-left { display: flex; align-items: center; gap: 14px; }
|
||||
.admin-header h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
.admin-header .admin-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
.admin-header-right { display: flex; align-items: center; gap: 12px; }
|
||||
#admin-role-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background: var(--accent-bg, #1f2a3a);
|
||||
color: var(--accent-text, #79c0ff);
|
||||
border: 1px solid var(--accent, #58a6ff);
|
||||
}
|
||||
#admin-role-badge[data-role="admin"] {
|
||||
background: var(--success-bg, #1a3d1f);
|
||||
color: var(--success, #3fb950);
|
||||
border-color: var(--success, #3fb950);
|
||||
}
|
||||
.admin-back-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--bg-hover, #1f2430);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.admin-back-btn:hover { background: var(--bg-sidebar, #13161d); }
|
||||
|
||||
.admin-main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
.admin-section {
|
||||
background: var(--admin-card-bg);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
.admin-section h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-section h2::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: var(--accent, #58a6ff);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Stats grid ── */
|
||||
#admin-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.admin-stat-card {
|
||||
background: var(--bg-hover, #1f2430);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.admin-stat-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
.admin-stat-unit {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
.admin-stat-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-stat-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease, background 0.4s ease;
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.admin-table th, .admin-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--admin-card-border);
|
||||
}
|
||||
.admin-table th {
|
||||
background: var(--bg-primary, #0f1117);
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--bg-hover, #1f2430);
|
||||
}
|
||||
.admin-table code {
|
||||
background: var(--bg-primary, #0f1117);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-text, #79c0ff);
|
||||
}
|
||||
|
||||
/* ── Filters ── */
|
||||
.admin-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-filters input, .admin-filters select {
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.admin-filters input:focus, .admin-filters select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.admin-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--accent, #58a6ff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.admin-btn:hover { opacity: 0.85; }
|
||||
.admin-btn-secondary {
|
||||
background: var(--bg-hover, #1f2430);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
}
|
||||
|
||||
/* ── Backups ── */
|
||||
#admin-backups-summary {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
#admin-backups-summary > div {
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--admin-card-border);
|
||||
}
|
||||
.admin-backup-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 120px;
|
||||
}
|
||||
.admin-backup-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent, #58a6ff), var(--accent-text, #79c0ff));
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* ── Users management ── */
|
||||
#admin-users-section .admin-users-hint {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.admin-footer {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 24px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #484f58);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--admin-card-border);
|
||||
}
|
||||
#admin-stats-timestamp { color: var(--text-secondary, #8b949e); }
|
||||
|
||||
/* ── Forbidden screen ── */
|
||||
#admin-forbidden {
|
||||
display: none;
|
||||
max-width: 500px;
|
||||
margin: 100px auto;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
background: var(--admin-card-bg);
|
||||
border: 1px solid var(--admin-danger);
|
||||
border-radius: 10px;
|
||||
}
|
||||
#admin-forbidden h2 { color: var(--admin-danger); margin-top: 0; }
|
||||
|
||||
/* ── Loading state ── */
|
||||
.admin-loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
<header class="admin-header">
|
||||
<div class="admin-header-left">
|
||||
<h1>ObsiGate — Admin</h1>
|
||||
<span class="admin-subtitle" data-i18n="admin.header_subtitle">Tableau de bord administrateur</span>
|
||||
</div>
|
||||
<div class="admin-header-right">
|
||||
<span id="admin-role-badge">…</span>
|
||||
<a href="/" class="admin-back-btn" data-i18n="admin.back_btn">← Retour</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="admin-main" class="admin-main">
|
||||
<!-- Loading state shown until init() resolves -->
|
||||
<div id="admin-loading" class="admin-loading" data-i18n="common.loading">Chargement…</div>
|
||||
|
||||
<!-- Section 1 — Stats temps réel -->
|
||||
<section class="admin-section">
|
||||
<h2 data-i18n="admin.stats_title">Statistiques temps réel</h2>
|
||||
<div id="admin-stats-grid" class="admin-loading">…</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 2 — Logs d'audit -->
|
||||
<section class="admin-section">
|
||||
<h2 data-i18n="admin.audit_title">Logs d'audit</h2>
|
||||
<div class="admin-filters">
|
||||
<input type="text" id="admin-audit-user" data-i18n-placeholder="admin.audit_filter_user" placeholder="Filtrer par utilisateur" />
|
||||
<select id="admin-audit-action">
|
||||
<option value="" data-i18n="admin.audit_all_actions">Toutes les actions</option>
|
||||
</select>
|
||||
<button id="admin-audit-refresh" class="admin-btn admin-btn-secondary" data-i18n="admin.audit_refresh">Rafraîchir</button>
|
||||
</div>
|
||||
<div id="admin-audit-empty" class="admin-loading" style="display:none;" data-i18n="admin.audit_empty">Aucun log disponible</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
<th>User</th>
|
||||
<th>Vault</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-audit-tbody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Section 3 — Statistiques backups -->
|
||||
<section class="admin-section">
|
||||
<h2 data-i18n="admin.backups_title">Statistiques backups</h2>
|
||||
<div id="admin-backups-summary"></div>
|
||||
<div id="admin-backups-empty" class="admin-loading" style="display:none;" data-i18n="admin.backups_vault_empty">Aucun backup</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.backups_per_vault">Par vault</th>
|
||||
<th>Count</th>
|
||||
<th>Size</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-backups-tbody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Section 4 — Gestion utilisateurs -->
|
||||
<section class="admin-section" id="admin-users-section">
|
||||
<h2 data-i18n="admin.users_title">Gestion utilisateurs</h2>
|
||||
<p class="admin-users-hint" data-i18n="admin.users_manage_hint">Ouvrir la modale de gestion des utilisateurs (CRUD complet)</p>
|
||||
<button id="admin-users-manage-btn" class="admin-btn" data-i18n="admin.users_manage_btn">Gérer les utilisateurs</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="admin-forbidden">
|
||||
<h2 data-i18n="admin.forbidden_title">Accès refusé</h2>
|
||||
<p data-i18n="admin.forbidden_msg">Vous devez être administrateur pour accéder à cette page.</p>
|
||||
<a href="/" class="admin-back-btn" data-i18n="admin.back_btn">← Retour</a>
|
||||
</div>
|
||||
|
||||
<footer class="admin-footer">
|
||||
<span>ObsiGate Admin — ROADMAP #71</span>
|
||||
<span id="admin-stats-timestamp">—</span>
|
||||
</footer>
|
||||
|
||||
<script type="module">
|
||||
// We import admin.js which provides init() — but the i18n module
|
||||
// is global (window.t), so we can use t() right away to translate
|
||||
// static DOM. admin.js will then call init() after DOMContentLoaded.
|
||||
import { initI18n } from "/frontend/js/i18n.js";
|
||||
import * as Admin from "/frontend/js/admin.js";
|
||||
|
||||
await initI18n();
|
||||
// Re-apply DOM translations now that locale strings are loaded
|
||||
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||
el.textContent = window.t(el.getAttribute("data-i18n"));
|
||||
});
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
||||
el.setAttribute("placeholder", window.t(el.getAttribute("data-i18n-placeholder")));
|
||||
});
|
||||
// Remove loading hint now that i18n is ready
|
||||
const loading = document.getElementById("admin-loading");
|
||||
if (loading) loading.style.display = "none";
|
||||
|
||||
if (typeof Admin.init === "function") {
|
||||
Admin.init();
|
||||
} else {
|
||||
console.error("admin.js did not export init()");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = `<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();
|
||||
}
|
||||
+1
-1
@@ -277,7 +277,7 @@ const AuthManager = {
|
||||
// Important: use an inline function to ensure we don't bind multiple identical listeners on rerenders, or clean up before
|
||||
adminRow.onclick = () => {
|
||||
closeHeaderMenu();
|
||||
AdminPanel.show();
|
||||
window.location.href = "/admin.html";
|
||||
};
|
||||
} else {
|
||||
adminRow.classList.add("hidden");
|
||||
|
||||
@@ -42,6 +42,19 @@
|
||||
"about.tests": "Tests",
|
||||
"about.vaults_configured": "Configured vaults",
|
||||
"about.version": "Version",
|
||||
"admin.audit_all_actions": "All actions",
|
||||
"admin.audit_empty": "No log available",
|
||||
"admin.audit_filter_action": "Action",
|
||||
"admin.audit_filter_user": "Filter by user",
|
||||
"admin.audit_refresh": "Refresh",
|
||||
"admin.audit_title": "Audit logs",
|
||||
"admin.back_btn": "← Back",
|
||||
"admin.backups_newest": "Newest: {days} days",
|
||||
"admin.backups_oldest": "Oldest: {days} days",
|
||||
"admin.backups_per_vault": "Per vault",
|
||||
"admin.backups_title": "Backup statistics",
|
||||
"admin.backups_total": "Total: {count} backups ({size})",
|
||||
"admin.backups_vault_empty": "No backup",
|
||||
"admin.close": "Close",
|
||||
"admin.col_actions": "Actions",
|
||||
"admin.col_last_login": "Last login",
|
||||
@@ -49,8 +62,22 @@
|
||||
"admin.col_status": "Status",
|
||||
"admin.col_user": "User",
|
||||
"admin.col_vaults": "Vaults",
|
||||
"admin.forbidden_msg": "You must be an administrator to access this page.",
|
||||
"admin.forbidden_title": "Access denied",
|
||||
"admin.header_subtitle": "Administrator dashboard",
|
||||
"admin.new_user": "+ New user",
|
||||
"admin.page_title": "ObsiGate — Administration",
|
||||
"admin.stats_cpu": "CPU",
|
||||
"admin.stats_disk": "Disk",
|
||||
"admin.stats_memory": "Memory",
|
||||
"admin.stats_refresh": "Last update: {time}",
|
||||
"admin.stats_sessions": "Active sessions",
|
||||
"admin.stats_title": "Real-time statistics",
|
||||
"admin.stats_uptime": "Uptime",
|
||||
"admin.title": "Administration — Users",
|
||||
"admin.users_manage_btn": "Manage users",
|
||||
"admin.users_manage_hint": "Open the user management modal (full CRUD)",
|
||||
"admin.users_title": "User management",
|
||||
"ai.casual": "Casual tone",
|
||||
"ai.completion_added": "AI: completion added",
|
||||
"ai.continue": "Continue",
|
||||
|
||||
@@ -42,6 +42,19 @@
|
||||
"about.tests": "Tests",
|
||||
"about.vaults_configured": "Vaults configurés",
|
||||
"about.version": "Version",
|
||||
"admin.audit_all_actions": "Toutes les actions",
|
||||
"admin.audit_empty": "Aucun log disponible",
|
||||
"admin.audit_filter_action": "Action",
|
||||
"admin.audit_filter_user": "Filtrer par utilisateur",
|
||||
"admin.audit_refresh": "Rafraîchir",
|
||||
"admin.audit_title": "Logs d'audit",
|
||||
"admin.back_btn": "← Retour",
|
||||
"admin.backups_newest": "Plus récent : {days} jours",
|
||||
"admin.backups_oldest": "Plus ancien : {days} jours",
|
||||
"admin.backups_per_vault": "Par vault",
|
||||
"admin.backups_title": "Statistiques backups",
|
||||
"admin.backups_total": "Total : {count} backups ({size})",
|
||||
"admin.backups_vault_empty": "Aucun backup",
|
||||
"admin.close": "Fermer",
|
||||
"admin.col_actions": "Actions",
|
||||
"admin.col_last_login": "Dernière connexion",
|
||||
@@ -49,8 +62,22 @@
|
||||
"admin.col_status": "Statut",
|
||||
"admin.col_user": "Utilisateur",
|
||||
"admin.col_vaults": "Vaults",
|
||||
"admin.forbidden_msg": "Vous devez être administrateur pour accéder à cette page.",
|
||||
"admin.forbidden_title": "Accès refusé",
|
||||
"admin.header_subtitle": "Tableau de bord administrateur",
|
||||
"admin.new_user": "+ Nouvel utilisateur",
|
||||
"admin.page_title": "ObsiGate — Administration",
|
||||
"admin.stats_cpu": "CPU",
|
||||
"admin.stats_disk": "Disque",
|
||||
"admin.stats_memory": "Mémoire",
|
||||
"admin.stats_refresh": "Dernière mise à jour : {time}",
|
||||
"admin.stats_sessions": "Sessions actives",
|
||||
"admin.stats_title": "Statistiques temps réel",
|
||||
"admin.stats_uptime": "Uptime",
|
||||
"admin.title": "Administration — Utilisateurs",
|
||||
"admin.users_manage_btn": "Gérer les utilisateurs",
|
||||
"admin.users_manage_hint": "Ouvrir la modale de gestion des utilisateurs (CRUD complet)",
|
||||
"admin.users_title": "Gestion utilisateurs",
|
||||
"ai.casual": "Ton décontracté",
|
||||
"ai.completion_added": "AI: complétion ajoutée",
|
||||
"ai.continue": "Continuer",
|
||||
|
||||
Reference in New Issue
Block a user