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,
|
internal_error,
|
||||||
bad_request,
|
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 = Blueprint("v1_metrics", __name__, url_prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@metrics_bp.route("/metrics", methods=["GET"])
|
@metrics_bp.route("/metrics", methods=["GET"])
|
||||||
@jwt_required_middleware
|
@jwt_required_middleware
|
||||||
@plan_required("premium", "pro")
|
|
||||||
def metrics():
|
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
|
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-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-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>
|
<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-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>
|
<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>
|
||||||
</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><!-- .content -->
|
||||||
</div><!-- .main -->
|
</div><!-- .main -->
|
||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
@@ -793,7 +842,11 @@ function logout() {
|
|||||||
location.href = '/login';
|
location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚠️ TEST_MODE — mettre false pour réactiver les restrictions de plan
|
||||||
|
const TEST_MODE = true;
|
||||||
|
|
||||||
function planLevel(plan) {
|
function planLevel(plan) {
|
||||||
|
if (TEST_MODE) return 2; // pro level pour tous
|
||||||
return { free: 0, premium: 1, pro: 2 }[plan] || 0;
|
return { free: 0, premium: 1, pro: 2 }[plan] || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,6 +883,7 @@ const SECTION_TITLES = {
|
|||||||
'api-token': 'API Token',
|
'api-token': 'API Token',
|
||||||
'webhook': 'Webhook',
|
'webhook': 'Webhook',
|
||||||
'multi-account': 'Multi-compte',
|
'multi-account': 'Multi-compte',
|
||||||
|
'metrics': 'Métriques de performance',
|
||||||
};
|
};
|
||||||
|
|
||||||
function showSection(name, navEl) {
|
function showSection(name, navEl) {
|
||||||
@@ -856,6 +910,7 @@ function onSectionShow(name) {
|
|||||||
if (name === 'api-token' && planAllows(currentPlan, 'premium')) loadApiToken();
|
if (name === 'api-token' && planAllows(currentPlan, 'premium')) loadApiToken();
|
||||||
if (name === 'webhook' && planAllows(currentPlan, 'pro')) loadWebhook();
|
if (name === 'webhook' && planAllows(currentPlan, 'pro')) loadWebhook();
|
||||||
if (name === 'multi-account' && planAllows(currentPlan, 'pro')) loadMultiAccount();
|
if (name === 'multi-account' && planAllows(currentPlan, 'pro')) loadMultiAccount();
|
||||||
|
if (name === 'metrics') loadMetrics();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────
|
||||||
@@ -1525,6 +1580,7 @@ function initNavFromHash() {
|
|||||||
'api-token': 'nav-api-token',
|
'api-token': 'nav-api-token',
|
||||||
'webhook': 'nav-webhook',
|
'webhook': 'nav-webhook',
|
||||||
'multi-account': 'nav-multi-account',
|
'multi-account': 'nav-multi-account',
|
||||||
|
'metrics': 'nav-metrics',
|
||||||
};
|
};
|
||||||
if (hash && sectionMap[hash]) {
|
if (hash && sectionMap[hash]) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1545,6 +1601,140 @@ window.showSection = function(name, navEl) {
|
|||||||
return _origShowSection(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);
|
loadDashboard().then(initNavFromHash);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ SAAS_DIR = "/home/h3r7/turf_saas"
|
|||||||
try:
|
try:
|
||||||
from saas_auth import auth_bp
|
from saas_auth import auth_bp
|
||||||
from saas_api_v1 import api_v1_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(auth_bp)
|
||||||
app.register_blueprint(api_v1_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 ✅")
|
print("[portal] SaaS auth & API v1 blueprints registered ✅")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
|
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user