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>
This commit is contained in:
DevOps Engineer
2026-04-25 17:44:21 +02:00
parent dce1e9b744
commit 793ee82c29
11 changed files with 2963 additions and 131 deletions

335
onboarding.html Normal file
View 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>Bienvenue — Turf IA</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
--radius: 12px; --gold: #ffd600;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; }
.onboarding-wrap { width: 100%; max-width: 580px; }
.step-indicator {
display: flex; align-items: center; justify-content: center; gap: 0; margin-bottom: 36px;
}
.step-dot {
width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--border);
display: flex; align-items: center; justify-content: center; font-size: .8rem; font-weight: 700;
color: var(--muted); background: var(--dark2); transition: all .3s; flex-shrink: 0;
}
.step-dot.active { border-color: var(--green); color: var(--green); background: rgba(0,200,83,.1); }
.step-dot.done { border-color: var(--green); background: var(--green); color: #000; }
.step-line { flex: 1; height: 2px; background: var(--border); max-width: 80px; transition: background .3s; }
.step-line.done { background: var(--green); }
.step-panel { display: none; animation: fadeIn .3s ease; }
.step-panel.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.card { background: var(--dark2); border: 1px solid var(--border); border-radius: 16px; padding: 40px; }
.step-eyebrow { font-size: .8rem; color: var(--green); font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
h2 { font-size: 1.6rem; font-weight: 800; margin-bottom: 10px; }
.step-subtitle { color: var(--muted); font-size: .95rem; margin-bottom: 28px; line-height: 1.6; }
.plan-options { display: flex; flex-direction: column; gap: 12px; margin-bottom: 28px; }
.plan-option {
display: flex; align-items: center; justify-content: space-between; gap: 14px;
padding: 16px 18px; border: 2px solid var(--border); border-radius: var(--radius);
cursor: pointer; transition: all .2s; background: var(--dark3);
}
.plan-option:hover { border-color: var(--muted); }
.plan-option.selected { border-color: var(--green); background: rgba(0,200,83,.06); }
.plan-option-left { display: flex; align-items: center; gap: 14px; }
.plan-icon { font-size: 1.6rem; }
.plan-info h3 { font-size: 1rem; font-weight: 700; }
.plan-info p { font-size: .82rem; color: var(--muted); margin-top: 2px; }
.plan-price-tag { font-weight: 800; font-size: 1rem; color: var(--green); text-align: right; }
.plan-price-tag .sub { font-size: .72rem; font-weight: 400; color: var(--muted); }
.plan-radio { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--border); flex-shrink: 0; transition: all .2s; }
.plan-option.selected .plan-radio { border-color: var(--green); background: var(--green); }
.telegram-field { background: var(--dark3); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px; }
.telegram-field h3 { font-size: .95rem; font-weight: 700; margin-bottom: 6px; }
.telegram-field p { font-size: .83rem; color: var(--muted); margin-bottom: 12px; }
.telegram-input-row { display: flex; gap: 10px; }
input[type=text], input[type=email] {
flex: 1; padding: 10px 14px; background: var(--dark); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-size: .9rem; outline: none; transition: border-color .2s;
}
input[type=text]:focus, input[type=email]:focus { border-color: var(--green); }
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 12px 28px; border-radius: var(--radius); font-size: 1rem; font-weight: 700; 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(--muted); padding: 12px 20px; }
.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
.btn-full { width: 100%; justify-content: center; }
.btn-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.success-icon { font-size: 4rem; text-align: center; margin-bottom: 20px; }
.checklist { list-style: none; margin: 20px 0; }
.checklist li { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: .93rem; }
.checklist li:last-child { border-bottom: none; }
.checklist li::before { content: "✓"; background: var(--green); color: #000; width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .75rem; font-weight: 800; flex-shrink: 0; }
.first-pred-preview { background: var(--dark3); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin: 20px 0; }
.pred-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: .88rem; }
.pred-row:last-child { border-bottom: none; }
.pred-rank { font-weight: 700; color: var(--gold); width: 24px; }
.pred-name { flex: 1; font-weight: 600; }
.pred-prob { color: var(--green); font-weight: 700; }
.pred-cote { color: var(--muted); font-size: .82rem; }
footer { margin-top: 24px; color: var(--muted); font-size: .78rem; text-align: center; }
</style>
</head>
<body>
<div class="onboarding-wrap">
<!-- Step indicator -->
<div class="step-indicator">
<div class="step-dot active" id="dot-1">1</div>
<div class="step-line" id="line-1"></div>
<div class="step-dot" id="dot-2">2</div>
<div class="step-line" id="line-2"></div>
<div class="step-dot" id="dot-3">3</div>
</div>
<!-- STEP 1: Welcome + plan confirm -->
<div class="step-panel active" id="step-1">
<div class="card">
<div class="step-eyebrow">Étape 1 sur 3</div>
<h2>Bienvenue sur Turf IA 🏇</h2>
<p class="step-subtitle">Votre compte est créé. Confirmez votre plan de départ pour personnaliser votre expérience.</p>
<div class="plan-options" id="plan-options">
<div class="plan-option" data-plan="free">
<div class="plan-option-left">
<div class="plan-icon">🆓</div>
<div class="plan-info">
<h3>Free</h3>
<p>Aperçu quotidien, 1 course complète</p>
</div>
</div>
<div>
<div class="plan-price-tag">0€<div class="sub">/mois</div></div>
</div>
<div class="plan-radio"></div>
</div>
<div class="plan-option" data-plan="premium">
<div class="plan-option-left">
<div class="plan-icon"></div>
<div class="plan-info">
<h3>Premium</h3>
<p>Toutes les courses, alertes Telegram</p>
</div>
</div>
<div>
<div class="plan-price-tag">9,90€<div class="sub">/mois</div></div>
</div>
<div class="plan-radio"></div>
</div>
<div class="plan-option" data-plan="pro">
<div class="plan-option-left">
<div class="plan-icon">🚀</div>
<div class="plan-info">
<h3>Pro</h3>
<p>API, export CSV, support prioritaire</p>
</div>
</div>
<div>
<div class="plan-price-tag">24,90€<div class="sub">/mois</div></div>
</div>
<div class="plan-radio"></div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary btn-full" id="step1-next">Continuer →</button>
</div>
</div>
</div>
<!-- STEP 2: Alerts & preferences -->
<div class="step-panel" id="step-2">
<div class="card">
<div class="step-eyebrow">Étape 2 sur 3</div>
<h2>Configurez vos alertes</h2>
<p class="step-subtitle">Recevez les meilleures opportunités directement sur votre téléphone avant chaque départ.</p>
<div class="telegram-field">
<h3>📱 Alertes Telegram</h3>
<p>Pour activer les alertes, envoyez <strong>/start</strong> à notre bot Telegram <strong>@TurfIABot</strong> et collez ci-dessous votre Chat ID.</p>
<div class="telegram-input-row">
<input type="text" id="telegram-id" placeholder="Votre Chat ID Telegram (ex: 123456789)">
<button class="btn btn-ghost" onclick="openTelegramBot()">Ouvrir le bot</button>
</div>
</div>
<div style="margin-bottom:24px">
<p style="font-size:.88rem;color:var(--muted);margin-bottom:12px">🔔 Quand recevoir les alertes :</p>
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
<input type="checkbox" id="alert-vb" checked style="width:auto;accent-color:var(--green)">
Value bets identifiés (cote sous-évaluée)
</label>
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
<input type="checkbox" id="alert-top1" checked style="width:auto;accent-color:var(--green)">
Favori IA Top-1 de chaque course
</label>
<label style="display:flex;gap:10px;align-items:center;cursor:pointer;font-size:.9rem">
<input type="checkbox" id="alert-quinte" style="width:auto;accent-color:var(--green)">
Quinté+ uniquement
</label>
</div>
<div class="btn-row">
<button class="btn btn-ghost" id="step2-skip">Passer cette étape</button>
<button class="btn btn-primary" id="step2-next">Enregistrer & continuer →</button>
</div>
</div>
</div>
<!-- STEP 3: First prediction preview -->
<div class="step-panel" id="step-3">
<div class="card">
<div class="step-eyebrow">Étape 3 sur 3</div>
<div class="success-icon">🎉</div>
<h2 style="text-align:center">Vous êtes prêt !</h2>
<p class="step-subtitle" style="text-align:center">Voici un aperçu de votre première prédiction du jour.</p>
<div class="first-pred-preview" id="first-pred">
<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION EXEMPLE — R1C1</div>
<div class="pred-row"><div class="pred-rank">🥇</div><div class="pred-name">CHARGEMENT...</div><div class="pred-prob">—%</div><div class="pred-cote"></div></div>
<div class="pred-row"><div class="pred-rank">🥈</div><div class="pred-name"></div><div class="pred-prob">—%</div><div class="pred-cote"></div></div>
<div class="pred-row"><div class="pred-rank">🥉</div><div class="pred-name"></div><div class="pred-prob">—%</div><div class="pred-cote"></div></div>
</div>
<ul class="checklist">
<li>Compte créé et configuré</li>
<li id="check-alerts">Alertes Telegram configurées</li>
<li>Dashboard accessible 24h/24</li>
</ul>
<button class="btn btn-primary btn-full" id="step3-finish">Accéder à mon dashboard →</button>
</div>
</div>
</div>
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
<script>
const API = '/api/v1';
let currentStep = 1;
let selectedPlan = 'free';
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) { location.href = '/login'; return null; }
return res.ok ? res.json() : null;
}
// Pre-select plan from user storage
(function() {
if (!getToken()) { location.href = '/login'; return; }
try {
const user = JSON.parse(localStorage.getItem('turf_user') || '{}');
selectedPlan = user.plan || 'free';
// Mark paid plans as not selectable if not purchased
document.querySelectorAll('.plan-option').forEach(el => {
if (el.dataset.plan === selectedPlan) selectPlan(selectedPlan);
});
} catch(_) { selectPlan('free'); }
if (!document.querySelector('.plan-option.selected')) selectPlan('free');
})();
function selectPlan(plan) {
selectedPlan = plan;
document.querySelectorAll('.plan-option').forEach(el => {
el.classList.toggle('selected', el.dataset.plan === plan);
});
}
document.querySelectorAll('.plan-option').forEach(el => {
el.addEventListener('click', () => selectPlan(el.dataset.plan));
});
function goToStep(n) {
currentStep = n;
document.querySelectorAll('.step-panel').forEach((el, i) => el.classList.toggle('active', i + 1 === n));
for (let i = 1; i <= 3; i++) {
const dot = document.getElementById(`dot-${i}`);
dot.classList.toggle('done', i < n);
dot.classList.toggle('active', i === n);
if (i < 3) document.getElementById(`line-${i}`)?.classList.toggle('done', i < n);
}
if (n === 3) loadFirstPrediction();
}
document.getElementById('step1-next').addEventListener('click', async () => {
// Update plan via API
await fetchJson(`${API}/auth/update-plan`, {
method: 'POST',
body: JSON.stringify({ plan: selectedPlan })
});
const user = JSON.parse(localStorage.getItem('turf_user') || '{}');
user.plan = selectedPlan;
localStorage.setItem('turf_user', JSON.stringify(user));
// If paid plan, show payment notice (will be handled in Sprint 5-6)
if (selectedPlan !== 'free') {
goToStep(2);
} else {
goToStep(2);
}
});
document.getElementById('step2-skip').addEventListener('click', () => { document.getElementById('check-alerts').textContent = 'Alertes Telegram (non configurées)'; goToStep(3); });
document.getElementById('step2-next').addEventListener('click', async () => {
const chatId = document.getElementById('telegram-id').value.trim();
const alertVb = document.getElementById('alert-vb').checked;
const alertTop1 = document.getElementById('alert-top1').checked;
const alertQ = document.getElementById('alert-quinte').checked;
if (chatId) {
await fetchJson(`${API}/auth/update-preferences`, {
method: 'POST',
body: JSON.stringify({ telegram_chat_id: chatId, alert_value_bets: alertVb, alert_top1: alertTop1, alert_quinte_only: alertQ })
});
}
goToStep(3);
});
document.getElementById('step3-finish').addEventListener('click', () => {
localStorage.removeItem('turf_onboarding');
location.href = '/dashboard';
});
function openTelegramBot() {
window.open('https://t.me/TurfIABot', '_blank');
}
async function loadFirstPrediction() {
const data = await fetchJson(`${API}/predictions/today`);
if (!data || !data.predictions || data.predictions.length === 0) return;
const sorted = [...data.predictions].sort((a, b) => b.ml_score - a.ml_score).slice(0, 3);
const container = document.getElementById('first-pred');
const label = data.predictions[0]?.race_label || 'R1C1';
container.innerHTML = `<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION DU JOUR — ${label}</div>` +
sorted.map((h, i) => `<div class="pred-row">
<div class="pred-rank">${['🥇','🥈','🥉'][i]}</div>
<div class="pred-name">${h.horse_name || '—'}</div>
<div class="pred-prob">${h.prob_top3 ? (h.prob_top3*100).toFixed(1)+'%' : '—'}</div>
<div class="pred-cote">${h.odds ? h.odds.toFixed(1) : '—'}</div>
</div>`).join('');
}
</script>
</body>
</html>