Spaces:
Sleeping
Sleeping
| <html lang="fr"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>YELY | Tableau de bord</title> | |
| <style> | |
| :root { | |
| --bg: #0f172a; | |
| --panel: #1e293b; | |
| --panel-2: #273449; | |
| --border: #334155; | |
| --text: #e2e8f0; | |
| --muted: #94a3b8; | |
| --accent: #f59e0b; | |
| --ok-text: #6ee7b7; | |
| --ko-text: #fca5a5; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| display: flex; | |
| justify-content: center; | |
| padding: 2rem 1rem; | |
| } | |
| .app { width: 100%; max-width: 720px; } | |
| .brand { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.25rem; } | |
| .brand-left { display: flex; align-items: center; gap: 0.6rem; } | |
| .brand .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); } | |
| .brand h1 { font-size: 1.3rem; margin: 0; } | |
| .brand a { color: var(--muted); font-size: 0.85rem; text-decoration: none; border: 1px solid var(--border); padding: 0.4rem 0.7rem; border-radius: 8px; } | |
| .brand a:hover { border-color: var(--accent); color: var(--text); } | |
| .subtitle { color: var(--muted); font-size: 0.9rem; margin: 0 0 1.5rem; } | |
| .card { | |
| background: var(--panel); | |
| border: 1px solid var(--border); | |
| border-radius: 14px; | |
| padding: 1.25rem; | |
| margin-bottom: 1rem; | |
| } | |
| label { display: block; font-size: 0.85rem; color: var(--muted); margin: 0 0 0.3rem; } | |
| input { | |
| width: 100%; | |
| padding: 0.6rem 0.75rem; | |
| border-radius: 8px; | |
| border: 1px solid var(--border); | |
| background: var(--panel-2); | |
| color: var(--text); | |
| font-size: 0.95rem; | |
| } | |
| .config-row { display: flex; gap: 0.6rem; align-items: flex-end; } | |
| .config-row > div:first-child { flex: 1; } | |
| button { | |
| padding: 0.6rem 1rem; | |
| border: none; | |
| border-radius: 8px; | |
| background: var(--accent); | |
| color: #1a1200; | |
| font-size: 0.9rem; | |
| font-weight: 700; | |
| cursor: pointer; | |
| white-space: nowrap; | |
| } | |
| button:disabled { background: #4b5563; color: #9ca3af; cursor: not-allowed; } | |
| .metrics-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.8rem; } | |
| .metric { background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem; text-align: center; } | |
| .metric .value { font-size: 1.8rem; font-weight: 700; } | |
| .metric .label { color: var(--muted); font-size: 0.78rem; margin-top: 0.2rem; } | |
| .section-title { font-size: 0.85rem; color: var(--muted); margin: 0 0 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; } | |
| .bar-row { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.5rem; } | |
| .bar-row .bar-label { width: 44%; font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .bar-row .bar-track { flex: 1; background: var(--panel-2); border-radius: 6px; height: 10px; overflow: hidden; } | |
| .bar-row .bar-fill { height: 100%; background: var(--accent); } | |
| .bar-row .bar-count { width: 2.5rem; text-align: right; font-size: 0.85rem; color: var(--muted); } | |
| .bar-row.blocking .bar-fill { background: var(--ko-text); } | |
| .empty { color: var(--muted); font-size: 0.85rem; text-align: center; padding: 1rem 0; } | |
| .status { font-size: 0.8rem; color: var(--muted); text-align: center; margin-top: 0.5rem; } | |
| .failure-list { display: flex; flex-direction: column; gap: 0.8rem; } | |
| .failure-card { display: flex; gap: 0.8rem; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 0.7rem; align-items: center; } | |
| .failure-card img { width: 84px; height: 84px; object-fit: cover; border-radius: 8px; background: #000; flex-shrink: 0; } | |
| .failure-card .no-photo { width: 84px; height: 84px; border-radius: 8px; background: #000; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 0.7rem; text-align: center; } | |
| .failure-info { flex: 1; min-width: 0; } | |
| .failure-info .msg { font-size: 0.85rem; color: var(--ko-text); font-weight: 600; margin-bottom: 0.2rem; } | |
| .failure-info .meta { font-size: 0.78rem; color: var(--muted); } | |
| .failure-card a.fix-link { | |
| flex-shrink: 0; text-decoration: none; background: var(--accent); color: #1a1200; | |
| font-size: 0.82rem; font-weight: 700; padding: 0.5rem 0.8rem; border-radius: 8px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <div class="brand"> | |
| <div class="brand-left"><span class="dot"></span><h1>YELY |Tableau de bord</h1></div> | |
| <a href="index.html">← Démo /analyze</a> | |
| </div> | |
| <p class="subtitle">Suivi des performances du module IA en production (voir docs/MONITORING.md).</p> | |
| <div class="card"> | |
| <div class="config-row"> | |
| <div> | |
| <label>URL de l'API (base, sans /analyze)</label> | |
| <input type="text" id="apiUrl" placeholder="https://xxx.hf.space" /> | |
| </div> | |
| <button id="refreshBtn">Actualiser</button> | |
| </div> | |
| <div class="status" id="status"></div> | |
| </div> | |
| <div class="card"> | |
| <div class="metrics-grid"> | |
| <div class="metric"> | |
| <div class="value" id="mTotal">—</div> | |
| <div class="label">Requêtes totales</div> | |
| </div> | |
| <div class="metric"> | |
| <div class="value" id="mSuccess">—</div> | |
| <div class="label">Taux de succès</div> | |
| </div> | |
| <div class="metric"> | |
| <div class="value" id="mConfidence">—</div> | |
| <div class="label">Confiance moyenne</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="section-title">Répartition qualité image</div> | |
| <div id="qualityBars"><div class="empty">Aucune donnée pour l'instant.</div></div> | |
| </div> | |
| <div class="card"> | |
| <div class="section-title">Causes de blocage</div> | |
| <div id="blockingBars"><div class="empty">Aucune donnée pour l'instant.</div></div> | |
| </div> | |
| <div class="card"> | |
| <div class="section-title">Requêtes échouées (20 dernières)</div> | |
| <div id="failureList" class="failure-list"><div class="empty">Aucune donnée pour l'instant.</div></div> | |
| </div> | |
| </div> | |
| <script src="config.js"></script> | |
| <script> | |
| const apiUrlInput = document.getElementById('apiUrl'); | |
| const refreshBtn = document.getElementById('refreshBtn'); | |
| const statusEl = document.getElementById('status'); | |
| const mTotal = document.getElementById('mTotal'); | |
| const mSuccess = document.getElementById('mSuccess'); | |
| const mConfidence = document.getElementById('mConfidence'); | |
| const qualityBars = document.getElementById('qualityBars'); | |
| const blockingBars = document.getElementById('blockingBars'); | |
| const failureList = document.getElementById('failureList'); | |
| function defaultBaseUrl() { | |
| const stored = localStorage.getItem('yely_api_url') || window.YELY_API_URL || ''; | |
| return stored.replace(/\/analyze\/?$/, ''); | |
| } | |
| apiUrlInput.value = localStorage.getItem('yely_stats_base_url') || defaultBaseUrl(); | |
| function renderBars(container, data, variant) { | |
| container.innerHTML = ''; | |
| const items = Object.entries(data || {}); | |
| if (items.length === 0) { | |
| container.innerHTML = '<div class="empty">Aucune donnée pour l\'instant.</div>'; | |
| return; | |
| } | |
| const max = Math.max(...items.map(([, count]) => count)); | |
| for (const [label, count] of items) { | |
| const row = document.createElement('div'); | |
| row.className = 'bar-row' + (variant ? ' ' + variant : ''); | |
| const pct = max > 0 ? Math.round((count / max) * 100) : 0; | |
| row.innerHTML = ` | |
| <span class="bar-label" title="${label}">${label}</span> | |
| <span class="bar-track"><span class="bar-fill" style="width:${pct}%"></span></span> | |
| <span class="bar-count">${count}</span>`; | |
| container.appendChild(row); | |
| } | |
| } | |
| function fmtVal(v) { | |
| return (v === null || v === undefined) ? '—' : v; | |
| } | |
| function renderFailures(base, items) { | |
| failureList.innerHTML = ''; | |
| if (!items || items.length === 0) { | |
| failureList.innerHTML = '<div class="empty">Aucune requête échouée pour l\'instant.</div>'; | |
| return; | |
| } | |
| for (const item of items) { | |
| const card = document.createElement('div'); | |
| card.className = 'failure-card'; | |
| let photoHtml; | |
| if (item.photo_reference) { | |
| const photoUrl = base + '/photos/' + encodeURIComponent(item.photo_reference); | |
| photoHtml = `<img src="${photoUrl}" alt="Photo" onerror="this.outerHTML='<div class="no-photo">Photo indisponible</div>'" />`; | |
| } else { | |
| photoHtml = '<div class="no-photo">Pas de photo</div>'; | |
| } | |
| const fixHref = 'feedback.html?ref=' + encodeURIComponent(item.photo_reference || ''); | |
| card.innerHTML = ` | |
| ${photoHtml} | |
| <div class="failure-info"> | |
| <div class="msg">${item.message || 'Échec non précisé'}</div> | |
| <div class="meta"> | |
| qualité: ${fmtVal(item.image_quality)} · confiance: ${item.confidence_score != null ? Math.round(item.confidence_score * 100) + '%' : '—'}<br/> | |
| montant: ${fmtVal(item.detected_amount)} · litres: ${fmtVal(item.detected_liters)} · prix/L: ${fmtVal(item.fuel_price)} | |
| </div> | |
| </div> | |
| ${item.photo_reference ? `<a class="fix-link" href="${fixHref}">Corriger</a>` : ''}`; | |
| failureList.appendChild(card); | |
| } | |
| } | |
| async function loadMetrics() { | |
| const base = apiUrlInput.value.trim().replace(/\/+$/, ''); | |
| localStorage.setItem('yely_stats_base_url', base); | |
| if (!base) { | |
| statusEl.textContent = "Renseigne l'URL de l'API."; | |
| return; | |
| } | |
| refreshBtn.disabled = true; | |
| refreshBtn.textContent = 'Chargement…'; | |
| statusEl.textContent = 'Récupération des métriques…'; | |
| try { | |
| const [metricsRes, failuresRes] = await Promise.all([ | |
| fetch(base + '/metrics'), | |
| fetch(base + '/failures'), | |
| ]); | |
| const data = await metricsRes.json(); | |
| if (!metricsRes.ok) throw new Error('HTTP ' + metricsRes.status); | |
| mTotal.textContent = data.total_requests ?? 0; | |
| mSuccess.textContent = data.success_rate === null || data.success_rate === undefined | |
| ? '—' : Math.round(data.success_rate * 100) + '%'; | |
| mConfidence.textContent = data.avg_confidence_score === null || data.avg_confidence_score === undefined | |
| ? '—' : Math.round(data.avg_confidence_score * 100) + '%'; | |
| renderBars(qualityBars, data.image_quality_distribution); | |
| renderBars(blockingBars, data.blocking_causes, 'blocking'); | |
| if (failuresRes.ok) { | |
| renderFailures(base, await failuresRes.json()); | |
| } | |
| statusEl.textContent = 'Mis à jour à ' + new Date().toLocaleTimeString('fr-FR'); | |
| } catch (err) { | |
| statusEl.textContent = "Impossible de contacter l'API (" + err.message + ")."; | |
| } finally { | |
| refreshBtn.disabled = false; | |
| refreshBtn.textContent = 'Actualiser'; | |
| } | |
| } | |
| refreshBtn.addEventListener('click', loadMetrics); | |
| apiUrlInput.addEventListener('change', loadMetrics); | |
| loadMetrics(); | |
| setInterval(loadMetrics, 15000); | |
| </script> | |
| </body> | |
| </html> | |