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:
@@ -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
|
||||
---
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user