Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Nouvelle page /knowledge-base avec : - Mémoire visuelle : 331 vecteurs FAISS / 13666 embeddings (alerte consolidation) - Sessions observées : 56 sessions, 6.66 Go, 3 machines - Réflexes natifs : 16 patterns UI en 6 catégories - Workflows appris : 29 Onglet 📚 Connaissances ajouté dans toute la navigation. Tout en français, dark theme, zéro jargon. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
880 lines
38 KiB
HTML
880 lines
38 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>RPA Vision V3 - Audit & Traçabilité</title>
|
|
<style>
|
|
/* === Reset & base — identique à index.html === */
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; }
|
|
|
|
/* === Header === */
|
|
.header { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); color: white; padding: 20px 30px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
|
|
.header h1 { font-size: 24px; display: flex; align-items: center; gap: 10px; }
|
|
.header-nav { display: flex; align-items: center; gap: 8px; }
|
|
.header-nav a {
|
|
color: rgba(255,255,255,0.8); text-decoration: none; font-size: 13px;
|
|
padding: 6px 14px; border-radius: 6px; transition: all 0.2s;
|
|
background: rgba(255,255,255,0.1);
|
|
}
|
|
.header-nav a:hover { background: rgba(255,255,255,0.2); }
|
|
.header-nav a.active { background: rgba(255,255,255,0.25); color: #fff; font-weight: 600; }
|
|
|
|
/* === Layout === */
|
|
.container { max-width: 1600px; margin: 0 auto; padding: 20px; }
|
|
|
|
/* === Cards === */
|
|
.card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; margin-bottom: 20px; }
|
|
.card h2 { font-size: 16px; margin-bottom: 15px; color: #94a3b8; display: flex; align-items: center; gap: 8px; }
|
|
|
|
/* === Stat cards === */
|
|
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 20px; }
|
|
@media (max-width: 900px) { .grid-4 { grid-template-columns: repeat(2, 1fr); } }
|
|
@media (max-width: 500px) { .grid-4 { grid-template-columns: 1fr; } }
|
|
.stat-card { text-align: center; }
|
|
.stat-value { font-size: 36px; font-weight: bold; color: #3b82f6; }
|
|
.stat-label { font-size: 12px; color: #64748b; margin-top: 5px; text-transform: uppercase; }
|
|
|
|
/* === Boutons === */
|
|
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px; }
|
|
.btn-primary { background: #3b82f6; color: white; }
|
|
.btn-primary:hover { background: #2563eb; }
|
|
.btn-success { background: #22c55e; color: white; }
|
|
.btn-success:hover { background: #16a34a; }
|
|
.btn-warning { background: #f59e0b; color: white; }
|
|
.btn-warning:hover { background: #d97706; }
|
|
.btn-danger { background: #ef4444; color: white; }
|
|
.btn-danger:hover { background: #dc2626; }
|
|
.btn-secondary { background: #475569; color: white; }
|
|
.btn-secondary:hover { background: #64748b; }
|
|
.btn-small { padding: 8px 16px; font-size: 12px; }
|
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
/* === Filtres === */
|
|
.filters-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
|
|
.filter-group { display: flex; flex-direction: column; gap: 4px; }
|
|
.filter-group label { font-size: 11px; color: #64748b; text-transform: uppercase; font-weight: 600; }
|
|
.filter-input {
|
|
padding: 8px 12px; background: #0f172a; border: 1px solid #334155;
|
|
border-radius: 8px; color: #e2e8f0; font-size: 13px; min-width: 140px;
|
|
}
|
|
.filter-input:focus { border-color: #3b82f6; outline: none; }
|
|
select.filter-input { cursor: pointer; }
|
|
|
|
/* === Tableau === */
|
|
.table-wrapper { overflow-x: auto; margin-top: 15px; }
|
|
.audit-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.audit-table thead th {
|
|
background: #334155; color: #94a3b8; padding: 12px 10px;
|
|
text-align: left; font-weight: 600; font-size: 11px;
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
position: sticky; top: 0; z-index: 10; white-space: nowrap;
|
|
}
|
|
.audit-table tbody tr { border-bottom: 1px solid #1e293b; transition: background 0.15s; }
|
|
.audit-table tbody tr:hover { background: #334155; }
|
|
.audit-table td { padding: 10px; vertical-align: middle; }
|
|
.audit-table td.ts { color: #94a3b8; font-size: 12px; white-space: nowrap; }
|
|
.audit-table td.detail { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
/* === Badges === */
|
|
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; white-space: nowrap; }
|
|
.badge-success { background: #064e3b; color: #6ee7b7; }
|
|
.badge-failed { background: #7f1d1d; color: #fca5a5; }
|
|
.badge-recovered { background: #78350f; color: #fcd34d; }
|
|
.badge-skipped { background: #1e293b; color: #94a3b8; }
|
|
.badge-shadow { background: #064e3b; color: #6ee7b7; }
|
|
.badge-copilot, .badge-assisted { background: #1e3a5f; color: #93c5fd; }
|
|
.badge-autonomous { background: #78350f; color: #fcd34d; }
|
|
.badge-action { background: #312e81; color: #a5b4fc; }
|
|
|
|
/* === Panneau de détails === */
|
|
.detail-overlay {
|
|
display: none; position: fixed; top: 0; right: 0; bottom: 0;
|
|
width: 500px; max-width: 90vw; background: #1e293b;
|
|
border-left: 1px solid #334155; z-index: 1000;
|
|
box-shadow: -4px 0 20px rgba(0,0,0,0.5); overflow-y: auto;
|
|
padding: 25px; animation: slideIn 0.2s ease-out;
|
|
}
|
|
.detail-overlay.open { display: block; }
|
|
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
|
.detail-overlay h3 { font-size: 16px; margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center; }
|
|
.detail-overlay .close-btn {
|
|
background: none; border: none; color: #94a3b8; font-size: 24px;
|
|
cursor: pointer; padding: 5px;
|
|
}
|
|
.detail-overlay .close-btn:hover { color: #e2e8f0; }
|
|
.detail-json {
|
|
background: #0f172a; border: 1px solid #334155; border-radius: 8px;
|
|
padding: 15px; font-family: 'Cascadia Code', 'Fira Code', monospace;
|
|
font-size: 12px; white-space: pre-wrap; word-break: break-all;
|
|
color: #a5b4fc; max-height: 70vh; overflow-y: auto; line-height: 1.6;
|
|
}
|
|
|
|
/* === Pagination === */
|
|
.pagination {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
margin-top: 15px; padding: 10px 0; color: #94a3b8; font-size: 13px;
|
|
}
|
|
.pagination .page-btns { display: flex; gap: 8px; }
|
|
|
|
/* === Auto-refresh indicator === */
|
|
.refresh-bar {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
margin-bottom: 15px; color: #64748b; font-size: 12px;
|
|
}
|
|
.refresh-bar .indicator { display: flex; align-items: center; gap: 6px; }
|
|
.refresh-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; }
|
|
.refresh-dot.paused { background: #f59e0b; }
|
|
|
|
/* === Subtitle réglementaire === */
|
|
.regulatory-subtitle { color: #64748b; font-size: 13px; margin-top: 4px; }
|
|
.regulatory-subtitle span { background: #1e293b; padding: 2px 8px; border-radius: 4px; font-size: 11px; border: 1px solid #334155; }
|
|
|
|
/* === Loading === */
|
|
.loading { text-align: center; padding: 40px; color: #64748b; }
|
|
.spinner { border: 3px solid #334155; border-top: 3px solid #3b82f6; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 0 auto 15px; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
/* === Backdrop pour le panneau latéral === */
|
|
.detail-backdrop {
|
|
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(0,0,0,0.4); z-index: 999;
|
|
}
|
|
.detail-backdrop.open { display: block; }
|
|
|
|
/* === Erreur connexion === */
|
|
.error-banner {
|
|
background: #7f1d1d; border: 1px solid #ef4444; border-radius: 8px;
|
|
padding: 12px 20px; color: #fca5a5; font-size: 13px; margin-bottom: 15px;
|
|
display: none; align-items: center; gap: 10px;
|
|
}
|
|
.error-banner.visible { display: flex; }
|
|
|
|
/* === Résumé bottom === */
|
|
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; }
|
|
@media (max-width: 900px) { .grid-3 { grid-template-columns: 1fr; } }
|
|
.top-failures-item {
|
|
display: flex; justify-content: space-between; padding: 8px 0;
|
|
border-bottom: 1px solid #334155; font-size: 13px;
|
|
}
|
|
.top-failures-item:last-child { border-bottom: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Header — identique à index.html -->
|
|
<div class="header">
|
|
<div>
|
|
<h1>⚖️ Audit & Traçabilité</h1>
|
|
<div class="regulatory-subtitle">
|
|
Conformité <span>AI Act art. 12</span> <span>RGPD art. 30</span>
|
|
</div>
|
|
</div>
|
|
<nav class="header-nav">
|
|
<a href="/">🎛️ Dashboard</a>
|
|
<a href="/audit" class="active">⚖️ Audit</a>
|
|
<a href="/process-mining">🗺️ Cartographie</a>
|
|
<a href="/knowledge-base">📚 Connaissances</a>
|
|
</nav>
|
|
</div>
|
|
|
|
<div class="container">
|
|
|
|
<!-- Bannière d'erreur connexion -->
|
|
<div class="error-banner" id="errorBanner">
|
|
⚠️ <span id="errorText">Serveur streaming (5005) inaccessible.</span>
|
|
</div>
|
|
|
|
<!-- KPIs -->
|
|
<div class="grid-4" id="kpiGrid">
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="kpiTotal">-</div>
|
|
<div class="stat-label">Actions aujourd'hui</div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="kpiSuccess">-</div>
|
|
<div class="stat-label">Taux de réussite</div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="kpiAutonomous">-</div>
|
|
<div class="stat-label">Mode autonome</div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="kpiAvgDuration">-</div>
|
|
<div class="stat-label">Durée moyenne</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filtres + Actions -->
|
|
<div class="card">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:15px;">
|
|
<h2 style="margin-bottom:0;">🔍 Filtres</h2>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
|
<button class="btn btn-success btn-small" onclick="exportCSV()">📥 Exporter CSV</button>
|
|
<button class="btn btn-primary btn-small" onclick="exportJSON()">📥 Exporter JSON</button>
|
|
<button class="btn btn-secondary btn-small" onclick="refreshAll()">🔄 Actualiser</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filters-bar">
|
|
<div class="filter-group">
|
|
<label>Date début</label>
|
|
<input type="date" id="filterDateFrom" class="filter-input">
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>Date fin</label>
|
|
<input type="date" id="filterDateTo" class="filter-input">
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>Collaborateur</label>
|
|
<select id="filterUser" class="filter-input">
|
|
<option value="">Tous</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>Application cible</label>
|
|
<select id="filterApp" class="filter-input">
|
|
<option value="">Toutes</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>Type d'action</label>
|
|
<select id="filterAction" class="filter-input">
|
|
<option value="">Tous</option>
|
|
<option value="click">click</option>
|
|
<option value="type">type</option>
|
|
<option value="key_combo">key_combo</option>
|
|
<option value="wait">wait</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>Mode d'exécution</label>
|
|
<select id="filterMode" class="filter-input">
|
|
<option value="">Tous</option>
|
|
<option value="autonomous">Autonome</option>
|
|
<option value="assisted">Assisté</option>
|
|
<option value="shadow">Shadow</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>Résultat</label>
|
|
<select id="filterResult" class="filter-input">
|
|
<option value="">Tous</option>
|
|
<option value="success">Succès</option>
|
|
<option value="failed">Échec</option>
|
|
<option value="recovered">Récupéré</option>
|
|
<option value="skipped">Ignoré</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>Recherche</label>
|
|
<input type="text" id="filterSearch" class="filter-input" placeholder="action_detail..." style="min-width:180px;">
|
|
</div>
|
|
<div class="filter-group" style="justify-content:flex-end;">
|
|
<label> </label>
|
|
<div style="display:flex;gap:6px;">
|
|
<button class="btn btn-primary btn-small" onclick="applyFilters()">Filtrer</button>
|
|
<button class="btn btn-secondary btn-small" onclick="resetFilters()">Réinitialiser</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Auto-refresh bar -->
|
|
<div class="refresh-bar">
|
|
<div class="indicator">
|
|
<div class="refresh-dot" id="refreshDot"></div>
|
|
<span id="refreshStatus">Auto-refresh actif (60s)</span>
|
|
<span>—</span>
|
|
<span>Dernière MAJ : <strong id="lastRefresh">-</strong></span>
|
|
</div>
|
|
<button class="btn btn-small btn-secondary" id="toggleRefreshBtn" onclick="toggleAutoRefresh()">
|
|
⏸️ Pause
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tableau -->
|
|
<div class="card" style="padding:0;overflow:hidden;">
|
|
<div class="table-wrapper" style="max-height:600px;overflow-y:auto;">
|
|
<table class="audit-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Horodatage</th>
|
|
<th>Collaborateur</th>
|
|
<th>Poste</th>
|
|
<th>Application</th>
|
|
<th>Action</th>
|
|
<th>Détail</th>
|
|
<th>Mode</th>
|
|
<th>Résultat</th>
|
|
<th>Récupération</th>
|
|
<th>Durée</th>
|
|
<th>Anonymisation</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="auditTableBody">
|
|
<tr><td colspan="12" class="loading"><div class="spinner"></div>Chargement...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div class="pagination">
|
|
<span id="paginationInfo">-</span>
|
|
<div class="page-btns">
|
|
<button class="btn btn-small btn-secondary" id="btnPrev" onclick="prevPage()" disabled>◀ Précédent</button>
|
|
<button class="btn btn-small btn-secondary" id="btnNext" onclick="nextPage()">Suivant ▶</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Résumé statistique (bottom) -->
|
|
<div class="grid-3" style="margin-top:20px;">
|
|
<!-- Répartition par application -->
|
|
<div class="card">
|
|
<h2>📊 Répartition par application</h2>
|
|
<div id="appDistribution">
|
|
<div class="loading">Chargement...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Répartition par résultat -->
|
|
<div class="card">
|
|
<h2>📊 Répartition par résultat</h2>
|
|
<div id="resultDistribution">
|
|
<div class="loading">Chargement...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Top échecs récents -->
|
|
<div class="card">
|
|
<h2>⚠️ Derniers échecs</h2>
|
|
<div id="recentFailures">
|
|
<div class="loading">Chargement...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Panneau de détails (panneau latéral) -->
|
|
<div class="detail-backdrop" id="detailBackdrop" onclick="closeDetail()"></div>
|
|
<div class="detail-overlay" id="detailPanel">
|
|
<h3>
|
|
Détail de l'entrée
|
|
<button class="close-btn" onclick="closeDetail()">×</button>
|
|
</h3>
|
|
<pre class="detail-json" id="detailJson"></pre>
|
|
</div>
|
|
|
|
<script>
|
|
// =====================================================================
|
|
// État global
|
|
// =====================================================================
|
|
let currentOffset = 0;
|
|
const PAGE_SIZE = 100;
|
|
let autoRefreshEnabled = true;
|
|
let autoRefreshTimer = null;
|
|
let lastEntries = []; // entrées de la dernière requête
|
|
let summaryData = null; // résumé du jour courant
|
|
|
|
// =====================================================================
|
|
// Initialisation
|
|
// =====================================================================
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Date par défaut = aujourd'hui
|
|
const today = new Date().toISOString().split('T')[0];
|
|
document.getElementById('filterDateFrom').value = today;
|
|
document.getElementById('filterDateTo').value = today;
|
|
|
|
refreshAll();
|
|
startAutoRefresh();
|
|
});
|
|
|
|
// =====================================================================
|
|
// Requêtes API (via le proxy Flask)
|
|
// =====================================================================
|
|
async function fetchJSON(url) {
|
|
try {
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({ error: resp.statusText }));
|
|
throw new Error(err.error || err.detail || resp.statusText);
|
|
}
|
|
hideError();
|
|
return await resp.json();
|
|
} catch (e) {
|
|
showError(e.message);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function showError(msg) {
|
|
const banner = document.getElementById('errorBanner');
|
|
document.getElementById('errorText').textContent = msg;
|
|
banner.classList.add('visible');
|
|
}
|
|
|
|
function hideError() {
|
|
document.getElementById('errorBanner').classList.remove('visible');
|
|
}
|
|
|
|
// =====================================================================
|
|
// Construction des query params depuis les filtres
|
|
// =====================================================================
|
|
function buildQueryParams(extraParams = {}) {
|
|
const params = new URLSearchParams();
|
|
const dateFrom = document.getElementById('filterDateFrom').value;
|
|
const dateTo = document.getElementById('filterDateTo').value;
|
|
const userId = document.getElementById('filterUser').value;
|
|
const app = document.getElementById('filterApp').value;
|
|
const action = document.getElementById('filterAction').value;
|
|
const mode = document.getElementById('filterMode').value;
|
|
const result = document.getElementById('filterResult').value;
|
|
|
|
if (dateFrom) params.set('date_from', dateFrom);
|
|
if (dateTo) params.set('date_to', dateTo);
|
|
if (userId) params.set('user_id', userId);
|
|
// target_app n'est pas un filtre backend direct —
|
|
// on filtre côté client (voir filterLocally)
|
|
if (action) params.set('action_type', action);
|
|
if (result) params.set('result', result);
|
|
// execution_mode n'est pas un filtre backend direct —
|
|
// on filtre côté client
|
|
// workflow_id non mappé ici (filtrage client si besoin)
|
|
|
|
for (const [k, v] of Object.entries(extraParams)) {
|
|
params.set(k, v);
|
|
}
|
|
return params.toString();
|
|
}
|
|
|
|
// =====================================================================
|
|
// Filtrage côté client pour les champs non supportés par le backend
|
|
// =====================================================================
|
|
function filterLocally(entries) {
|
|
const app = document.getElementById('filterApp').value;
|
|
const mode = document.getElementById('filterMode').value;
|
|
const search = document.getElementById('filterSearch').value.toLowerCase().trim();
|
|
|
|
return entries.filter(e => {
|
|
if (app && e.target_app !== app) return false;
|
|
if (mode && e.execution_mode !== mode) return false;
|
|
if (search && !(e.action_detail || '').toLowerCase().includes(search)) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// =====================================================================
|
|
// Chargement de l'historique
|
|
// =====================================================================
|
|
async function loadHistory() {
|
|
const params = buildQueryParams({ limit: 1000, offset: 0 });
|
|
try {
|
|
const data = await fetchJSON(`/api/audit/history?${params}`);
|
|
const allEntries = data.entries || [];
|
|
|
|
// Filtrage local (target_app, execution_mode, recherche libre)
|
|
lastEntries = filterLocally(allEntries);
|
|
|
|
// Peupler les dropdowns dynamiques à partir des données
|
|
populateDynamicDropdowns(allEntries);
|
|
|
|
renderTable();
|
|
} catch (e) {
|
|
document.getElementById('auditTableBody').innerHTML =
|
|
`<tr><td colspan="12" style="text-align:center;padding:30px;color:#ef4444;">Erreur : ${e.message}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Chargement du résumé (KPIs)
|
|
// =====================================================================
|
|
async function loadSummary() {
|
|
const dateFrom = document.getElementById('filterDateFrom').value;
|
|
try {
|
|
const data = await fetchJSON(`/api/audit/summary?date=${dateFrom || ''}`);
|
|
summaryData = data;
|
|
renderKPIs(data);
|
|
renderDistributions(data);
|
|
} catch (e) {
|
|
// KPIs en erreur
|
|
['kpiTotal', 'kpiSuccess', 'kpiAutonomous', 'kpiAvgDuration'].forEach(id => {
|
|
document.getElementById(id).textContent = '-';
|
|
});
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Rendu KPIs
|
|
// =====================================================================
|
|
function renderKPIs(data) {
|
|
document.getElementById('kpiTotal').textContent = data.total_actions || 0;
|
|
|
|
const rate = data.success_rate || 0;
|
|
const el = document.getElementById('kpiSuccess');
|
|
el.textContent = `${Math.round(rate * 100)}%`;
|
|
el.style.color = rate >= 0.9 ? '#22c55e' : rate >= 0.7 ? '#f59e0b' : '#ef4444';
|
|
|
|
// Mode autonome
|
|
const byMode = data.by_execution_mode || {};
|
|
const autoCount = byMode['autonomous'] || 0;
|
|
const total = data.total_actions || 1;
|
|
document.getElementById('kpiAutonomous').textContent = `${Math.round(autoCount / total * 100)}%`;
|
|
|
|
// Durée moyenne — pas disponible dans le summary backend,
|
|
// on la calcule depuis lastEntries si disponible
|
|
if (lastEntries.length > 0) {
|
|
const durations = lastEntries.filter(e => e.duration_ms > 0).map(e => e.duration_ms);
|
|
if (durations.length > 0) {
|
|
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
|
document.getElementById('kpiAvgDuration').textContent = formatDuration(avg);
|
|
} else {
|
|
document.getElementById('kpiAvgDuration').textContent = '-';
|
|
}
|
|
} else {
|
|
document.getElementById('kpiAvgDuration').textContent = '-';
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Rendu tableau
|
|
// =====================================================================
|
|
function renderTable() {
|
|
const tbody = document.getElementById('auditTableBody');
|
|
|
|
if (lastEntries.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="12" style="text-align:center;padding:30px;color:#64748b;">Aucune entrée pour les filtres sélectionnés.</td></tr>';
|
|
document.getElementById('paginationInfo').textContent = '0 résultat';
|
|
document.getElementById('btnPrev').disabled = true;
|
|
document.getElementById('btnNext').disabled = true;
|
|
return;
|
|
}
|
|
|
|
// Pagination côté client (les données sont déjà chargées)
|
|
const page = lastEntries.slice(currentOffset, currentOffset + PAGE_SIZE);
|
|
|
|
const rows = page.map((e, i) => {
|
|
const ts = formatTimestamp(e.timestamp);
|
|
const user = e.user_name || e.user_id || '<span style="color:#64748b">-</span>';
|
|
const machine = e.machine_id || '-';
|
|
const app = e.target_app || '<span style="color:#64748b">-</span>';
|
|
const actionBadge = `<span class="badge badge-action">${escapeHtml(e.action_type || '-')}</span>`;
|
|
const detail = escapeHtml((e.action_detail || '').substring(0, 80)) || '<span style="color:#64748b">-</span>';
|
|
const modeBadge = renderModeBadge(e.execution_mode);
|
|
const resultBadge = renderResultBadge(e.result);
|
|
const recovery = e.recovery_action && e.result !== 'success'
|
|
? `<span style="color:#fcd34d;font-size:12px;">${escapeHtml(e.recovery_action)}</span>`
|
|
: '<span style="color:#64748b">-</span>';
|
|
const duration = e.duration_ms > 0 ? formatDuration(e.duration_ms) : '-';
|
|
|
|
// Anonymisation : champ non présent dans AuditEntry — on affiche "—" avec tooltip
|
|
const anonymisation = '<span style="color:#64748b;cursor:help;" title="Information non remontée par le pipeline actuel">—</span>';
|
|
|
|
const idx = currentOffset + i;
|
|
|
|
return `<tr style="cursor:pointer;" onclick="showDetail(${idx})">
|
|
<td class="ts">${ts}</td>
|
|
<td>${user}</td>
|
|
<td style="font-size:12px;color:#94a3b8;">${escapeHtml(machine)}</td>
|
|
<td>${escapeHtml(app)}</td>
|
|
<td>${actionBadge}</td>
|
|
<td class="detail" title="${escapeHtml(e.action_detail || '')}">${detail}</td>
|
|
<td>${modeBadge}</td>
|
|
<td>${resultBadge}</td>
|
|
<td>${recovery}</td>
|
|
<td style="white-space:nowrap;">${duration}</td>
|
|
<td style="text-align:center;">${anonymisation}</td>
|
|
<td><button class="btn btn-small btn-secondary" onclick="event.stopPropagation();showDetail(${idx})">🔍</button></td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
tbody.innerHTML = rows;
|
|
|
|
// Pagination info
|
|
const start = currentOffset + 1;
|
|
const end = Math.min(currentOffset + PAGE_SIZE, lastEntries.length);
|
|
document.getElementById('paginationInfo').textContent =
|
|
`${start}-${end} sur ${lastEntries.length} résultat${lastEntries.length > 1 ? 's' : ''}`;
|
|
|
|
document.getElementById('btnPrev').disabled = currentOffset === 0;
|
|
document.getElementById('btnNext').disabled = currentOffset + PAGE_SIZE >= lastEntries.length;
|
|
}
|
|
|
|
// =====================================================================
|
|
// Rendu distributions (résumé bottom)
|
|
// =====================================================================
|
|
function renderDistributions(data) {
|
|
// Par application (depuis les données chargées, plus riche que le summary)
|
|
const appDiv = document.getElementById('appDistribution');
|
|
if (lastEntries.length > 0) {
|
|
const appCounts = {};
|
|
lastEntries.forEach(e => {
|
|
const a = e.target_app || 'Non renseigné';
|
|
appCounts[a] = (appCounts[a] || 0) + 1;
|
|
});
|
|
const sorted = Object.entries(appCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
appDiv.innerHTML = sorted.map(([name, count]) => {
|
|
const pct = Math.round(count / lastEntries.length * 100);
|
|
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #334155;">
|
|
<span style="font-size:13px;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
|
<div style="display:flex;align-items:center;gap:8px;">
|
|
<div style="width:80px;height:6px;background:#334155;border-radius:3px;overflow:hidden;">
|
|
<div style="width:${pct}%;height:100%;background:#3b82f6;border-radius:3px;"></div>
|
|
</div>
|
|
<span style="font-size:12px;color:#94a3b8;min-width:45px;text-align:right;">${count} (${pct}%)</span>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
} else {
|
|
appDiv.innerHTML = '<span style="color:#64748b;font-size:13px;">Aucune donnée</span>';
|
|
}
|
|
|
|
// Par résultat
|
|
const resultDiv = document.getElementById('resultDistribution');
|
|
const byResult = data.by_result || {};
|
|
const resultEntries = Object.entries(byResult);
|
|
if (resultEntries.length > 0) {
|
|
const total = resultEntries.reduce((s, [, c]) => s + c, 0);
|
|
resultDiv.innerHTML = resultEntries.map(([name, count]) => {
|
|
const pct = Math.round(count / total * 100);
|
|
const color = name === 'success' ? '#22c55e' : name === 'failed' ? '#ef4444' : name === 'recovered' ? '#f59e0b' : '#94a3b8';
|
|
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #334155;">
|
|
<span style="font-size:13px;">${renderResultBadge(name)}</span>
|
|
<div style="display:flex;align-items:center;gap:8px;">
|
|
<div style="width:80px;height:6px;background:#334155;border-radius:3px;overflow:hidden;">
|
|
<div style="width:${pct}%;height:100%;background:${color};border-radius:3px;"></div>
|
|
</div>
|
|
<span style="font-size:12px;color:#94a3b8;min-width:45px;text-align:right;">${count} (${pct}%)</span>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
} else {
|
|
resultDiv.innerHTML = '<span style="color:#64748b;font-size:13px;">Aucune donnée</span>';
|
|
}
|
|
|
|
// Derniers échecs
|
|
const failDiv = document.getElementById('recentFailures');
|
|
const failures = lastEntries.filter(e => e.result === 'failed' || e.result === 'recovered').slice(0, 10);
|
|
if (failures.length > 0) {
|
|
failDiv.innerHTML = failures.map(e => {
|
|
const ts = formatTimestamp(e.timestamp);
|
|
const detail = escapeHtml((e.action_detail || e.action_type || '-').substring(0, 40));
|
|
return `<div class="top-failures-item">
|
|
<span style="color:#fca5a5;">${detail}</span>
|
|
<span style="color:#64748b;font-size:11px;">${ts}</span>
|
|
</div>`;
|
|
}).join('');
|
|
} else {
|
|
failDiv.innerHTML = '<span style="color:#22c55e;font-size:13px;">Aucun échec !</span>';
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Dropdowns dynamiques
|
|
// =====================================================================
|
|
function populateDynamicDropdowns(entries) {
|
|
// Applications
|
|
const apps = new Set();
|
|
const users = new Set();
|
|
entries.forEach(e => {
|
|
if (e.target_app) apps.add(e.target_app);
|
|
if (e.user_id) users.add(JSON.stringify({ id: e.user_id, name: e.user_name || e.user_id }));
|
|
});
|
|
|
|
const appSelect = document.getElementById('filterApp');
|
|
const currentApp = appSelect.value;
|
|
// Garder la première option "Toutes"
|
|
appSelect.innerHTML = '<option value="">Toutes</option>';
|
|
[...apps].sort().forEach(a => {
|
|
appSelect.innerHTML += `<option value="${escapeHtml(a)}" ${a === currentApp ? 'selected' : ''}>${escapeHtml(a)}</option>`;
|
|
});
|
|
|
|
const userSelect = document.getElementById('filterUser');
|
|
const currentUser = userSelect.value;
|
|
userSelect.innerHTML = '<option value="">Tous</option>';
|
|
[...users].map(u => JSON.parse(u)).sort((a, b) => a.name.localeCompare(b.name)).forEach(u => {
|
|
userSelect.innerHTML += `<option value="${escapeHtml(u.id)}" ${u.id === currentUser ? 'selected' : ''}>${escapeHtml(u.name)}</option>`;
|
|
});
|
|
}
|
|
|
|
// =====================================================================
|
|
// Formatters
|
|
// =====================================================================
|
|
function formatTimestamp(ts) {
|
|
if (!ts) return '-';
|
|
try {
|
|
const d = new Date(ts);
|
|
const hh = String(d.getHours()).padStart(2, '0');
|
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
const dd = String(d.getDate()).padStart(2, '0');
|
|
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
|
return `${hh}:${mm}:${ss} · ${dd}/${mo}`;
|
|
} catch {
|
|
return ts;
|
|
}
|
|
}
|
|
|
|
function formatDuration(ms) {
|
|
if (ms >= 1000) return `${(ms / 1000).toFixed(1)} s`;
|
|
return `${Math.round(ms)} ms`;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function renderModeBadge(mode) {
|
|
if (!mode) return '<span style="color:#64748b;">-</span>';
|
|
const map = {
|
|
'shadow': ['badge-shadow', 'Shadow'],
|
|
'assisted': ['badge-assisted', 'Assisté'],
|
|
'copilot': ['badge-copilot', 'Copilot'],
|
|
'autonomous': ['badge-autonomous', 'Autonome'],
|
|
};
|
|
const [cls, label] = map[mode] || ['badge-skipped', mode];
|
|
return `<span class="badge ${cls}">${label}</span>`;
|
|
}
|
|
|
|
function renderResultBadge(result) {
|
|
if (!result) return '<span style="color:#64748b;">-</span>';
|
|
const map = {
|
|
'success': ['badge-success', 'Succès'],
|
|
'failed': ['badge-failed', 'Échec'],
|
|
'recovered': ['badge-recovered', 'Récupéré'],
|
|
'skipped': ['badge-skipped', 'Ignoré'],
|
|
};
|
|
const [cls, label] = map[result] || ['badge-skipped', result];
|
|
return `<span class="badge ${cls}">${label}</span>`;
|
|
}
|
|
|
|
// =====================================================================
|
|
// Panneau de détails
|
|
// =====================================================================
|
|
function showDetail(idx) {
|
|
const entry = lastEntries[idx];
|
|
if (!entry) return;
|
|
document.getElementById('detailJson').textContent = JSON.stringify(entry, null, 2);
|
|
document.getElementById('detailPanel').classList.add('open');
|
|
document.getElementById('detailBackdrop').classList.add('open');
|
|
}
|
|
|
|
function closeDetail() {
|
|
document.getElementById('detailPanel').classList.remove('open');
|
|
document.getElementById('detailBackdrop').classList.remove('open');
|
|
}
|
|
|
|
// Fermer avec Escape
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDetail(); });
|
|
|
|
// =====================================================================
|
|
// Pagination
|
|
// =====================================================================
|
|
function prevPage() {
|
|
currentOffset = Math.max(0, currentOffset - PAGE_SIZE);
|
|
renderTable();
|
|
window.scrollTo({ top: 300, behavior: 'smooth' });
|
|
}
|
|
|
|
function nextPage() {
|
|
if (currentOffset + PAGE_SIZE < lastEntries.length) {
|
|
currentOffset += PAGE_SIZE;
|
|
renderTable();
|
|
window.scrollTo({ top: 300, behavior: 'smooth' });
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Filtres
|
|
// =====================================================================
|
|
function applyFilters() {
|
|
currentOffset = 0;
|
|
refreshAll();
|
|
}
|
|
|
|
function resetFilters() {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
document.getElementById('filterDateFrom').value = today;
|
|
document.getElementById('filterDateTo').value = today;
|
|
document.getElementById('filterUser').value = '';
|
|
document.getElementById('filterApp').value = '';
|
|
document.getElementById('filterAction').value = '';
|
|
document.getElementById('filterMode').value = '';
|
|
document.getElementById('filterResult').value = '';
|
|
document.getElementById('filterSearch').value = '';
|
|
currentOffset = 0;
|
|
refreshAll();
|
|
}
|
|
|
|
// =====================================================================
|
|
// Export
|
|
// =====================================================================
|
|
function exportCSV() {
|
|
const dateFrom = document.getElementById('filterDateFrom').value;
|
|
const dateTo = document.getElementById('filterDateTo').value;
|
|
const userId = document.getElementById('filterUser').value;
|
|
let url = `/api/audit/export?date_from=${dateFrom || ''}&date_to=${dateTo || ''}`;
|
|
if (userId) url += `&user_id=${encodeURIComponent(userId)}`;
|
|
window.open(url, '_blank');
|
|
}
|
|
|
|
function exportJSON() {
|
|
// Exporter les données filtrées côté client en JSON
|
|
const blob = new Blob([JSON.stringify(lastEntries, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
const dateFrom = document.getElementById('filterDateFrom').value || 'today';
|
|
a.download = `audit_${dateFrom}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// =====================================================================
|
|
// Auto-refresh
|
|
// =====================================================================
|
|
function startAutoRefresh() {
|
|
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
|
autoRefreshTimer = setInterval(() => {
|
|
if (autoRefreshEnabled) refreshAll();
|
|
}, 60000);
|
|
}
|
|
|
|
function toggleAutoRefresh() {
|
|
autoRefreshEnabled = !autoRefreshEnabled;
|
|
const btn = document.getElementById('toggleRefreshBtn');
|
|
const dot = document.getElementById('refreshDot');
|
|
const status = document.getElementById('refreshStatus');
|
|
if (autoRefreshEnabled) {
|
|
btn.innerHTML = '⏸️ Pause';
|
|
dot.classList.remove('paused');
|
|
status.textContent = 'Auto-refresh actif (60s)';
|
|
} else {
|
|
btn.innerHTML = '▶️ Reprendre';
|
|
dot.classList.add('paused');
|
|
status.textContent = 'Auto-refresh en pause';
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Refresh principal
|
|
// =====================================================================
|
|
async function refreshAll() {
|
|
const ts = new Date();
|
|
document.getElementById('lastRefresh').textContent =
|
|
`${String(ts.getHours()).padStart(2,'0')}:${String(ts.getMinutes()).padStart(2,'0')}:${String(ts.getSeconds()).padStart(2,'0')}`;
|
|
|
|
// Charger en parallèle
|
|
await Promise.all([loadHistory(), loadSummary()]);
|
|
}
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|