Compare commits
2 Commits
feature/HR
...
feature/HR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e25ec54d1 | ||
|
|
60e12cc4dd |
@@ -55,6 +55,23 @@ PLAN_NAMES = {
|
|||||||
"pro": "Pro",
|
"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
|
# DB helpers
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
@@ -654,6 +671,122 @@ def _handle_payment_succeeded(db, event):
|
|||||||
logger.info("invoice.payment_succeeded: user %s payment cleared", user_id)
|
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
|
# On-import: ensure DB migration ran
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ Run once:
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
import logging
|
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")
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
logger = logging.getLogger("turf_saas.billing_db")
|
logger = logging.getLogger("turf_saas.billing_db")
|
||||||
@@ -21,6 +23,7 @@ logger = logging.getLogger("turf_saas.billing_db")
|
|||||||
def get_db():
|
def get_db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
return conn
|
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_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_stripe ON subscriptions(stripe_subscription_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON subscriptions(stripe_customer_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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
print(
|
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()
|
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
|
# Re-exported helpers for test usage
|
||||||
# (primary implementations live in api_v1/routes/billing.py)
|
# (primary implementations live in api_v1/routes/billing.py)
|
||||||
|
|||||||
335
consumption_alerts.html
Normal file
335
consumption_alerts.html
Normal 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
415
consumption_dashboard.html
Normal 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
327
consumption_history.html
Normal 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>
|
||||||
@@ -755,6 +755,63 @@ def turf_static(filename):
|
|||||||
return send_from_directory("/home/h3r7/turf_saas", 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 ---
|
# --- POD Routes ---
|
||||||
@app.route("/pod/")
|
@app.route("/pod/")
|
||||||
@app.route("/pod/<path:filename>")
|
@app.route("/pod/<path:filename>")
|
||||||
|
|||||||
Reference in New Issue
Block a user