Files
turf_saas/dashboard_saas.html
DevOps Engineer 793ee82c29 fix(qa): add /health endpoints to Flask apps for Docker healthchecks
Docker compose healthchecks target /health on combined-api, dashboard-api
and portal, but these endpoints did not exist (returned 404). This caused
all dependent services (condition: service_healthy) to fail startup.

- combined_api.py: GET /health + /turf/health with DB connectivity check
- dashboard_api.py: GET /health + /turf/health with DB connectivity check
- portal_server.py: GET /health (lightweight, no DB)

QA Finding 1 from HRT-34 review of HRT-33 branch feature/devops-cicd.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 17:44:21 +02:00

442 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard — Turf IA</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;
}
html { scroll-behavior: smooth; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; }
a { color: inherit; text-decoration: none; }
/* SIDEBAR */
.sidebar {
width: 240px; flex-shrink: 0; background: var(--dark2);
border-right: 1px solid var(--border); padding: 20px 0;
display: flex; flex-direction: column; height: 100vh;
position: sticky; top: 0; overflow-y: auto;
}
.sidebar-logo { padding: 0 20px 20px; font-weight: 800; font-size: 1.1rem; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
.sidebar-logo span { color: var(--green); }
.nav-section { padding: 0 12px 8px; font-size: .72rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); font-weight: 600; margin-top: 12px; }
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 20px; font-size: .9rem; cursor: pointer;
border-radius: 8px; margin: 1px 8px; color: var(--muted); transition: all .15s;
}
.nav-item:hover { background: var(--dark3); color: var(--text); }
.nav-item.active { background: rgba(0,200,83,.1); color: var(--green); }
.nav-item .icon { font-size: 1.1rem; width: 22px; text-align: center; }
.sidebar-bottom { margin-top: auto; padding: 16px; border-top: 1px solid var(--border); }
.user-chip { display: flex; align-items: center; gap: 10px; }
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--green); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .9rem; color: #000; flex-shrink: 0; }
.user-info { flex: 1; min-width: 0; }
.user-name { font-size: .85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-plan { font-size: .72rem; color: var(--muted); text-transform: uppercase; }
/* MAIN */
.main { flex: 1; overflow-y: auto; }
.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; }
.topbar-title { font-size: 1.1rem; font-weight: 700; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.badge { padding: 3px 10px; border-radius: 20px; font-size: .75rem; font-weight: 700; }
.badge-free { background: rgba(139,148,158,.15); color: var(--muted); }
.badge-premium { background: rgba(255,214,0,.15); color: var(--gold); }
.badge-pro { background: rgba(30,136,229,.15); color: var(--blue); }
.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-upgrade { background: linear-gradient(135deg, var(--gold), var(--orange)); color: #000; }
/* CONTENT */
.content { padding: 28px; }
/* UPGRADE BANNER */
.upgrade-banner {
background: linear-gradient(135deg, rgba(255,214,0,.1), rgba(255,109,0,.08));
border: 1px solid rgba(255,214,0,.25); border-radius: var(--radius);
padding: 16px 20px; margin-bottom: 24px;
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
.upgrade-banner p { font-size: .9rem; }
.upgrade-banner strong { color: var(--gold); }
/* STAT CARDS */
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 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; }
.stat-value { font-size: 1.8rem; font-weight: 800; }
.stat-sub { font-size: .78rem; color: var(--muted); margin-top: 4px; }
.stat-up { color: var(--green); }
.stat-down { color: var(--error); }
/* SECTION TITLE */
.section-title { font-size: 1rem; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between; }
.section-title small { font-size: .78rem; color: var(--muted); font-weight: 400; }
/* RACE TABLE */
.race-table-wrap { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; }
thead th { padding: 10px 14px; font-size: .78rem; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); text-align: left; border-bottom: 1px solid var(--border); background: var(--dark3); }
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: 11px 14px; font-size: .88rem; }
.horse-name { font-weight: 600; }
.prob-bar-wrap { display: flex; align-items: center; gap: 8px; }
.prob-bar { width: 80px; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
.prob-bar-fill { height: 100%; border-radius: 3px; background: var(--green); }
.value-bet { background: rgba(0,200,83,.15); color: var(--green); padding: 2px 8px; border-radius: 12px; font-size: .75rem; font-weight: 700; }
.cote { font-weight: 700; }
.rank-1 { color: var(--gold); font-weight: 800; }
.rank-2 { color: var(--muted); }
.rank-3 { color: #cd7f32; }
/* BLURRED (locked) */
.locked { filter: blur(6px); pointer-events: none; user-select: none; position: relative; }
.locked-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(13,17,23,.7); border-radius: var(--radius); z-index: 2; }
.locked-overlay-msg { text-align: center; }
.locked-overlay-msg h3 { font-size: 1rem; margin-bottom: 8px; }
.lock-wrap { position: relative; }
/* RACE CARD grid */
.races-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-bottom: 24px; }
.race-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; transition: border-color .2s; }
.race-card:hover { border-color: var(--muted); }
.race-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.race-name { font-weight: 700; font-size: .93rem; }
.race-meta { font-size: .78rem; color: var(--muted); margin-top: 2px; }
.race-time { font-size: .8rem; font-weight: 700; color: var(--green); }
.top3-row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.horse-chip {
padding: 4px 10px; border-radius: 20px; font-size: .78rem; font-weight: 600;
background: var(--dark3); border: 1px solid var(--border);
}
.horse-chip.top1 { border-color: var(--gold); color: var(--gold); background: rgba(255,214,0,.08); }
.horse-chip.top2 { border-color: var(--muted); }
.horse-chip.top3 { border-color: #cd7f32; color: #cd7f32; background: rgba(205,127,50,.08); }
/* EMPTY STATE */
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
.empty-state .icon { font-size: 3rem; margin-bottom: 14px; }
.empty-state h3 { font-size: 1.1rem; font-weight: 700; color: var(--text); margin-bottom: 8px; }
.empty-state p { font-size: .9rem; max-width: 360px; margin: 0 auto 20px; }
/* TOAST */
#toast { position: fixed; bottom: 24px; right: 24px; z-index: 999; padding: 12px 20px; border-radius: 10px; font-size: .88rem; font-weight: 600; transform: translateY(60px); opacity: 0; transition: all .3s; pointer-events: none; }
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { background: var(--green); color: #000; }
#toast.info { background: var(--blue); color: #fff; }
/* LOADING */
.loader-row { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 40px; color: var(--muted); }
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* RESPONSIVE */
@media (max-width: 900px) { .sidebar { display: none; } }
@media (max-width: 600px) { .stats-row { grid-template-columns: 1fr 1fr; } .content { padding: 16px; } }
</style>
</head>
<body>
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-logo">🏇 <span>Turf</span> IA</div>
<div class="nav-section">Principal</div>
<a class="nav-item active" href="/dashboard"><span class="icon">📊</span> Dashboard</a>
<a class="nav-item" href="#races"><span class="icon">🏁</span> Courses du jour</a>
<a class="nav-item" href="#predictions"><span class="icon">🧠</span> Prédictions</a>
<a class="nav-item" id="nav-value-bets" href="#value-bets"><span class="icon">💎</span> Value Bets</a>
<div class="nav-section">Analyse</div>
<a class="nav-item" id="nav-history" href="#history"><span class="icon">📅</span> Historique</a>
<a class="nav-item" id="nav-export" href="#export"><span class="icon">📤</span> Export CSV</a>
<a class="nav-item" id="nav-api" href="/docs/api"><span class="icon"></span> API Docs</a>
<div class="nav-section">Compte</div>
<a class="nav-item" href="/account"><span class="icon">⚙️</span> Mon compte</a>
<a class="nav-item" href="#" id="logout-btn"><span class="icon">🚪</span> Déconnexion</a>
<div class="sidebar-bottom">
<div class="user-chip">
<div class="user-avatar" id="sidebar-avatar">?</div>
<div class="user-info">
<div class="user-name" id="sidebar-name">Chargement…</div>
<div class="user-plan" id="sidebar-plan"></div>
</div>
</div>
</div>
</aside>
<!-- MAIN -->
<div class="main">
<div class="topbar">
<div class="topbar-title">Tableau de bord</div>
<div class="topbar-right">
<span class="badge" id="plan-badge"></span>
<a href="/account?tab=upgrade" class="btn btn-upgrade" id="upgrade-btn" style="display:none">⭐ Upgrader</a>
</div>
</div>
<div class="content">
<!-- Upgrade banner (shown for free users) -->
<div class="upgrade-banner" id="upgrade-banner" style="display:none">
<p>🔒 Plan <strong>Free</strong> — Vous voyez un aperçu limité. Passez à <strong>Premium</strong> pour toutes les courses, value bets et alertes Telegram.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade">Upgrader — 9,90€/mois</a>
</div>
<!-- Stats row -->
<div class="stats-row" id="stats-row">
<div class="stat-card">
<div class="stat-label">Courses analysées</div>
<div class="stat-value" id="stat-courses"></div>
<div class="stat-sub">aujourd'hui</div>
</div>
<div class="stat-card">
<div class="stat-label">Précision Top-3</div>
<div class="stat-value stat-up" id="stat-accuracy"></div>
<div class="stat-sub">30 derniers jours</div>
</div>
<div class="stat-card">
<div class="stat-label">Value bets du jour</div>
<div class="stat-value" id="stat-vb"></div>
<div class="stat-sub" id="stat-vb-sub"></div>
</div>
<div class="stat-card">
<div class="stat-label">Prochaine course</div>
<div class="stat-value stat-up" id="stat-next"></div>
<div class="stat-sub" id="stat-next-hip"></div>
</div>
</div>
<!-- Today's races -->
<div class="section-title">
🏁 Prédictions du jour
<small id="race-count-label">Chargement…</small>
</div>
<div id="races-container">
<div class="loader-row"><div class="spinner"></div> Chargement des prédictions…</div>
</div>
<!-- Locked section for free users -->
<div id="locked-section" style="display:none">
<div class="lock-wrap" style="position:relative; min-height:200px;">
<div style="filter:blur(5px)">
<div class="races-grid">
<div class="race-card"><div class="race-name">R3C5 - Quinté+</div><div class="race-meta">16 partants · Plat · 2400m</div></div>
<div class="race-card"><div class="race-name">R2C3 - Prix du Président</div><div class="race-meta">12 partants · Trot · 2100m</div></div>
<div class="race-card"><div class="race-name">R4C7 - Prix Deauville</div><div class="race-meta">10 partants · Galop · 1600m</div></div>
</div>
</div>
<div class="locked-overlay">
<div class="locked-overlay-msg">
<div style="font-size:2rem;margin-bottom:10px">🔒</div>
<h3>+120 courses cachées</h3>
<p style="color:var(--muted);font-size:.88rem;margin-bottom:14px">Passez à Premium pour débloquer toutes les courses, les value bets et les alertes Telegram.</p>
<a href="/account?tab=upgrade" class="btn btn-primary">Débloquer — 9,90€/mois</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="toast"></div>
<script>
const API = '/api/v1';
let currentUser = null;
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg; t.className = `show ${type}`;
setTimeout(() => t.className = '', 3500);
}
function getToken() {
return localStorage.getItem('turf_token');
}
async function fetchJson(url, opts = {}) {
const token = getToken();
const res = await fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }
});
if (res.status === 401) { logout(); return null; }
return res.json();
}
function logout() {
localStorage.removeItem('turf_token');
localStorage.removeItem('turf_user');
location.href = '/login';
}
document.getElementById('logout-btn').addEventListener('click', e => { e.preventDefault(); logout(); });
function setPlanUI(plan) {
const badge = document.getElementById('plan-badge');
const upgradeBtn = document.getElementById('upgrade-btn');
const upgradeBanner = document.getElementById('upgrade-banner');
const navExport = document.getElementById('nav-export');
const navApi = document.getElementById('nav-api');
badge.className = `badge badge-${plan}`;
badge.textContent = { free: 'Free', premium: 'Premium ⭐', pro: 'Pro 🚀' }[plan] || plan;
if (plan === 'free') {
upgradeBtn.style.display = '';
upgradeBanner.style.display = '';
document.getElementById('locked-section').style.display = '';
navExport.style.opacity = '.4';
navApi.style.opacity = '.4';
document.getElementById('nav-value-bets').style.opacity = '.4';
} else if (plan === 'premium') {
upgradeBtn.style.display = '';
upgradeBtn.innerHTML = '🚀 Passer Pro';
navExport.style.opacity = '.4';
navApi.style.opacity = '.4';
}
}
function renderRaceCards(predictions, plan) {
const container = document.getElementById('races-container');
if (!predictions || predictions.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="icon">🏇</div><h3>Aucune prédiction disponible</h3><p>Les prédictions d'aujourd'hui ne sont pas encore disponibles. Revenez plus tard.</p></div>`;
return;
}
// Group by race
const races = {};
predictions.forEach(p => {
const key = `${p.num_reunion}-${p.num_course}`;
if (!races[key]) races[key] = { label: p.race_label || `R${p.num_reunion}C${p.num_course}`, name: p.race_name || '', hippodrome: p.hippodrome || '', discipline: p.discipline || '', heure: p.heure || '', horses: [] };
races[key].horses.push(p);
});
const maxRaces = plan === 'free' ? 1 : 999;
const raceKeys = Object.keys(races).slice(0, maxRaces);
document.getElementById('race-count-label').textContent = `${raceKeys.length} course${raceKeys.length>1?'s':''} affichée${raceKeys.length>1?'s':''}`;
let html = '<div class="races-grid">';
raceKeys.forEach(key => {
const race = races[key];
const sorted = [...race.horses].sort((a, b) => b.ml_score - a.ml_score).slice(0, 3);
const vbCount = race.horses.filter(h => h.is_value_bet).length;
html += `<div class="race-card">
<div class="race-header">
<div>
<div class="race-name">${race.label}${race.name ? ' — ' + race.name : ''}</div>
<div class="race-meta">${race.hippodrome ? race.hippodrome + ' · ' : ''}${race.discipline || ''}</div>
</div>
<div class="race-time">${race.heure || ''}</div>
</div>
${vbCount > 0 ? `<span class="value-bet">💎 ${vbCount} value bet${vbCount>1?'s':''}</span>` : ''}
<div class="top3-row">
${sorted.map((h, i) => `<span class="horse-chip top${i+1}">${i===0?'🥇':i===1?'🥈':'🥉'} ${h.horse_name} (${h.odds ? h.odds.toFixed(1) : '—'})</span>`).join('')}
</div>
</div>`;
});
html += '</div>';
// Detailed table for first race
if (raceKeys.length > 0) {
const firstRace = races[raceKeys[0]];
const sorted = [...firstRace.horses].sort((a, b) => b.ml_score - a.ml_score);
html += `<div class="section-title" style="margin-top:8px">📋 Détail — ${firstRace.label}${firstRace.name ? ' · ' + firstRace.name : ''}</div>
<div class="race-table-wrap">
<table>
<thead><tr>
<th>#</th><th>Cheval</th><th>Cote</th><th>Prob Top-1</th><th>Prob Top-3</th><th>Score IA</th><th>Value</th>
</tr></thead>
<tbody>`;
sorted.forEach((h, i) => {
const prob1 = h.prob_top1 ? (h.prob_top1 * 100).toFixed(1) : '—';
const prob3 = h.prob_top3 ? (h.prob_top3 * 100).toFixed(1) : '—';
const score = h.ml_score ? h.ml_score.toFixed(2) : '—';
html += `<tr>
<td class="${i===0?'rank-1':i===1?'rank-2':i===2?'rank-3':''}">${i+1}</td>
<td class="horse-name">${h.horse_name || '—'}</td>
<td class="cote">${h.odds ? h.odds.toFixed(1) : '—'}</td>
<td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:${prob1}%"></div></div>${prob1}%</div></td>
<td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:${prob3}%;background:var(--blue)"></div></div>${prob3}%</div></td>
<td>${score}</td>
<td>${h.is_value_bet ? '<span class="value-bet">💎 VB</span>' : '—'}</td>
</tr>`;
});
html += '</tbody></table></div>';
}
container.innerHTML = html;
}
async function loadDashboard() {
const token = getToken();
if (!token) { location.href = '/login'; return; }
// Try loading from localStorage first
try {
const saved = JSON.parse(localStorage.getItem('turf_user') || '{}');
if (saved.firstname) {
document.getElementById('sidebar-name').textContent = `${saved.firstname} ${saved.lastname || ''}`;
document.getElementById('sidebar-avatar').textContent = saved.firstname[0].toUpperCase();
document.getElementById('sidebar-plan').textContent = (saved.plan || 'free').toUpperCase();
setPlanUI(saved.plan || 'free');
currentUser = saved;
}
} catch(_) {}
// Fetch fresh user profile
const profile = await fetchJson(`${API}/auth/me`);
if (!profile) return;
currentUser = profile.user || profile;
const plan = currentUser.plan || 'free';
document.getElementById('sidebar-name').textContent = `${currentUser.firstname || ''} ${currentUser.lastname || ''}`.trim() || currentUser.email;
document.getElementById('sidebar-avatar').textContent = (currentUser.firstname || currentUser.email || '?')[0].toUpperCase();
document.getElementById('sidebar-plan').textContent = plan.toUpperCase();
localStorage.setItem('turf_user', JSON.stringify(currentUser));
setPlanUI(plan);
// Fetch stats
const statsData = await fetchJson(`${API}/stats/summary`);
if (statsData) {
document.getElementById('stat-courses').textContent = statsData.courses_today || '—';
document.getElementById('stat-accuracy').textContent = statsData.accuracy_top3 ? statsData.accuracy_top3 + '%' : '—';
document.getElementById('stat-vb').textContent = statsData.value_bets_today ?? '—';
document.getElementById('stat-vb-sub').textContent = plan === 'free' ? '(limité)' : 'identifiés';
if (statsData.next_race_time) {
document.getElementById('stat-next').textContent = statsData.next_race_time;
document.getElementById('stat-next-hip').textContent = statsData.next_race_hippodrome || '';
}
}
// Fetch predictions
const predsData = await fetchJson(`${API}/predictions/today`);
if (predsData && predsData.predictions) {
renderRaceCards(predsData.predictions, plan);
} else {
document.getElementById('races-container').innerHTML = '<div class="empty-state"><div class="icon">🏇</div><h3>Prédictions non disponibles</h3><p>L\'API de prédictions est en cours de démarrage. Réessayez dans quelques instants.</p></div>';
}
}
loadDashboard();
</script>
</body>
</html>