- 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>
416 lines
18 KiB
HTML
416 lines
18 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 — Dashboard</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<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; --cyan: #00d9ff;
|
|
}
|
|
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; }
|
|
.btn-active { background: var(--green); color: #000; border-color: var(--green); }
|
|
|
|
.content { padding: 28px; max-width: 1400px; margin: 0 auto; }
|
|
|
|
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
|
.stat-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
|
|
.stat-label { font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
|
.stat-value { font-size: 1.8rem; font-weight: 800; }
|
|
.stat-sub { font-size: .78rem; color: var(--muted); margin-top: 4px; }
|
|
.stat-warn { color: var(--gold); }
|
|
.stat-err { color: var(--error); }
|
|
|
|
.period-bar { display: flex; gap: 6px; margin-bottom: 20px; flex-wrap: wrap; }
|
|
.period-btn { padding: 6px 16px; border-radius: 20px; font-size: .82rem; font-weight: 600; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--muted); transition: all .15s; }
|
|
.period-btn:hover { border-color: var(--muted); color: var(--text); }
|
|
.period-btn.active { background: var(--green); color: #000; border-color: var(--green); }
|
|
|
|
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
|
|
@media (max-width: 900px) { .charts-grid { grid-template-columns: 1fr; } }
|
|
|
|
.chart-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; }
|
|
.chart-title { font-size: .9rem; font-weight: 700; margin-bottom: 14px; color: var(--muted); }
|
|
.chart-container { height: 280px; position: relative; }
|
|
|
|
.alert-banner {
|
|
background: linear-gradient(135deg, rgba(248,81,73,.1), rgba(255,109,0,.08));
|
|
border: 1px solid rgba(248,81,73,.25); border-radius: var(--radius);
|
|
padding: 12px 18px; margin-bottom: 20px;
|
|
display: none; align-items: center; gap: 12px;
|
|
}
|
|
.alert-banner.visible { display: flex; }
|
|
.alert-banner .alert-icon { font-size: 1.3rem; }
|
|
.alert-banner .alert-text { flex: 1; font-size: .9rem; }
|
|
.alert-banner .alert-text strong { color: var(--error); }
|
|
.alert-banner .alert-close { cursor: pointer; font-size: 1.2rem; 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); } }
|
|
|
|
.provider-breakdown { margin-top: 12px; }
|
|
.provider-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
|
.provider-row:last-child { border-bottom: none; }
|
|
.provider-name { flex: 1; font-weight: 600; font-size: .88rem; }
|
|
.provider-bar-wrap { flex: 2; height: 8px; border-radius: 4px; background: var(--border); overflow: hidden; }
|
|
.provider-bar-fill { height: 100%; border-radius: 4px; transition: width .5s; }
|
|
.provider-stats { flex: 1; text-align: right; font-size: .82rem; color: var(--muted); }
|
|
|
|
.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)}
|
|
</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>📊 Consommation IA</span>
|
|
<a href="/dashboard/consumption/history">Historique →</a>
|
|
</div>
|
|
<div class="topbar-right">
|
|
<a href="/dashboard/consumption/alerts" class="btn btn-ghost btn-sm">⚙️ Alertes</a>
|
|
<span id="last-refresh" style="font-size:.78rem;color:var(--muted)">—</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<div class="alert-banner" id="alert-banner">
|
|
<span class="alert-icon">⚠️</span>
|
|
<div class="alert-text" id="alert-text"></div>
|
|
<span class="alert-close" onclick="this.parentElement.classList.remove('visible')">✕</span>
|
|
</div>
|
|
|
|
<div class="period-bar" id="period-bar">
|
|
<button class="period-btn" data-period="24h" onclick="setPeriod('24h')">24h</button>
|
|
<button class="period-btn active" data-period="7d" onclick="setPeriod('7d')">7 jours</button>
|
|
<button class="period-btn" data-period="30d" onclick="setPeriod('30d')">30 jours</button>
|
|
</div>
|
|
|
|
<div class="loading" id="loading">
|
|
<div class="spinner"></div>
|
|
<div>Chargement des données...</div>
|
|
</div>
|
|
|
|
<div id="dashboard-content" style="display:none">
|
|
<div class="stats-row" id="stats-row"></div>
|
|
|
|
<div class="charts-grid">
|
|
<div class="chart-card">
|
|
<div class="chart-title">🔷 Tokens par jour</div>
|
|
<div class="chart-container"><canvas id="tokensChart"></canvas></div>
|
|
</div>
|
|
<div class="chart-card">
|
|
<div class="chart-title">💰 Coût par jour (€)</div>
|
|
<div class="chart-container"><canvas id="costChart"></canvas></div>
|
|
</div>
|
|
<div class="chart-card">
|
|
<div class="chart-title">🏢 Répartition par provider</div>
|
|
<div class="chart-container"><canvas id="providerChart"></canvas></div>
|
|
</div>
|
|
<div class="chart-card">
|
|
<div class="chart-title">📞 Appels par jour</div>
|
|
<div class="chart-container"><canvas id="callsChart"></canvas></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
var currentPeriod = '7d';
|
|
var statsData = null;
|
|
|
|
function formatDate(isoStr) {
|
|
var d = new Date(isoStr);
|
|
return d.toLocaleDateString('fr-FR', {day:'2-digit', month:'short'});
|
|
}
|
|
|
|
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 getDateRange(period) {
|
|
var now = new Date();
|
|
var start = new Date(now);
|
|
if (period === '24h') start.setDate(start.getDate() - 1);
|
|
else if (period === '7d') start.setDate(start.getDate() - 7);
|
|
else if (period === '30d') start.setDate(start.getDate() - 30);
|
|
return {
|
|
start: start.toISOString().split('T')[0],
|
|
end: now.toISOString().split('T')[0]
|
|
};
|
|
}
|
|
|
|
function setPeriod(period) {
|
|
currentPeriod = period;
|
|
document.querySelectorAll('.period-btn').forEach(function(b) {
|
|
b.classList.toggle('active', b.dataset.period === period);
|
|
});
|
|
loadData();
|
|
}
|
|
|
|
function checkAlerts(totals) {
|
|
var banner = document.getElementById('alert-banner');
|
|
var text = document.getElementById('alert-text');
|
|
|
|
var dailyTokens = 0;
|
|
var dailyCost = 0;
|
|
if (statsData && statsData.by_day && statsData.by_day.length > 0) {
|
|
var today = statsData.by_day[0];
|
|
dailyTokens = today.tokens || 0;
|
|
dailyCost = (today.cost_cents || 0) / 100;
|
|
}
|
|
|
|
if (dailyTokens > 0) {
|
|
var tokensPct = 100;
|
|
var costPct = 100;
|
|
banner.classList.add('visible');
|
|
if (dailyTokens > 100000) {
|
|
text.innerHTML = '<strong>⚠️ Seuil critique</strong> — ' + formatNumber(dailyTokens) + ' tokens aujourd\'hui (dépassement >100k)';
|
|
} else if (dailyCost > 5) {
|
|
text.innerHTML = '<strong>⚠️ Alerte coût</strong> — ' + dailyCost.toFixed(2) + '€ aujourd\'hui (seuil >5€)';
|
|
} else {
|
|
banner.classList.remove('visible');
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderStats(totals) {
|
|
var html = '';
|
|
var cards = [
|
|
{ label: 'Requêtes totales', value: formatNumber(totals.calls_count || 0), sub: 'période sélectionnée' },
|
|
{ label: 'Tokens totaux', value: formatNumber(totals.total_tokens || 0), sub: 'entrée + sortie' },
|
|
{ label: 'Coût estimé', value: (totals.total_cost_cents / 100).toFixed(2) + '€', sub: 'coût total période', cls: totals.total_cost_cents > 500 ? 'stat-warn' : '' },
|
|
{ label: 'Latence moyenne', value: (totals.avg_latency_ms || 0).toFixed(0) + 'ms', sub: 'temps de réponse' },
|
|
{ label: 'Erreurs', value: totals.error_count || 0, sub: 'requêtes échouées', cls: totals.error_count > 0 ? 'stat-err' : '' },
|
|
];
|
|
cards.forEach(function(c) {
|
|
html += '<div class="stat-card"><div class="stat-label">' + c.label + '</div><div class="stat-value ' + (c.cls || '') + '">' + c.value + '</div><div class="stat-sub">' + c.sub + '</div></div>';
|
|
});
|
|
document.getElementById('stats-row').innerHTML = html;
|
|
}
|
|
|
|
function renderTokensChart(byDay) {
|
|
var ctx = document.getElementById('tokensChart').getContext('2d');
|
|
var labels = byDay.map(function(d) { return formatDate(d.date); }).reverse();
|
|
var data = byDay.map(function(d) { return d.tokens; }).reverse();
|
|
new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Tokens',
|
|
data: data,
|
|
borderColor: '#00c853',
|
|
backgroundColor: 'rgba(0,200,83,0.1)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: 3,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
|
ticks: { color: '#8b949e', callback: function(v) { return formatNumber(v); } }
|
|
},
|
|
x: {
|
|
grid: { display: false },
|
|
ticks: { color: '#8b949e', maxTicksLimit: 10 }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderCostChart(byDay) {
|
|
var ctx = document.getElementById('costChart').getContext('2d');
|
|
var labels = byDay.map(function(d) { return formatDate(d.date); }).reverse();
|
|
var data = byDay.map(function(d) { return (d.cost_cents / 100).toFixed(2); }).reverse();
|
|
new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Coût (€)',
|
|
data: data,
|
|
backgroundColor: 'rgba(30,136,229,0.6)',
|
|
borderColor: '#1e88e5',
|
|
borderWidth: 1
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
|
ticks: { color: '#8b949e', callback: function(v) { return v + '€'; } }
|
|
},
|
|
x: {
|
|
grid: { display: false },
|
|
ticks: { color: '#8b949e', maxTicksLimit: 10 }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderProviderChart(byProvider) {
|
|
var ctx = document.getElementById('providerChart').getContext('2d');
|
|
var labels = byProvider.map(function(p) { return p.provider; });
|
|
var data = byProvider.map(function(p) { return p.cost_cents / 100; });
|
|
var colors = ['#00c853', '#1e88e5', '#ffd600', '#7c3aed', '#ff6d00', '#f85149'];
|
|
new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
data: data,
|
|
backgroundColor: colors.slice(0, labels.length),
|
|
borderWidth: 0
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'right',
|
|
labels: { color: '#8b949e', padding: 12, font: { size: 11 } }
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(ctx) {
|
|
return ctx.label + ': ' + ctx.parsed.toFixed(2) + '€';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderCallsChart(byDay) {
|
|
var ctx = document.getElementById('callsChart').getContext('2d');
|
|
var labels = byDay.map(function(d) { return formatDate(d.date); }).reverse();
|
|
var data = byDay.map(function(d) { return d.calls; }).reverse();
|
|
new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Appels',
|
|
data: data,
|
|
backgroundColor: 'rgba(124,58,237,0.5)',
|
|
borderColor: '#7c3aed',
|
|
borderWidth: 1,
|
|
borderRadius: 3
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
|
ticks: { color: '#8b949e' }
|
|
},
|
|
x: {
|
|
grid: { display: false },
|
|
ticks: { color: '#8b949e', maxTicksLimit: 10 }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadData() {
|
|
var range = getDateRange(currentPeriod);
|
|
document.getElementById('loading').style.display = '';
|
|
document.getElementById('dashboard-content').style.display = 'none';
|
|
|
|
try {
|
|
var url = '/api/v1/consumption/stats?client_id=internal&start_date=' + range.start + '&end_date=' + range.end;
|
|
var r = await fetch(url);
|
|
statsData = await r.json();
|
|
|
|
document.getElementById('last-refresh').textContent = '🔄 ' + new Date().toLocaleTimeString('fr-FR');
|
|
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('dashboard-content').style.display = '';
|
|
|
|
var totals = statsData.totals || {};
|
|
var byDay = statsData.by_day || [];
|
|
var byProvider = statsData.by_provider || [];
|
|
|
|
renderStats(totals);
|
|
checkAlerts(totals);
|
|
|
|
if (byDay.length > 0) {
|
|
renderTokensChart(byDay);
|
|
renderCostChart(byDay);
|
|
renderCallsChart(byDay);
|
|
} else {
|
|
['tokensChart','costChart','callsChart'].forEach(function(id) {
|
|
var ctx = document.getElementById(id).getContext('2d');
|
|
new Chart(ctx, {
|
|
type: 'line',
|
|
data: { labels: ['Aucune donnée'], datasets: [{ data: [0], backgroundColor: 'rgba(139,148,158,0.2)', borderColor: '#8b949e' }] },
|
|
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
|
|
});
|
|
});
|
|
}
|
|
|
|
if (byProvider.length > 0) {
|
|
renderProviderChart(byProvider);
|
|
} else {
|
|
var ctx = document.getElementById('providerChart').getContext('2d');
|
|
new Chart(ctx, { type: 'doughnut', data: { labels: ['Aucune donnée'], datasets: [{ data: [1], backgroundColor: ['#8b949e'], borderWidth: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#8b949e' } } } } });
|
|
}
|
|
} catch (e) {
|
|
document.getElementById('loading').innerHTML = '<div style="color:var(--error);padding:40px">❌ Erreur de chargement: ' + e.message + '</div>';
|
|
}
|
|
}
|
|
|
|
loadData();
|
|
setInterval(loadData, 30000);
|
|
</script>
|
|
</body>
|
|
</html>
|