feat: journée 17 avril — tests E2E validés, dashboard fleet+audit, VWB bridge, cleaner C2
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
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

Pipeline E2E complet validé :
  Capture VM → streaming → serveur → cleaner → replay → audit trail
  Mode apprentissage supervisé fonctionne (Léa échoue → humain → reprise)

Dashboard :
  - Cleanup 14→10 onglets (RCE supprimée)
  - Fleet : enregistrer/révoquer agents, tokens, ZIP pré-configuré téléchargeable
  - Audit trail MVP (/audit) : filtres, tableau, export CSV, conformité AI Act/RGPD
  - Formulaire Fleet simplifié (nom + email, machine_id auto)

VWB bridge Léa→VWB :
  - Compound décomposés en N steps (saisie + raccourci visibles)
  - Layout serpentin 3 colonnes (plus colonne verticale)
  - Badge OS 🪟/🐧, filtre OS retiré (admin Linux voit Windows)
  - Fix import SQLite readonly

Cleaner intelligent :
  - Descriptions lisibles (UIA/C2) + détection doublons
  - Logique C2 : UIElement identifié = jamais parasite
  - Patterns parasites resserrés
  - Message Léa : "Je n'y arrive pas, montrez-moi comment faire"

Config agent (INC-1 à INC-7) :
  - SERVER_URL + SERVER_BASE unifiés
  - RPA_OLLAMA_HOST séparé
  - allow_redirects=False sur POST
  - Middleware réécriture URL serveur

CI Gitea : fix token + Flask-SocketIO + ruff propre
Fleet endpoints : /agents/enroll|uninstall|fleet + agent_registry SQLite
Backup : script quotidien workflows.db + audit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-17 17:46:40 +02:00
parent 2fa864b5c7
commit 4f61741420
27 changed files with 5088 additions and 1543 deletions

View File

@@ -0,0 +1,877 @@
<!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>&#x2696;&#xFE0F; 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="/">&#x1F39B;&#xFE0F; Dashboard</a>
<a href="/audit" class="active">&#x2696;&#xFE0F; Audit</a>
</nav>
</div>
<div class="container">
<!-- Bannière d'erreur connexion -->
<div class="error-banner" id="errorBanner">
&#x26A0;&#xFE0F; <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;">&#x1F50D; Filtres</h2>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-success btn-small" onclick="exportCSV()">&#x1F4E5; Exporter CSV</button>
<button class="btn btn-primary btn-small" onclick="exportJSON()">&#x1F4E5; Exporter JSON</button>
<button class="btn btn-secondary btn-small" onclick="refreshAll()">&#x1F504; 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>&nbsp;</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>&mdash;</span>
<span>Dernière MAJ : <strong id="lastRefresh">-</strong></span>
</div>
<button class="btn btn-small btn-secondary" id="toggleRefreshBtn" onclick="toggleAutoRefresh()">
&#x23F8;&#xFE0F; 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>&#x25C0; Précédent</button>
<button class="btn btn-small btn-secondary" id="btnNext" onclick="nextPage()">Suivant &#x25B6;</button>
</div>
</div>
<!-- Résumé statistique (bottom) -->
<div class="grid-3" style="margin-top:20px;">
<!-- Répartition par application -->
<div class="card">
<h2>&#x1F4CA; Répartition par application</h2>
<div id="appDistribution">
<div class="loading">Chargement...</div>
</div>
</div>
<!-- Répartition par résultat -->
<div class="card">
<h2>&#x1F4CA; Répartition par résultat</h2>
<div id="resultDistribution">
<div class="loading">Chargement...</div>
</div>
</div>
<!-- Top échecs récents -->
<div class="card">
<h2>&#x26A0;&#xFE0F; 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()">&times;</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">&mdash;</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})">&#x1F50D;</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 = '&#x23F8;&#xFE0F; Pause';
dot.classList.remove('paused');
status.textContent = 'Auto-refresh actif (60s)';
} else {
btn.innerHTML = '&#x25B6;&#xFE0F; 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>

File diff suppressed because it is too large Load Diff