Compare commits

...

2 Commits

Author SHA1 Message Date
CTO H3R7Tech
0e25ec54d1 feat(HRT-202): Billing tables + consumption endpoint
Phase 1 — Added 3 SQLite tables to billing_db.py:
- invoices (invoice_number, user_id, period, amount, status, pdf_path)
- transactions (user_id, invoice_id, type, amount, stripe_payment_intent)
- consumption_log (user_id, date, api_calls, endpoint)
- PRAGMA foreign_keys = ON in get_db()
- Dataclass model classes for documentation

Phase 2 — GET /api/v1/billing/consumption?month=YYYY-MM:
- JWT auth required, user can only query own data
- YYYY-MM validation (422 on malformed)
- Configurable PLAN_LIMITS via env vars (not hardcoded)
- Monthly aggregation from consumption_log
- Alert semantics: 80% soft (X-Billing-Alert: soft_limit_warning)
                  100% hard (X-Billing-Alert: hard_limit_reached)
- Proper error handling (200 with zeros for no data)

Pre-checks addressed:
- PRAGMA foreign_keys = ON added to get_db()
- saas_subscriptions.plan column verified present
- Invoice format: FACT-{YYYYMM}-{XXXX} (future generation)
- Dataclass models added

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-24 11:42:36 +02:00
CTO H3R7Tech
60e12cc4dd feat(HRT-226): Dashboard consommation IA + alertes visuelles
- 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>
2026-05-24 11:29:33 +02:00
6 changed files with 1363 additions and 1 deletions

View File

@@ -55,6 +55,23 @@ PLAN_NAMES = {
"pro": "Pro",
}
# Plan consumption limits (configurable, not hardcoded)
# Override via env vars: BILLING_LIMIT_FREE_API_CALLS, BILLING_LIMIT_PREMIUM_API_CALLS, etc.
PLAN_LIMITS = {
"free": {
"monthly_api_calls": int(os.environ.get("BILLING_LIMIT_FREE_API_CALLS", "300")),
"monthly_tokens": int(os.environ.get("BILLING_LIMIT_FREE_TOKENS", "100000")),
},
"premium": {
"monthly_api_calls": int(os.environ.get("BILLING_LIMIT_PREMIUM_API_CALLS", "3000")),
"monthly_tokens": int(os.environ.get("BILLING_LIMIT_PREMIUM_TOKENS", "1000000")),
},
"pro": {
"monthly_api_calls": int(os.environ.get("BILLING_LIMIT_PRO_API_CALLS", "30000")),
"monthly_tokens": int(os.environ.get("BILLING_LIMIT_PRO_TOKENS", "10000000")),
},
}
# ──────────────────────────────────────────────────────────────
# DB helpers
# ──────────────────────────────────────────────────────────────
@@ -654,6 +671,122 @@ def _handle_payment_succeeded(db, event):
logger.info("invoice.payment_succeeded: user %s payment cleared", user_id)
# ──────────────────────────────────────────────────────────────
# GET /api/v1/billing/consumption
# ──────────────────────────────────────────────────────────────
def _parse_month(month: str):
"""Validate YYYY-MM format, return (year, month) tuple or None."""
import re
if not re.match(r"^\d{4}-\d{2}$", month):
return None
parts = month.split("-")
y, m = int(parts[0]), int(parts[1])
if m < 1 or m > 12:
return None
return y, m
@billing_bp.route("/consumption", methods=["GET"])
@jwt_required_middleware
def consumption_status():
"""
Return current month consumption vs plan limits for the authenticated user.
---
tags:
- Billing
security:
- Bearer: []
parameters:
- in: query
name: month
type: string
required: false
description: "Month in YYYY-MM format (default: current month)"
responses:
200:
description: Consumption status with usage, limits, and alerts
400:
description: Invalid parameters
422:
description: Malformed month format
"""
user = request.current_user
month = request.args.get("month", datetime.now().strftime("%Y-%m"))
parsed = _parse_month(month)
if not parsed:
return jsonify({"error": "Format mois invalide. Utiliser YYYY-MM"}), 422
year, mon = parsed
plan = user.get("plan", "free")
limits = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"])
db = get_db()
try:
# Aggregate consumption for the given month
month_start = f"{year:04d}-{mon:02d}-01"
if mon == 12:
month_end = f"{year + 1:04d}-01-01"
else:
month_end = f"{year:04d}-{mon + 1:02d}-01"
row = db.execute(
"""SELECT
COALESCE(SUM(api_calls), 0) AS total_api_calls
FROM consumption_log
WHERE user_id = ? AND date >= ? AND date < ?""",
(str(user["id"]), month_start, month_end),
).fetchone()
total_api_calls = row["total_api_calls"] if row else 0
# Calculate alert levels
api_limit = limits["monthly_api_calls"]
api_pct = round((total_api_calls / api_limit * 100), 1) if api_limit > 0 else 0
alerts = []
if api_pct >= 100:
alerts.append({
"type": "hard",
"metric": "api_calls",
"message": "Limite mensuelle d'appels API atteinte.",
"current": total_api_calls,
"limit": api_limit,
})
elif api_pct >= 80:
alerts.append({
"type": "soft",
"metric": "api_calls",
"message": f"Appels API à {api_pct}% de la limite mensuelle.",
"current": total_api_calls,
"limit": api_limit,
})
except Exception as e:
logger.error("Consumption query error for user %s: %s", user["id"], e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
resp = jsonify({
"user_id": user["id"],
"plan": plan,
"month": month,
"consumption": {
"total_api_calls": total_api_calls,
"limit_api_calls": api_limit,
"usage_pct": api_pct,
},
"alerts": alerts,
})
if any(a["type"] == "hard" for a in alerts):
resp.headers["X-Billing-Alert"] = "hard_limit_reached"
elif any(a["type"] == "soft" for a in alerts):
resp.headers["X-Billing-Alert"] = "soft_limit_warning"
return resp, 200
# ──────────────────────────────────────────────────────────────
# On-import: ensure DB migration ran
# ──────────────────────────────────────────────────────────────

View File

@@ -13,6 +13,8 @@ Run once:
import sqlite3
import os
import logging
from dataclasses import dataclass
from typing import Optional
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
logger = logging.getLogger("turf_saas.billing_db")
@@ -21,6 +23,7 @@ logger = logging.getLogger("turf_saas.billing_db")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
@@ -101,12 +104,59 @@ def migrate_billing_tables():
CREATE INDEX IF NOT EXISTS idx_saas_subs_stripe ON saas_subscriptions(stripe_subscription_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON subscriptions(stripe_customer_id);
-- HRT-202: Billing tables (invoices, transactions, consumption_log)
CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_number TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id),
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
plan TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'EUR',
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','paid','overdue','cancelled','refunded')),
pdf_path TEXT,
stripe_invoice_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
paid_at TEXT
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
invoice_id INTEGER REFERENCES invoices(id),
type TEXT NOT NULL
CHECK(type IN ('subscription','overage','credit','refund')),
amount_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'EUR',
stripe_payment_intent_id TEXT,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS consumption_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
date TEXT NOT NULL,
api_calls INTEGER NOT NULL DEFAULT 0,
endpoint TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, date, endpoint)
);
CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id);
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
CREATE INDEX IF NOT EXISTS idx_transactions_user ON transactions(user_id);
CREATE INDEX IF NOT EXISTS idx_consumption_user ON consumption_log(user_id);
CREATE INDEX IF NOT EXISTS idx_consumption_date ON consumption_log(date);
""")
conn.commit()
conn.close()
print(
"[billing_db] Migration complete: subscriptions + billing_events tables ready."
"[billing_db] Migration complete: subscriptions + billing_events + invoices + transactions + consumption_log ready."
)
@@ -115,6 +165,51 @@ if __name__ == "__main__":
migrate_billing_tables()
# ──────────────────────────────────────────────────────────────
# Model classes (documentation / type hints)
# ──────────────────────────────────────────────────────────────
@dataclass
class Invoice:
id: Optional[int] = None
invoice_number: str = ""
user_id: int = 0
period_start: str = ""
period_end: str = ""
plan: str = ""
amount_cents: int = 0
currency: str = "EUR"
status: str = "pending"
pdf_path: Optional[str] = None
stripe_invoice_id: Optional[str] = None
created_at: str = ""
paid_at: Optional[str] = None
@dataclass
class Transaction:
id: Optional[int] = None
user_id: int = 0
invoice_id: Optional[int] = None
type: str = "subscription"
amount_cents: int = 0
currency: str = "EUR"
stripe_payment_intent_id: Optional[str] = None
description: Optional[str] = None
created_at: str = ""
@dataclass
class ConsumptionLog:
id: Optional[int] = None
user_id: int = 0
date: str = ""
api_calls: int = 0
endpoint: Optional[str] = None
created_at: str = ""
# ──────────────────────────────────────────────────────────────
# Re-exported helpers for test usage
# (primary implementations live in api_v1/routes/billing.py)

335
consumption_alerts.html Normal file
View File

@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Consommation IA — Alertes</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-danger { background: rgba(248,81,73,.15); color: var(--error); border: 1px solid rgba(248,81,73,.3); }
.btn-danger:hover { background: rgba(248,81,73,.25); }
.btn-sm { padding: 5px 12px; font-size: .8rem; }
.content { padding: 28px; max-width: 1000px; margin: 0 auto; }
.section-title { font-size: 1rem; font-weight: 700; margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; }
.alert-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; margin-bottom: 12px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.alert-card.inactive { opacity: .6; }
.alert-icon { font-size: 1.3rem; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: var(--dark3); border-radius: 8px; flex-shrink: 0; }
.alert-info { flex: 1; min-width: 200px; }
.alert-info .alert-name { font-weight: 700; font-size: .93rem; }
.alert-info .alert-desc { font-size: .82rem; color: var(--muted); margin-top: 2px; }
.alert-actions { display: flex; gap: 8px; flex-shrink: 0; }
.toggle-switch { width: 40px; height: 22px; border-radius: 11px; background: var(--border); cursor: pointer; position: relative; transition: background .2s; flex-shrink: 0; }
.toggle-switch.active { background: var(--green); }
.toggle-switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .2s; }
.toggle-switch.active::after { transform: translateX(18px); }
.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; margin-bottom: 16px; }
.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)}
.modal-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,.6); z-index: 1000;
align-items: center; justify-content: center;
}
.modal-overlay.show { display: flex; }
.modal {
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 24px; width: 90%; max-width: 480px; max-height: 90vh; overflow-y: auto;
}
.modal h3 { font-size: 1.05rem; margin-bottom: 20px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: .8rem; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
.form-group input, .form-group select {
width: 100%; background: var(--dark3); border: 1px solid var(--border);
border-radius: 8px; padding: 9px 12px; color: var(--text); font-size: .88rem;
outline: none; transition: border-color .2s; font-family: inherit;
}
.form-group input:focus, .form-group select:focus { border-color: var(--green); }
.form-actions { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
.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); }
.threshold-badge { padding: 3px 10px; border-radius: 12px; font-size: .72rem; font-weight: 700; }
.threshold-info { background: rgba(30,136,229,.15); color: var(--blue); }
.threshold-warn { background: rgba(255,214,0,.15); color: var(--gold); }
.threshold-danger { background: rgba(248,81,73,.15); 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>🔔 Gestion des Alertes</span>
<a href="/dashboard/consumption">← Dashboard</a>
</div>
<div class="topbar-right">
<button class="btn btn-primary btn-sm" onclick="openCreateModal()">+ Nouvelle alerte</button>
</div>
</div>
<div class="content">
<div class="section-title">
<span>Règles d'alerte consommation</span>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<div>Chargement des alertes...</div>
</div>
<div id="alerts-container"></div>
</div>
<div class="modal-overlay" id="modal">
<div class="modal">
<h3 id="modal-title">Nouvelle alerte</h3>
<input type="hidden" id="edit-id">
<div class="form-group">
<label>Type de seuil</label>
<select id="form-type">
<option value="tokens">Tokens</option>
<option value="cost">Coût (cents)</option>
</select>
</div>
<div class="form-group">
<label>Valeur du seuil</label>
<input type="number" id="form-value" min="1" step="0.01" placeholder="1000">
</div>
<div class="form-group">
<label>Période</label>
<select id="form-period">
<option value="daily">Journalier</option>
<option value="monthly">Mensuel</option>
</select>
</div>
<div class="form-group">
<label>Email notification (optionnel)</label>
<input type="email" id="form-email" placeholder="admin@example.com">
</div>
<div class="form-group">
<label>Webhook notification (optionnel)</label>
<input type="url" id="form-webhook" placeholder="https://hooks.example.com/alert">
</div>
<div class="form-actions">
<button class="btn btn-ghost" onclick="closeModal()">Annuler</button>
<button class="btn btn-primary" onclick="saveAlert()">Enregistrer</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
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 openModal() {
document.getElementById('modal').classList.add('show');
}
function closeModal() {
document.getElementById('modal').classList.remove('show');
document.getElementById('edit-id').value = '';
document.getElementById('modal-title').textContent = 'Nouvelle alerte';
document.getElementById('form-type').value = 'tokens';
document.getElementById('form-value').value = '';
document.getElementById('form-period').value = 'daily';
document.getElementById('form-email').value = '';
document.getElementById('form-webhook').value = '';
}
function openCreateModal() {
closeModal();
openModal();
}
function getThresholdClass(value, type) {
if (type === 'tokens') {
if (value >= 100000) return 'threshold-danger';
if (value >= 50000) return 'threshold-warn';
return 'threshold-info';
}
if (type === 'cost') {
if (value >= 10000) return 'threshold-danger';
if (value >= 5000) return 'threshold-warn';
return 'threshold-info';
}
return 'threshold-info';
}
function formatThreshold(value, type) {
if (type === 'tokens') {
if (value >= 1000000) return (value/1000000).toFixed(1) + 'M tokens';
if (value >= 1000) return (value/1000).toFixed(1) + 'k tokens';
return value + ' tokens';
}
if (type === 'cost') {
return (value / 100).toFixed(2) + '€';
}
return value;
}
function renderAlerts(alerts) {
var container = document.getElementById('alerts-container');
if (!alerts || alerts.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">🔔</div><p>Aucune alerte configurée</p><button class="btn btn-primary" onclick="openCreateModal()">+ Créer une alerte</button></div>';
return;
}
var html = '';
alerts.forEach(function(a) {
var activeClass = a.is_active ? '' : 'inactive';
var toggleClass = a.is_active ? 'active' : '';
var thresholdClass = getThresholdClass(a.threshold_value, a.threshold_type);
html += '<div class="alert-card ' + activeClass + '" data-id="' + a.id + '">' +
'<div class="alert-icon">' + (a.threshold_type === 'tokens' ? '🔷' : '💰') + '</div>' +
'<div class="alert-info">' +
'<div class="alert-name">' + (a.threshold_type === 'tokens' ? 'Seuil tokens' : 'Seuil coût') + ' <span class="threshold-badge ' + thresholdClass + '">' + formatThreshold(a.threshold_value, a.threshold_type) + '</span></div>' +
'<div class="alert-desc">Période: ' + (a.period === 'daily' ? 'Journalier' : 'Mensuel') + (a.notify_email ? ' · 📧 ' + a.notify_email : '') + (a.notify_webhook ? ' · 🔗 webhook' : '') + '</div>' +
'</div>' +
'<div class="alert-actions">' +
'<div class="toggle-switch ' + toggleClass + '" onclick="toggleAlert(' + a.id + ', ' + !a.is_active + ')"></div>' +
'<button class="btn btn-ghost btn-sm" onclick="editAlert(' + a.id + ')">✏️</button>' +
'<button class="btn btn-danger btn-sm" onclick="deleteAlert(' + a.id + ')">🗑️</button>' +
'</div>' +
'</div>';
});
container.innerHTML = html;
}
async function loadAlerts() {
document.getElementById('loading').style.display = '';
try {
var r = await fetch('/api/v1/consumption/alerts');
var data = await r.json();
document.getElementById('loading').style.display = 'none';
renderAlerts(data.alerts || []);
} catch (e) {
document.getElementById('loading').innerHTML = '<div style="color:var(--error);padding:40px">❌ Erreur: ' + e.message + '</div>';
}
}
async function toggleAlert(id, newState) {
try {
var r = await fetch('/api/v1/consumption/alerts/' + id, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ is_active: newState })
});
if (!r.ok) { showToast('Erreur lors de la modification', 'error'); return; }
showToast('Alerte ' + (newState ? 'activée' : 'désactivée'), 'success');
loadAlerts();
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
function editAlert(id) {
var card = document.querySelector('.alert-card[data-id="' + id + '"]');
if (!card) return;
// Re-fetch from API to get full data
fetch('/api/v1/consumption/alerts/' + id)
.then(function(r) { return r.json(); })
.then(function(a) {
document.getElementById('edit-id').value = a.id;
document.getElementById('modal-title').textContent = 'Modifier l\'alerte #' + a.id;
document.getElementById('form-type').value = a.threshold_type;
document.getElementById('form-value').value = a.threshold_value;
document.getElementById('form-period').value = a.period;
document.getElementById('form-email').value = a.notify_email || '';
document.getElementById('form-webhook').value = a.notify_webhook || '';
openModal();
})
.catch(function(e) { showToast('Erreur: ' + e.message, 'error'); });
}
async function saveAlert() {
var id = document.getElementById('edit-id').value;
var data = {
client_id: 'internal',
threshold_type: document.getElementById('form-type').value,
threshold_value: parseFloat(document.getElementById('form-value').value),
period: document.getElementById('form-period').value,
notify_email: document.getElementById('form-email').value || null,
notify_webhook: document.getElementById('form-webhook').value || null,
};
if (!data.threshold_value || data.threshold_value <= 0) {
showToast('Veuillez entrer une valeur de seuil valide', 'error');
return;
}
try {
var url = id ? '/api/v1/consumption/alerts/' + id : '/api/v1/consumption/alerts';
var method = id ? 'PUT' : 'POST';
var r = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (!r.ok) { showToast('Erreur lors de l\'enregistrement', 'error'); return; }
showToast(id ? 'Alerte modifiée' : 'Alerte créée', 'success');
closeModal();
loadAlerts();
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
async function deleteAlert(id) {
if (!confirm('Supprimer cette alerte ?')) return;
try {
var r = await fetch('/api/v1/consumption/alerts/' + id, { method: 'DELETE' });
if (!r.ok) { showToast('Erreur lors de la suppression', 'error'); return; }
showToast('Alerte supprimée', 'success');
loadAlerts();
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
loadAlerts();
</script>
</body>
</html>

415
consumption_dashboard.html Normal file
View File

@@ -0,0 +1,415 @@
<!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>

327
consumption_history.html Normal file
View File

@@ -0,0 +1,327 @@
<!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>

View File

@@ -755,6 +755,63 @@ def turf_static(filename):
return send_from_directory("/home/h3r7/turf_saas", filename)
# --- Consumption Dashboard (HRT-226) ---
CONSUMPTION_API_URL = "http://localhost:8784"
CONSUMPTION_API_KEY = os.environ.get("CONSUMPTION_API_KEY", "dev-key-change-in-production")
@app.route("/dashboard/consumption")
def consumption_dashboard():
return send_from_directory(SAAS_DIR, "consumption_dashboard.html")
@app.route("/dashboard/consumption/history")
def consumption_history():
return send_from_directory(SAAS_DIR, "consumption_history.html")
@app.route("/dashboard/consumption/alerts")
def consumption_alerts():
return send_from_directory(SAAS_DIR, "consumption_alerts.html")
# Proxy: /api/v1/consumption/* -> consumption-tracker (port 8784)
@app.route("/api/v1/consumption/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def proxy_consumption_api(subpath):
full_url = f"{CONSUMPTION_API_URL}/api/v1/consumption/{subpath}"
if request.query_string:
full_url += "?" + request.query_string.decode()
try:
headers = {k: v for k, v in request.headers if k.lower() not in ("host", "content-length", "transfer-encoding", "connection")}
if not any(k.lower() == "authorization" for k in headers):
headers["Authorization"] = f"Bearer {CONSUMPTION_API_KEY}"
raw_body = request.get_data()
resp = requests.request(request.method, full_url, headers=headers, data=raw_body, cookies=request.cookies, allow_redirects=False, timeout=15)
response = make_response(resp.content, resp.status_code)
for k, v in resp.headers.items():
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
response.headers[k] = v
return response
except Exception as e:
return jsonify({"error": f"Consumption proxy error: {e}"}), 502
# Proxy: /api/v1/ai/usage* -> AI Router (port 8783)
@app.route("/api/v1/ai/usage")
@app.route("/api/v1/ai/usage/<path:subpath>")
def proxy_ai_usage(subpath=""):
full_url = f"http://localhost:8783/api/v1/ai/usage"
if subpath:
full_url += "/" + subpath
if request.query_string:
full_url += "?" + request.query_string.decode()
try:
resp = requests.get(full_url, timeout=15)
return resp.content, resp.status_code, {"Content-Type": "application/json"}
except Exception as e:
return jsonify({"error": f"AI usage proxy error: {e}"}), 502
# --- POD Routes ---
@app.route("/pod/")
@app.route("/pod/<path:filename>")