feat(saas): métriques ML + TEST_MODE + compte test pro

- portal_server.py: enregistre metrics_bp (/api/v1/metrics)
- api_v1/routes/metrics.py: switch vers saas_auth.require_auth (compat token opaque)
- dashboard_saas.html: onglet Métriques (KPIs + Chart.js ROI/précision/cumul + table daily)
- dashboard_saas.html: TEST_MODE=true -> plan level pro pour toutes les fonctionnalités
- turf_saas.db: compte admin@h3r7.ai / Test1234! plan=pro (test)
This commit is contained in:
DevOps Engineer
2026-05-02 22:49:59 +02:00
parent 3079c2c6c6
commit a126941f7f
3 changed files with 202 additions and 2 deletions

View File

@@ -14,15 +14,21 @@ from api_v1.utils import (
internal_error,
bad_request,
)
from auth import jwt_required_middleware, plan_required
from saas_auth import require_auth as jwt_required_middleware
from flask import request as _req
metrics_bp = Blueprint("v1_metrics", __name__, url_prefix="/api/v1")
@metrics_bp.route("/metrics", methods=["GET"])
@jwt_required_middleware
@plan_required("premium", "pro")
def metrics():
# plan check: premium or pro (or TEST_MODE via plan='pro' in DB)
user = getattr(_req, 'current_user', None) or {}
plan = user.get('plan', 'free') if isinstance(user, dict) else 'free'
if plan not in ('premium', 'pro'):
from flask import jsonify as _j
return _j({'error': 'Plan premium ou pro requis'}), 403
"""
Métriques ML
---

View File

@@ -259,6 +259,7 @@
<a class="nav-item" id="nav-history" href="#history" onclick="showSection('history',this)"><span class="icon">📅</span> Historique <span class="plan-lock" id="lock-hist"></span></a>
<a class="nav-item" id="nav-export" href="#export" onclick="showSection('export',this)"><span class="icon">📤</span> Export CSV <span class="plan-lock" id="lock-export"></span></a>
<a class="nav-item" id="nav-metrics" href="#metrics" onclick="showSection('metrics',this)"><span class="icon">📈</span> Métriques</a>
<div class="nav-section">Paramètres</div>
<a class="nav-item" id="nav-telegram" href="#telegram" onclick="showSection('telegram',this)"><span class="icon">📱</span> Alertes Telegram <span class="plan-lock" id="lock-tg"></span></a>
<a class="nav-item" id="nav-api-token" href="#api-token" onclick="showSection('api-token',this)"><span class="icon"></span> API Token <span class="plan-lock" id="lock-api"></span></a>
@@ -753,11 +754,59 @@
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ METRICS -->
<div id="section-metrics" class="dashboard-section" style="display:none">
<div class="section-header">
<h2>📈 Métriques de performance</h2>
<div style="display:flex;gap:8px;align-items:center">
<select id="metrics-days" style="background:var(--dark3);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 10px;font-size:.85rem" onchange="loadMetrics()">
<option value="7">7 jours</option>
<option value="30" selected>30 jours</option>
<option value="90">90 jours</option>
<option value="365">365 jours</option>
</select>
<button class="btn btn-sm" onclick="loadMetrics()" style="padding:4px 14px;font-size:.85rem">🔄 Rafraîchir</button>
</div>
</div>
<!-- KPI cards -->
<div class="stats-grid" id="metrics-kpis" style="margin-bottom:20px">
<div class="stat-card"><div class="stat-label">Total paris</div><div class="stat-value" id="m-total-bets"></div></div>
<div class="stat-card"><div class="stat-label">Précision</div><div class="stat-value" id="m-precision" style="color:var(--green)"></div></div>
<div class="stat-card"><div class="stat-label">ROI</div><div class="stat-value" id="m-roi"></div></div>
<div class="stat-card"><div class="stat-label">Mise totale</div><div class="stat-value" id="m-mise"></div></div>
<div class="stat-card"><div class="stat-label">Gain total</div><div class="stat-value" id="m-gain"></div></div>
<div class="stat-card"><div class="stat-label">Prédictions ML</div><div class="stat-value" id="m-ml-preds"></div></div>
<div class="stat-card"><div class="stat-label">Value Bets ML</div><div class="stat-value" id="m-value-bets"></div></div>
<div class="stat-card"><div class="stat-label">Prob. Top-3 moy.</div><div class="stat-value" id="m-prob-top3"></div></div>
</div>
<!-- Charts row -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
<div class="form-card" style="padding:16px">
<h3 style="font-size:.9rem;margin-bottom:12px">📊 ROI & Précision quotidiens</h3>
<canvas id="chart-roi-daily" height="200"></canvas>
</div>
<div class="form-card" style="padding:16px">
<h3 style="font-size:.9rem;margin-bottom:12px">💰 Cumul gains vs mises</h3>
<canvas id="chart-cumul" height="200"></canvas>
</div>
</div>
<!-- Daily stats table -->
<div class="form-card">
<h3 style="font-size:.9rem;margin-bottom:12px">📋 Détail quotidien</h3>
<div id="metrics-table-wrap" style="overflow-x:auto">
<div class="loader-row"><div class="spinner"></div> Chargement…</div>
</div>
</div>
</div>
</div><!-- .content -->
</div><!-- .main -->
<div id="toast"></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
const API = '/api/v1';
let currentUser = null;
@@ -793,7 +842,11 @@ function logout() {
location.href = '/login';
}
// ⚠️ TEST_MODE — mettre false pour réactiver les restrictions de plan
const TEST_MODE = true;
function planLevel(plan) {
if (TEST_MODE) return 2; // pro level pour tous
return { free: 0, premium: 1, pro: 2 }[plan] || 0;
}
@@ -830,6 +883,7 @@ const SECTION_TITLES = {
'api-token': 'API Token',
'webhook': 'Webhook',
'multi-account': 'Multi-compte',
'metrics': 'Métriques de performance',
};
function showSection(name, navEl) {
@@ -856,6 +910,7 @@ function onSectionShow(name) {
if (name === 'api-token' && planAllows(currentPlan, 'premium')) loadApiToken();
if (name === 'webhook' && planAllows(currentPlan, 'pro')) loadWebhook();
if (name === 'multi-account' && planAllows(currentPlan, 'pro')) loadMultiAccount();
if (name === 'metrics') loadMetrics();
}
// ────────────────────────────────────────────────────────
@@ -1525,6 +1580,7 @@ function initNavFromHash() {
'api-token': 'nav-api-token',
'webhook': 'nav-webhook',
'multi-account': 'nav-multi-account',
'metrics': 'nav-metrics',
};
if (hash && sectionMap[hash]) {
setTimeout(() => {
@@ -1545,6 +1601,140 @@ window.showSection = function(name, navEl) {
return _origShowSection(name, navEl);
};
// ────────────────────────────────────────────────────────
// Métriques
// ────────────────────────────────────────────────────────
let chartRoiDaily = null;
let chartCumul = null;
async function loadMetrics() {
const days = document.getElementById('metrics-days')?.value || 30;
const data = await fetchJson(`${API}/metrics?days=${days}`);
if (!data) return;
// KPIs
const bm = data.bet_metrics || {};
const ml = data.ml_metrics || {};
setText('m-total-bets', bm.available ? bm.total_bets : '—');
setText('m-precision', bm.available ? bm.precision_pct + ' %' : '—');
const roi = bm.available ? bm.roi_pct : null;
const roiEl = document.getElementById('m-roi');
if (roiEl) {
roiEl.textContent = roi !== null ? roi + ' %' : '—';
roiEl.style.color = roi > 0 ? 'var(--green)' : roi < 0 ? '#f44' : 'var(--text)';
}
setText('m-mise', bm.available ? bm.mise_totale + ' €' : '—');
setText('m-gain', bm.available ? bm.gain_total + ' €' : '—');
setText('m-ml-preds', ml.available ? ml.total_predictions : '—');
setText('m-value-bets', ml.available ? ml.value_bets : '—');
setText('m-prob-top3', ml.available ? (ml.avg_prob_top3 * 100).toFixed(1) + ' %' : '—');
// Daily charts
const daily = data.daily || [];
const labels = daily.map(r => r.date ? r.date.slice(5) : '').reverse();
const roiArr = daily.map(r => r.roi_pct || 0).reverse();
const precArr = daily.map(r => r.precision_pct || 0).reverse();
const gainArr = daily.map(r => r.gain_total || 0).reverse();
const miseArr = daily.map(r => r.mise_totale || 0).reverse();
// Cumul gains
const cumulGain = gainArr.reduce((acc, v, i) => { acc.push((acc[i-1]||0) + v); return acc; }, []);
const cumulMise = miseArr.reduce((acc, v, i) => { acc.push((acc[i-1]||0) + v); return acc; }, []);
renderChartRoi(labels, roiArr, precArr);
renderChartCumul(labels, cumulGain, cumulMise);
// Table
renderMetricsTable(daily);
}
function setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
function renderChartRoi(labels, roiArr, precArr) {
const ctx = document.getElementById('chart-roi-daily');
if (!ctx) return;
if (chartRoiDaily) chartRoiDaily.destroy();
chartRoiDaily = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'ROI %', data: roiArr, backgroundColor: roiArr.map(v => v >= 0 ? 'rgba(0,200,83,.6)' : 'rgba(244,67,54,.6)'), yAxisID: 'y' },
{ label: 'Précision %', data: precArr, type: 'line', borderColor: '#ffd600', backgroundColor: 'transparent', tension: 0.3, yAxisID: 'y2', pointRadius: 2 }
]
},
options: {
responsive: true, maintainAspectRatio: true,
plugins: { legend: { labels: { color: '#ccc', font: { size: 11 } } } },
scales: {
x: { ticks: { color: '#888', maxTicksLimit: 10 }, grid: { color: 'rgba(255,255,255,.05)' } },
y: { ticks: { color: '#888' }, grid: { color: 'rgba(255,255,255,.05)' } },
y2: { position: 'right', ticks: { color: '#ffd600' }, grid: { display: false } }
}
}
});
}
function renderChartCumul(labels, cumulGain, cumulMise) {
const ctx = document.getElementById('chart-cumul');
if (!ctx) return;
if (chartCumul) chartCumul.destroy();
chartCumul = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{ label: 'Gain cumulé (€)', data: cumulGain, borderColor: '#00c853', backgroundColor: 'rgba(0,200,83,.1)', fill: true, tension: 0.3, pointRadius: 2 },
{ label: 'Mise cumulée (€)', data: cumulMise, borderColor: '#aaa', backgroundColor: 'transparent', borderDash: [4,4], tension: 0.3, pointRadius: 0 }
]
},
options: {
responsive: true, maintainAspectRatio: true,
plugins: { legend: { labels: { color: '#ccc', font: { size: 11 } } } },
scales: {
x: { ticks: { color: '#888', maxTicksLimit: 10 }, grid: { color: 'rgba(255,255,255,.05)' } },
y: { ticks: { color: '#888' }, grid: { color: 'rgba(255,255,255,.05)' } }
}
}
});
}
function renderMetricsTable(daily) {
const wrap = document.getElementById('metrics-table-wrap');
if (!wrap) return;
if (!daily.length) {
wrap.innerHTML = '<p style="color:var(--muted);padding:12px">Aucune donnée disponible pour cette période.</p>';
return;
}
const rows = daily.map(r => `
<tr>
<td>${r.date || '—'}</td>
<td>${r.total_bets ?? '—'}</td>
<td>${r.bets_gagne ?? '—'}</td>
<td style="color:${(r.precision_pct||0)>50?'var(--green)':'var(--text)'}">${r.precision_pct != null ? r.precision_pct.toFixed(1)+' %' : '—'}</td>
<td style="color:${(r.roi_pct||0)>0?'var(--green)':'#f44'}">${r.roi_pct != null ? (r.roi_pct>0?'+':'')+r.roi_pct.toFixed(2)+' %' : '—'}</td>
<td>${r.mise_totale != null ? r.mise_totale.toFixed(2)+' €' : '—'}</td>
<td style="color:${(r.gain_total||0)>0?'var(--green)':'#f44'}">${r.gain_total != null ? (r.gain_total>0?'+':'')+r.gain_total.toFixed(2)+' €' : '—'}</td>
</tr>`).join('');
wrap.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:.85rem">
<thead><tr style="color:var(--muted);border-bottom:1px solid var(--border)">
<th style="padding:6px 8px;text-align:left">Date</th>
<th style="padding:6px 8px;text-align:left">Paris</th>
<th style="padding:6px 8px;text-align:left">Gagnés</th>
<th style="padding:6px 8px;text-align:left">Précision</th>
<th style="padding:6px 8px;text-align:left">ROI</th>
<th style="padding:6px 8px;text-align:left">Mise</th>
<th style="padding:6px 8px;text-align:left">Gain</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
loadDashboard().then(initNavFromHash);
</script>
</body>

View File

@@ -19,9 +19,13 @@ SAAS_DIR = "/home/h3r7/turf_saas"
try:
from saas_auth import auth_bp
from saas_api_v1 import api_v1_bp
from api_v1.routes.ml_feedback import ml_feedback_bp
from api_v1.routes.metrics import metrics_bp
app.register_blueprint(auth_bp)
app.register_blueprint(api_v1_bp)
app.register_blueprint(ml_feedback_bp)
app.register_blueprint(metrics_bp)
print("[portal] SaaS auth & API v1 blueprints registered ✅")
except Exception as e:
print(f"[portal] Warning: could not register SaaS blueprints: {e}")