- 3 nouvelles pages HTML servies sous /dashboard/consumption* - Proxy routes /api/v1/consumption/* -> port 8784 (consumption-tracker) - Proxy routes /api/v1/ai/usage* -> port 8783 (AI Router) - consumption_dashboard.html: KPI cards, Chart.js tokens/cost/provider/calls charts, period selector 24h/7j/30j, auto-refresh 30s, alertes visuelles temps reel - consumption_history.html: Tableau pagine, filtres provider/status/date range, tri colonnes, export CSV - consumption_alerts.html: CRUD alertes, toggle actif/inactif, seuils visuels badges, modal creation/edition Co-Authored-By: Paperclip <noreply@paperclip.ing>
328 lines
16 KiB
HTML
328 lines
16 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Consommation IA — Historique</title>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
:root {
|
||
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
|
||
--gold: #ffd600; --orange: #ff6d00;
|
||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||
--radius: 10px; --error: #f85149; --purple: #7c3aed;
|
||
}
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; }
|
||
a { color: inherit; text-decoration: none; }
|
||
|
||
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 28px; border-bottom: 1px solid var(--border); background: var(--dark); position: sticky; top: 0; z-index: 10; flex-wrap: wrap; gap: 10px; }
|
||
.topbar-title { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
|
||
.topbar-title a { color: var(--muted); font-weight: 400; font-size: .9rem; }
|
||
.topbar-title a:hover { color: var(--text); }
|
||
.topbar-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border-radius: 8px; font-size: .85rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
|
||
.btn-primary { background: var(--green); color: #000; }
|
||
.btn-primary:hover { background: var(--green-d); }
|
||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||
.btn-ghost:hover { border-color: var(--muted); }
|
||
.btn-sm { padding: 5px 12px; font-size: .8rem; }
|
||
|
||
.content { padding: 28px; max-width: 1400px; margin: 0 auto; }
|
||
|
||
.filter-bar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
|
||
.filter-group { display: flex; flex-direction: column; gap: 4px; }
|
||
.filter-group label { font-size: .72rem; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; font-weight: 600; }
|
||
.filter-group select, .filter-group input {
|
||
background: var(--dark3); border: 1px solid var(--border); border-radius: 8px;
|
||
padding: 8px 12px; color: var(--text); font-size: .85rem; outline: none;
|
||
transition: border-color .2s; font-family: inherit;
|
||
}
|
||
.filter-group select:focus, .filter-group input:focus { border-color: var(--green); }
|
||
|
||
.table-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 20px; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
thead th { padding: 10px 14px; font-size: .75rem; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); text-align: left; border-bottom: 1px solid var(--border); background: var(--dark3); white-space: nowrap; cursor: pointer; user-select: none; }
|
||
thead th:hover { color: var(--text); }
|
||
tbody tr { border-bottom: 1px solid var(--border); transition: background .15s; }
|
||
tbody tr:last-child { border-bottom: none; }
|
||
tbody tr:hover { background: var(--dark3); }
|
||
tbody td { padding: 10px 14px; font-size: .85rem; white-space: nowrap; }
|
||
.status-badge { padding: 2px 8px; border-radius: 10px; font-size: .72rem; font-weight: 700; }
|
||
.status-success { background: rgba(0,200,83,.15); color: var(--green); }
|
||
.status-error { background: rgba(248,81,73,.15); color: var(--error); }
|
||
|
||
.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; flex-wrap: wrap; }
|
||
.page-btn { padding: 6px 14px; border-radius: 6px; font-size: .85rem; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--muted); transition: all .15s; }
|
||
.page-btn:hover { border-color: var(--muted); color: var(--text); }
|
||
.page-btn.active { background: var(--green); color: #000; border-color: var(--green); }
|
||
.page-btn:disabled { opacity: .4; cursor: default; }
|
||
.page-info { font-size: .82rem; color: var(--muted); }
|
||
|
||
.loading { text-align: center; padding: 60px 20px; color: var(--muted); }
|
||
.loading .spinner { display: inline-block; width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .8s linear infinite; margin-bottom: 12px; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
|
||
|
||
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
|
||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 12px; }
|
||
.empty-state p { font-size: .9rem; }
|
||
|
||
.toast {
|
||
position: fixed; bottom: 20px; right: 20px; z-index: 9999;
|
||
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
|
||
padding: 14px 20px; font-size: .88rem; box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||
display: none; max-width: 400px;
|
||
}
|
||
.toast.show { display: block; animation: slideIn .3s ease; }
|
||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||
.toast-success { border-color: var(--green); }
|
||
.toast-error { border-color: var(--error); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<a href="/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
|
||
|
||
<div class="topbar">
|
||
<div class="topbar-title">
|
||
<span>📋 Historique Consommation</span>
|
||
<a href="/dashboard/consumption">← Dashboard</a>
|
||
</div>
|
||
<div class="topbar-right">
|
||
<button class="btn btn-ghost btn-sm" onclick="exportCSV()">📥 Export CSV</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="loadHistory()">🔄 Rafraîchir</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<div class="filter-bar">
|
||
<div class="filter-group">
|
||
<label>Provider</label>
|
||
<select id="filter-provider" onchange="loadHistory()">
|
||
<option value="">Tous</option>
|
||
<option value="openai">OpenAI</option>
|
||
<option value="anthropic">Anthropic</option>
|
||
<option value="google">Google</option>
|
||
<option value="mistral">Mistral</option>
|
||
<option value="deepseek">DeepSeek</option>
|
||
<option value="meta">Meta</option>
|
||
</select>
|
||
</div>
|
||
<div class="filter-group">
|
||
<label>Statut</label>
|
||
<select id="filter-status" onchange="loadHistory()">
|
||
<option value="">Tous</option>
|
||
<option value="success">Succès</option>
|
||
<option value="error">Erreur</option>
|
||
</select>
|
||
</div>
|
||
<div class="filter-group">
|
||
<label>Du</label>
|
||
<input type="date" id="filter-date-from" onchange="loadHistory()">
|
||
</div>
|
||
<div class="filter-group">
|
||
<label>Au</label>
|
||
<input type="date" id="filter-date-to" onchange="loadHistory()">
|
||
</div>
|
||
<div class="filter-group" style="align-self:flex-end">
|
||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('filter-provider').value='';document.getElementById('filter-status').value='';document.getElementById('filter-date-from').value='';document.getElementById('filter-date-to').value='';loadHistory()">✕ Réinitialiser</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="loading" id="loading">
|
||
<div class="spinner"></div>
|
||
<div>Chargement de l'historique...</div>
|
||
</div>
|
||
|
||
<div class="table-card" id="table-wrap" style="display:none">
|
||
<div style="overflow-x:auto">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th onclick="sortBy('created_at')">Date ⬍</th>
|
||
<th onclick="sortBy('provider')">Provider ⬍</th>
|
||
<th onclick="sortBy('model')">Modèle ⬍</th>
|
||
<th onclick="sortBy('tokens_in')">Tokens In ⬍</th>
|
||
<th onclick="sortBy('tokens_out')">Tokens Out ⬍</th>
|
||
<th onclick="sortBy('tokens_total')">Total ⬍</th>
|
||
<th onclick="sortBy('cost_cents')">Coût ⬍</th>
|
||
<th onclick="sortBy('latency_ms')">Latence ⬍</th>
|
||
<th onclick="sortBy('status')">Statut ⬍</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="history-body"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="pagination" id="pagination"></div>
|
||
</div>
|
||
|
||
<div class="empty-state" id="empty-state" style="display:none">
|
||
<div class="icon">📭</div>
|
||
<p>Aucune donnée de consommation pour cette période.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
var currentPage = 1;
|
||
var totalPages = 0;
|
||
var totalItems = 0;
|
||
var historyData = [];
|
||
var sortField = 'created_at';
|
||
var sortDir = 'desc';
|
||
|
||
function formatDate(isoStr) {
|
||
var d = new Date(isoStr);
|
||
return d.toLocaleDateString('fr-FR', {day:'2-digit', month:'short', year:'numeric'}) + ' ' +
|
||
d.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'});
|
||
}
|
||
|
||
function formatNumber(n) {
|
||
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
|
||
if (n >= 1000) return (n/1000).toFixed(1) + 'k';
|
||
return n.toLocaleString('fr-FR');
|
||
}
|
||
|
||
function showToast(msg, type) {
|
||
var t = document.getElementById('toast');
|
||
t.textContent = msg;
|
||
t.className = 'toast show toast-' + type;
|
||
setTimeout(function() { t.classList.remove('show'); }, 4000);
|
||
}
|
||
|
||
function sortBy(field) {
|
||
if (sortField === field) {
|
||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
sortField = field;
|
||
sortDir = 'desc';
|
||
}
|
||
renderTable();
|
||
}
|
||
|
||
function renderTable() {
|
||
var tbody = document.getElementById('history-body');
|
||
var sorted = [...historyData].sort(function(a, b) {
|
||
var va = a[sortField], vb = b[sortField];
|
||
if (typeof va === 'string') va = va.toLowerCase();
|
||
if (typeof vb === 'string') vb = vb.toLowerCase();
|
||
if (va < vb) return sortDir === 'asc' ? -1 : 1;
|
||
if (va > vb) return sortDir === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
var html = '';
|
||
sorted.forEach(function(r) {
|
||
var cost = ((r.cost_cents || 0) / 100);
|
||
var statusClass = r.status === 'success' ? 'status-success' : 'status-error';
|
||
html += '<tr>' +
|
||
'<td>' + formatDate(r.created_at) + '</td>' +
|
||
'<td>' + (r.provider || '—') + '</td>' +
|
||
'<td>' + (r.model || '—') + '</td>' +
|
||
'<td>' + formatNumber(r.tokens_in || 0) + '</td>' +
|
||
'<td>' + formatNumber(r.tokens_out || 0) + '</td>' +
|
||
'<td><strong>' + formatNumber(r.tokens_total || 0) + '</strong></td>' +
|
||
'<td>' + cost.toFixed(4) + '€</td>' +
|
||
'<td>' + (r.latency_ms ? r.latency_ms + 'ms' : '—') + '</td>' +
|
||
'<td><span class="status-badge ' + statusClass + '">' + (r.status || '—') + '</span></td>' +
|
||
'</tr>';
|
||
});
|
||
if (!html) {
|
||
html = '<tr><td colspan="9" style="text-align:center;padding:40px;color:var(--muted)">Aucun résultat</td></tr>';
|
||
}
|
||
tbody.innerHTML = html;
|
||
}
|
||
|
||
function renderPagination() {
|
||
var el = document.getElementById('pagination');
|
||
if (totalPages <= 1) { el.innerHTML = ''; return; }
|
||
var html = '<span class="page-info">Page ' + currentPage + ' / ' + totalPages + ' (' + totalItems + ' entrées)</span>';
|
||
html += '<button class="page-btn" onclick="goPage(1)" ' + (currentPage <= 1 ? 'disabled' : '') + '>«</button>';
|
||
html += '<button class="page-btn" onclick="goPage(' + (currentPage - 1) + ')" ' + (currentPage <= 1 ? 'disabled' : '') + '>‹</button>';
|
||
var start = Math.max(1, currentPage - 2);
|
||
var end = Math.min(totalPages, currentPage + 2);
|
||
for (var i = start; i <= end; i++) {
|
||
html += '<button class="page-btn' + (i === currentPage ? ' active' : '') + '" onclick="goPage(' + i + ')">' + i + '</button>';
|
||
}
|
||
html += '<button class="page-btn" onclick="goPage(' + (currentPage + 1) + ')" ' + (currentPage >= totalPages ? 'disabled' : '') + '>›</button>';
|
||
html += '<button class="page-btn" onclick="goPage(' + totalPages + ')" ' + (currentPage >= totalPages ? 'disabled' : '') + '>»</button>';
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function goPage(page) {
|
||
if (page < 1 || page > totalPages) return;
|
||
currentPage = page;
|
||
loadHistory();
|
||
}
|
||
|
||
async function loadHistory() {
|
||
document.getElementById('loading').style.display = '';
|
||
document.getElementById('table-wrap').style.display = 'none';
|
||
document.getElementById('empty-state').style.display = 'none';
|
||
|
||
var params = 'client_id=internal&page=' + currentPage + '&per_page=25';
|
||
var provider = document.getElementById('filter-provider').value;
|
||
var status = document.getElementById('filter-status').value;
|
||
var dateFrom = document.getElementById('filter-date-from').value;
|
||
var dateTo = document.getElementById('filter-date-to').value;
|
||
if (provider) params += '&provider=' + encodeURIComponent(provider);
|
||
if (dateFrom) params += '&start_date=' + dateFrom;
|
||
if (dateTo) params += '&end_date=' + dateTo;
|
||
|
||
try {
|
||
var r = await fetch('/api/v1/consumption/history?' + params);
|
||
var data = await r.json();
|
||
var items = data.history || [];
|
||
totalItems = data.total || 0;
|
||
totalPages = data.total_pages || 0;
|
||
|
||
document.getElementById('loading').style.display = 'none';
|
||
|
||
if (items.length === 0) {
|
||
document.getElementById('empty-state').style.display = '';
|
||
return;
|
||
}
|
||
|
||
document.getElementById('table-wrap').style.display = '';
|
||
historyData = items;
|
||
renderTable();
|
||
renderPagination();
|
||
} catch (e) {
|
||
document.getElementById('loading').innerHTML = '<div style="color:var(--error);padding:40px">❌ Erreur: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
|
||
function exportCSV() {
|
||
if (!historyData || historyData.length === 0) {
|
||
showToast('Aucune donnée à exporter', 'error');
|
||
return;
|
||
}
|
||
var headers = ['Date','Provider','Modèle','Tokens In','Tokens Out','Tokens Total','Coût (€)','Latence (ms)','Statut'];
|
||
var rows = historyData.map(function(r) {
|
||
return [
|
||
r.created_at || '',
|
||
r.provider || '',
|
||
r.model || '',
|
||
r.tokens_in || 0,
|
||
r.tokens_out || 0,
|
||
r.tokens_total || 0,
|
||
((r.cost_cents || 0) / 100).toFixed(4),
|
||
r.latency_ms || '',
|
||
r.status || ''
|
||
].join(',');
|
||
});
|
||
var csv = '\uFEFF' + headers.join(',') + '\n' + rows.join('\n');
|
||
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||
var link = document.createElement('a');
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = 'consommation_ia_' + new Date().toISOString().split('T')[0] + '.csv';
|
||
link.click();
|
||
showToast('CSV téléchargé', 'success');
|
||
}
|
||
|
||
loadHistory();
|
||
</script>
|
||
</body>
|
||
</html>
|