feat: Phase 9 — Dashboard web embarqué

- Frontend vanilla JS (18.9KB) : dark theme, gallery grid, search, stats, WebSocket
- rust-embed : frontend servi depuis le binaire (zéro dépendance externe)
- API REST fonctionnelle : /api/health, /api/images, /api/search, /api/images/:id, /api/stats
- WebSocket hub (dashmap + broadcast) : notifications temps réel
- ImagoClient: méthode get_raw() pour proxy thumbnails, Serialize sur ImageInfo
- MIME guess pour servir CSS/JS/images avec bons headers
- Fallback SPA : routes inconnues → index.html
- Dépendances : tokio-stream, futures-util, dashmap, chrono
- Testé : health OK, stats JSON, dashboard 18.9KB HTML, WS upgrade 400 (HTTP)

0 errors, 0 warnings (cargo check + build --release)
This commit is contained in:
2026-06-22 17:05:49 -04:00
parent b680c0fff7
commit e92da02067
8 changed files with 994 additions and 42 deletions
+373 -13
View File
@@ -5,34 +5,394 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Imago Dashboard</title>
<style>
/* Phase 9: Dashboard Svelte — ici juste un placeholder */
:root {
--bg: #0d1117;
--bg-card: #161b22;
--bg-hover: #1c2129;
--border: #30363d;
--fg: #c9d1d9;
--fg-dim: #8b949e;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--radius: 8px;
--shadow: 0 4px 24px rgba(0,0,0,0.3);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
line-height: 1.5;
}
.placeholder {
text-align: center;
padding: 2rem;
/* ─── Header ─── */
.header {
position: sticky; top: 0; z-index: 100;
background: rgba(13,17,23,0.92); backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex; align-items: center; gap: 16px;
flex-wrap: wrap;
}
.header .logo { font-size: 20px; font-weight: 700; color: var(--accent); }
.header .logo span { font-weight: 400; color: var(--fg-dim); margin-left: 4px; }
.ws-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.ws-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
.ws-dot.off { background: var(--red); }
.ws-status { font-size: 12px; color: var(--fg-dim); display: flex; align-items: center; gap: 6px; }
/* ─── Search ─── */
.search-bar { display: flex; gap: 8px; flex: 1; min-width: 200px; }
.search-bar input {
flex: 1; padding: 8px 14px; border-radius: var(--radius);
background: var(--bg-card); border: 1px solid var(--border);
color: var(--fg); font-size: 14px; outline: none; min-width: 0;
}
.search-bar input:focus { border-color: var(--accent); }
.search-bar input::placeholder { color: var(--fg-dim); }
.btn {
padding: 8px 16px; border-radius: var(--radius); border: 1px solid var(--border);
background: var(--bg-card); color: var(--fg); cursor: pointer;
font-size: 13px; font-weight: 500; white-space: nowrap;
transition: all 0.15s;
}
.btn:hover { background: var(--bg-hover); border-color: var(--accent); color: var(--accent-hover); }
.btn.accent { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn.accent:hover { background: var(--accent-hover); }
/* ─── Layout ─── */
.main { max-width: 1400px; margin: 0 auto; padding: 24px; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px 20px;
position: relative; overflow: hidden;
}
.stat-card::before {
content: ''; position: absolute; top: 0; left: 0;
width: 3px; height: 100%; background: var(--accent);
}
.stat-label { font-size: 12px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.5px; }
.stat-value { font-size: 28px; font-weight: 700; margin-top: 4px; color: var(--fg); }
/* ─── Gallery ─── */
.section-title { font-size: 16px; font-weight: 600; margin: 24px 0 12px; color: var(--fg-dim); }
.gallery {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.gallery.empty { display: flex; flex-direction: column; align-items: center; padding: 48px 24px; }
.gallery.empty p { color: var(--fg-dim); margin-top: 8px; }
.gallery.empty .icon { font-size: 48px; opacity: 0.35; }
.card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden; cursor: pointer;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.card:hover { transform: translateY(-2px); border-color: var(--accent); box-shadow: var(--shadow); }
.card-thumb {
width: 100%; aspect-ratio: 16/10; object-fit: cover;
display: block; background: #0d1117;
}
.card-body { padding: 10px 12px; }
.card-filename { font-size: 13px; font-weight: 500; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-meta { font-size: 11px; color: var(--fg-dim); margin-top: 4px; display: flex; align-items: center; gap: 8px; }
.card-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.tag { font-size: 10px; padding: 2px 6px; border-radius: 12px; background: rgba(88,166,255,0.12); color: var(--accent); white-space: nowrap; }
.pagination { display: flex; gap: 8px; justify-content: center; margin-top: 24px; align-items: center; }
.pagination span { font-size: 13px; color: var(--fg-dim); }
/* ─── Modal ─── */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 200; display: none; align-items: center; justify-content: center; padding: 24px; }
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; max-width: 720px; width: 100%; max-height: 85vh;
overflow-y: auto; position: relative;
}
.modal-close { position: absolute; top: 12px; right: 16px; font-size: 24px; color: var(--fg-dim); cursor: pointer; z-index: 1; background: none; border: none; }
.modal-close:hover { color: var(--fg); }
.modal img { width: 100%; display: block; border-radius: 12px 12px 0 0; }
.modal-details { padding: 20px; }
.modal-details h2 { font-size: 18px; margin-bottom: 8px; }
.modal-details .desc { color: var(--fg-dim); font-size: 14px; line-height: 1.6; margin-bottom: 12px; }
.modal-details .ocr { background: #0d1117; border: 1px solid var(--border); border-radius: 6px; padding: 12px; font-size: 12px; color: var(--fg-dim); font-family: monospace; max-height: 120px; overflow-y: auto; white-space: pre-wrap; }
.modal-details table { width: 100%; font-size: 13px; margin-top: 12px; }
.modal-details td { padding: 4px 8px; border-bottom: 1px solid var(--border); }
.modal-details td:first-child { color: var(--fg-dim); width: 100px; }
/* ─── Spinner ─── */
.spinner { width: 24px; height: 24px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; margin: 20px auto; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ─── Empty search ─── */
.no-results { text-align: center; padding: 48px 24px; color: var(--fg-dim); }
/* ─── Responsive ─── */
@media (max-width: 640px) {
.header { padding: 10px 12px; gap: 8px; }
.header .logo { font-size: 16px; }
.main { padding: 12px; }
.gallery { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
.stats-row { grid-template-columns: repeat(2, 1fr); gap: 10px; }
}
h1 { color: var(--accent); margin-bottom: 1rem; }
p { color: #8b949e; }
</style>
</head>
<body>
<div class="placeholder">
<h1>🖼️ Imago Dashboard</h1>
<p>Dashboard web en construction — Phase 9</p>
<!-- ─── Header ─── -->
<div class="header">
<div class="logo">🖼️ Imago<span>Dashboard</span></div>
<div class="ws-status" id="ws-status">
<span class="ws-dot off" id="ws-dot"></span>
<span id="ws-label">Déconnecté</span>
</div>
<div class="search-bar">
<input type="text" id="search" placeholder="Rechercher par texte, tag, description…" autocomplete="off">
<button class="btn" id="search-btn">🔍</button>
<button class="btn" id="clear-btn" style="display:none">✕</button>
</div>
</div>
<!-- ─── Main Content ─── -->
<div class="main">
<!-- Stats -->
<div class="stats-row" id="stats-row">
<div class="stat-card"><div class="stat-label">Images</div><div class="stat-value" id="stat-images">…</div></div>
<div class="stat-card"><div class="stat-label">Stockage</div><div class="stat-value" id="stat-storage">…</div></div>
<div class="stat-card"><div class="stat-label">Tags</div><div class="stat-value" id="stat-tags">…</div></div>
<div class="stat-card"><div class="stat-label">En ligne</div><div class="stat-value" id="stat-uptime" style="color:var(--green)">●</div></div>
</div>
<!-- Gallery -->
<div class="section-title" id="gallery-title">🖼️ Captures récentes</div>
<div class="gallery" id="gallery"></div>
<div class="pagination" id="pagination"></div>
<div class="no-results" id="no-results" style="display:none">
<div style="font-size:48px;opacity:0.35">🔍</div>
<p>Aucun résultat trouvé</p>
</div>
</div>
<!-- ─── Modal ─── -->
<div class="modal-overlay" id="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal" id="modal"></div>
</div>
<script>
// ─── State ───
let currentPage = 1;
let currentQuery = '';
let ws = null;
// ─── API Helpers ───
async function api(path) {
const r = await fetch(path);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
function fmtBytes(b) {
if (!b || b === 0) return '0 B';
const u = ['B','KB','MB','GB']; let i = 0;
while (b >= 1024 && i < 3) { b /= 1024; i++; }
return `${b.toFixed(i ? 1 : 0)} ${u[i]}`;
}
function fmtDate(s) {
if (!s) return '';
const d = new Date(s);
return d.toLocaleDateString('fr-CA', { year:'numeric', month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' });
}
// ─── Load Images ───
async function loadImages(page = 1) {
currentPage = page;
const gallery = document.getElementById('gallery');
const title = document.getElementById('gallery-title');
const noRes = document.getElementById('no-results');
gallery.innerHTML = '<div class="spinner"></div>';
noRes.style.display = 'none';
title.textContent = currentQuery ? '🔍 Résultats de recherche' : '🖼️ Captures récentes';
try {
const endpoint = currentQuery
? `/api/images/search?q=${encodeURIComponent(currentQuery)}&page=${page}&per_page=24`
: `/api/images?page=${page}&per_page=24`;
const data = await api(endpoint);
renderGallery(data.images || data.results || [], data.total || 0, data.page || page);
} catch(e) {
gallery.innerHTML = `<div class="gallery empty"><div class="icon">⚠️</div><p>Impossible de charger les images : ${e.message}</p></div>`;
}
}
function renderGallery(images, total, page) {
const gallery = document.getElementById('gallery');
const pagination = document.getElementById('pagination');
const noRes = document.getElementById('no-results');
if (!images || images.length === 0) {
gallery.classList.add('empty');
if (currentQuery) {
gallery.innerHTML = '';
noRes.style.display = 'block';
} else {
gallery.innerHTML = `<div class="gallery empty"><div class="icon">📭</div><p>Aucune capture pour l'instant</p><p style="font-size:12px;margin-top:4px">Les screenshots apparaîtront ici automatiquement</p></div>`;
}
pagination.innerHTML = '';
return;
}
gallery.classList.remove('empty');
noRes.style.display = 'none';
gallery.innerHTML = images.map(img => {
const tags = (img.tags || []).slice(0, 4).map(t => `<span class="tag">${escHtml(t)}</span>`).join('');
const desc = img.description || img.ocr_text || '';
const thumb = `/api/images/${img.id}/thumbnail`;
const dims = (img.width && img.height) ? `${img.width}×${img.height}` : '';
return `
<div class="card" onclick="openImage('${img.id}')">
<img class="card-thumb" src="${thumb}" alt="${escHtml(img.filename || '')}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22220%22 height=%22138%22><rect fill=%22%23161b22%22 width=%22220%22 height=%22138%22/><text x=%2250%25%22 y=%2250%25%22 fill=%22%238b949e%22 text-anchor=%22middle%22 dy=%22.3em%22 font-size=%2212%22>Pas d'aperçu</text></svg>'">
<div class="card-body">
<div class="card-filename">${escHtml(img.filename || img.id)}</div>
<div class="card-meta">
<span>${fmtDate(img.created_at)}</span>
${dims ? `<span>${dims}</span>` : ''}
</div>
${tags ? `<div class="card-tags">${tags}</div>` : ''}
</div>
</div>`;
}).join('');
// Pagination
const totalPages = Math.ceil(total / 24);
if (totalPages > 1) {
let html = '';
html += `<button class="btn" ${page <= 1 ? 'disabled' : ''} onclick="loadImages(${page-1})">← Précédent</button>`;
html += `<span>Page ${page} / ${totalPages} (${total} images)</span>`;
html += `<button class="btn" ${page >= totalPages ? 'disabled' : ''} onclick="loadImages(${page+1})">Suivant →</button>`;
pagination.innerHTML = html;
} else {
pagination.innerHTML = `<span>${total} image${total > 1 ? 's' : ''}</span>`;
}
}
// ─── Modal ───
async function openImage(id) {
const overlay = document.getElementById('modal-overlay');
const modal = document.getElementById('modal');
overlay.classList.add('active');
modal.innerHTML = '<div class="spinner"></div>';
try {
const img = await api(`/api/images/${id}`);
const tags = (img.tags || []).map(t => `<span class="tag">${escHtml(t)}</span>`).join(' ');
modal.innerHTML = `
<button class="modal-close" onclick="closeModal()">✕</button>
<img src="/api/images/${id}/thumbnail?full=1" alt="${escHtml(img.filename)}" onerror="this.style.display='none'">
<div class="modal-details">
<h2>${escHtml(img.filename || img.id)}</h2>
${img.description ? `<p class="desc">${escHtml(img.description)}</p>` : ''}
${img.ocr_text ? `<p style="color:var(--fg-dim);font-size:12px;margin-bottom:8px;">📝 OCR détecté :</p><div class="ocr">${escHtml(img.ocr_text)}</div>` : ''}
<table>
<tr><td>ID</td><td style="font-family:monospace;font-size:11px">${img.id}</td></tr>
<tr><td>Dimensions</td><td>${img.width||'?'} × ${img.height||'?'}</td></tr>
<tr><td>Taille</td><td>${fmtBytes(img.size_bytes)}</td></tr>
<tr><td>Date</td><td>${fmtDate(img.created_at)}</td></tr>
<tr><td>Tags</td><td>${tags || '<span style="color:var(--fg-dim)">Aucun</span>'}</td></tr>
</table>
</div>`;
} catch(e) {
modal.innerHTML = `<button class="modal-close" onclick="closeModal()">✕</button><div style="padding:40px;text-align:center;color:var(--red)">Erreur : ${e.message}</div>`;
}
}
function closeModal() { document.getElementById('modal-overlay').classList.remove('active'); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
// ─── Stats ───
async function loadStats() {
try {
const stats = await api('/api/stats');
document.getElementById('stat-images').textContent = (stats.total_images || 0).toLocaleString();
document.getElementById('stat-storage').textContent = fmtBytes(stats.total_storage_bytes);
document.getElementById('stat-tags').textContent = (stats.unique_tags || 0).toLocaleString();
} catch(e) { /* ignore */ }
}
// ─── WebSocket ───
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => {
document.getElementById('ws-dot').className = 'ws-dot on';
document.getElementById('ws-label').textContent = 'Connecté · temps réel';
document.getElementById('stat-uptime').style.color = 'var(--green)';
};
ws.onclose = () => {
document.getElementById('ws-dot').className = 'ws-dot off';
document.getElementById('ws-label').textContent = 'Déconnecté';
setTimeout(connectWS, 3000);
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'new_image') {
// Option: append à la gallery sans recharger toute la page
if (currentPage === 1 && !currentQuery) loadImages(1);
loadStats();
}
} catch(_) {}
};
}
// ─── Search ───
let searchTimer;
document.getElementById('search').addEventListener('input', (e) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
currentQuery = e.target.value.trim();
document.getElementById('clear-btn').style.display = currentQuery ? 'inline-flex' : 'none';
if (currentQuery) {
loadImages(1);
}
}, 300);
});
document.getElementById('search').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
clearTimeout(searchTimer);
currentQuery = e.target.value.trim();
document.getElementById('clear-btn').style.display = currentQuery ? 'inline-flex' : 'none';
loadImages(1);
}
});
document.getElementById('search-btn').addEventListener('click', () => {
currentQuery = document.getElementById('search').value.trim();
document.getElementById('clear-btn').style.display = currentQuery ? 'inline-flex' : 'none';
loadImages(1);
});
document.getElementById('clear-btn').addEventListener('click', () => {
document.getElementById('search').value = '';
currentQuery = '';
document.getElementById('clear-btn').style.display = 'none';
loadImages(1);
});
// ─── Escape HTML ───
function escHtml(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ─── Init ───
loadImages();
loadStats();
connectWS();
setInterval(loadStats, 30000); // Refresh stats every 30s
</script>
</body>
</html>