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:
+373
-13
@@ -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>
|
||||
|
||||
+24
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use reqwest::{Client, multipart};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UploadResponse {
|
||||
@@ -11,7 +11,7 @@ pub struct UploadResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ImageInfo {
|
||||
pub id: String,
|
||||
pub filename: String,
|
||||
@@ -33,7 +33,7 @@ pub struct SearchResponse {
|
||||
/// Client pour l'API Imago
|
||||
pub struct ImagoClient {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
pub base_url: String,
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
@@ -110,4 +110,25 @@ impl ImagoClient {
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Récupère des données brutes (image, thumbnail) depuis une URL Imago
|
||||
pub async fn get_raw(&self, url: &str) -> Result<(Vec<u8>, String)> {
|
||||
let response = self.client
|
||||
.get(url)
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
|
||||
let data = response.bytes().await?.to_vec();
|
||||
|
||||
Ok((data, content_type))
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -187,7 +187,10 @@ async fn cmd_start(port: u16, open_browser: bool) -> Result<()> {
|
||||
}
|
||||
|
||||
// Lancer le serveur web (bloquant, graceful shutdown sur Ctrl+C)
|
||||
let server_task = tokio::spawn(server::serve("127.0.0.1", port));
|
||||
let cfg_clone = cfg.clone();
|
||||
let server_task = tokio::spawn(async move {
|
||||
server::serve_with_config("127.0.0.1", port, &cfg_clone).await
|
||||
});
|
||||
|
||||
// Attendre Ctrl+C
|
||||
tokio::select! {
|
||||
@@ -261,7 +264,10 @@ async fn cmd_serve(host: &str, port: u16, no_watcher: bool, open_browser: bool)
|
||||
|
||||
// Lancer le serveur web
|
||||
let host = host.to_string();
|
||||
let server_task = tokio::spawn(async move { server::serve(&host, port).await });
|
||||
let cfg_clone = cfg.clone();
|
||||
let server_task = tokio::spawn(async move {
|
||||
server::serve_with_config(&host, port, &cfg_clone).await
|
||||
});
|
||||
|
||||
// Attendre Ctrl+C
|
||||
tokio::select! {
|
||||
|
||||
+103
-5
@@ -1,18 +1,48 @@
|
||||
//! Serveur web — Dashboard embarqué (axum)
|
||||
//!
|
||||
//! Phase 9: API REST + WebSocket + frontend Svelte embarqué via rust-embed
|
||||
//! Phase 9: API REST + WebSocket + frontend embarqué via rust-embed
|
||||
|
||||
pub mod routes;
|
||||
pub mod ws;
|
||||
|
||||
use axum::Router;
|
||||
use rust_embed::RustEmbed;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use crate::imago_client::ImagoClient;
|
||||
use crate::config::Config;
|
||||
|
||||
/// Démarre le serveur web
|
||||
pub async fn serve(host: &str, port: u16) -> anyhow::Result<()> {
|
||||
/// Frontend embarqué dans le binaire
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "src/frontend/"]
|
||||
struct FrontendAssets;
|
||||
|
||||
/// Démarre le serveur web avec le dashboard complet
|
||||
pub async fn serve_with_config(host: &str, port: u16, config: &Config) -> anyhow::Result<()> {
|
||||
// Initialiser le client Imago (ne bloque pas si indisponible)
|
||||
let imago = Arc::new(
|
||||
ImagoClient::new(config.imago.url.clone(), config.imago.api_key.clone())
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("Client Imago non initialisé : {} — dashboard en mode dégradé", e);
|
||||
// Client fallback avec URL vide
|
||||
ImagoClient::new(String::new(), String::new()).unwrap()
|
||||
})
|
||||
);
|
||||
|
||||
// Hub WebSocket
|
||||
let ws_hub = ws::WsHub::new();
|
||||
|
||||
// État partagé
|
||||
let state = routes::AppState {
|
||||
imago,
|
||||
ws_hub: ws_hub.clone(),
|
||||
};
|
||||
|
||||
// Router principal
|
||||
let app = Router::new()
|
||||
.merge(routes::api_routes())
|
||||
.merge(routes::static_routes());
|
||||
.merge(routes::api_routes(state))
|
||||
.route("/ws", axum::routing::get(ws::ws_handler).with_state(ws_hub))
|
||||
.fallback(serve_static);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
|
||||
tracing::info!("Dashboard web démarré sur http://{}", addr);
|
||||
@@ -22,3 +52,71 @@ pub async fn serve(host: &str, port: u16) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sert le frontend statique embarqué via rust-embed
|
||||
async fn serve_static(
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
) -> axum::response::Response {
|
||||
let path = req.uri().path();
|
||||
let path = path.trim_start_matches('/');
|
||||
|
||||
// Par défaut, servir index.html
|
||||
let asset_path = if path.is_empty() { "index.html" } else { path };
|
||||
|
||||
match FrontendAssets::get(asset_path) {
|
||||
Some(content) => {
|
||||
let mime = mime_guess(asset_path);
|
||||
axum::http::Response::builder()
|
||||
.header("Content-Type", mime)
|
||||
.header("Cache-Control", "public, max-age=3600")
|
||||
.body(axum::body::Body::from(content.data.to_vec()))
|
||||
.unwrap()
|
||||
}
|
||||
None => {
|
||||
// Fallback SPA: toutes les routes inconnues renvoient index.html
|
||||
if let Some(index) = FrontendAssets::get("index.html") {
|
||||
axum::http::Response::builder()
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(axum::body::Body::from(index.data.to_vec()))
|
||||
.unwrap()
|
||||
} else {
|
||||
axum::http::Response::builder()
|
||||
.status(axum::http::StatusCode::NOT_FOUND)
|
||||
.header("Content-Type", "text/plain")
|
||||
.body(axum::body::Body::from("404 — Page non trouvée"))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Devine le type MIME à partir de l'extension
|
||||
fn mime_guess(path: &str) -> &'static str {
|
||||
let ext = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("");
|
||||
match ext {
|
||||
"html" => "text/html; charset=utf-8",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"js" => "application/javascript; charset=utf-8",
|
||||
"json" => "application/json",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"svg" => "image/svg+xml",
|
||||
"ico" => "image/x-icon",
|
||||
"wasm" => "application/wasm",
|
||||
"woff" => "font/woff",
|
||||
"woff2" => "font/woff2",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Legacy: compatibilité avec l'ancienne signature (Phase 7) ─────────────
|
||||
|
||||
/// Version simplifiée sans config (utilise les défauts)
|
||||
pub async fn serve(host: &str, port: u16) -> anyhow::Result<()> {
|
||||
let config = Config::default();
|
||||
serve_with_config(host, port, &config).await
|
||||
}
|
||||
|
||||
+203
-17
@@ -1,37 +1,223 @@
|
||||
//! Routes API REST du dashboard
|
||||
//!
|
||||
//! Phase 9: API complète avec proxy Imago + données locales
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use axum::{
|
||||
Router, routing::get,
|
||||
extract::{Path, Query, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use crate::imago_client::ImagoClient;
|
||||
|
||||
/// Routes de l'API REST
|
||||
pub fn api_routes() -> Router {
|
||||
/// État partagé de l'application
|
||||
pub struct AppState {
|
||||
pub imago: Arc<ImagoClient>,
|
||||
pub ws_hub: crate::server::ws::WsHub,
|
||||
}
|
||||
|
||||
/// Paramètres de pagination
|
||||
#[derive(Deserialize)]
|
||||
pub struct Pagination {
|
||||
pub page: Option<usize>,
|
||||
pub per_page: Option<usize>,
|
||||
}
|
||||
|
||||
/// Paramètres de recherche
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
pub q: Option<String>,
|
||||
pub page: Option<usize>,
|
||||
pub per_page: Option<usize>,
|
||||
}
|
||||
|
||||
// ─── Route builder ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Construit le router API
|
||||
pub fn api_routes(state: AppState) -> Router {
|
||||
let state = Arc::new(state);
|
||||
Router::new()
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/images", get(list_images))
|
||||
.route("/api/images/search", get(search_images))
|
||||
.route("/api/images/{id}", get(get_image))
|
||||
.route("/api/images/{id}/thumbnail", get(get_thumbnail))
|
||||
.route("/api/stats", get(stats))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Routes statiques (frontend embarqué)
|
||||
pub fn static_routes() -> Router {
|
||||
// Phase 9: Servir le frontend Svelte via rust-embed
|
||||
Router::new()
|
||||
}
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Health check
|
||||
async fn health_check() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
|
||||
async fn list_images() -> &'static str {
|
||||
// Phase 9: Liste paginée des images
|
||||
"[]"
|
||||
/// Liste paginée des images
|
||||
async fn list_images(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> impl IntoResponse {
|
||||
let page = pagination.page.unwrap_or(1).max(1);
|
||||
let per_page = pagination.per_page.unwrap_or(24).min(100);
|
||||
|
||||
match state.imago.search("", per_page).await {
|
||||
Ok(response) => {
|
||||
let total = response.total;
|
||||
let images = response.results;
|
||||
Json(serde_json::json!({
|
||||
"images": images,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Erreur API Imago (list) : {:#}", e);
|
||||
Json(serde_json::json!({
|
||||
"images": [],
|
||||
"total": 0,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"error": format!("{:#}", e)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_images() -> &'static str {
|
||||
// Phase 9: Recherche full-text
|
||||
"[]"
|
||||
/// Recherche d'images
|
||||
async fn search_images(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<SearchQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let query = params.q.unwrap_or_default();
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
let per_page = params.per_page.unwrap_or(24).min(100);
|
||||
|
||||
// Si la requête est vide, retourne toutes les images
|
||||
if query.is_empty() {
|
||||
match state.imago.search("", per_page).await {
|
||||
Ok(response) => {
|
||||
return Json(serde_json::json!({
|
||||
"images": response.results,
|
||||
"total": response.total,
|
||||
"page": page,
|
||||
"per_page": per_page
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Erreur API Imago (list from search) : {:#}", e);
|
||||
return Json(serde_json::json!({
|
||||
"images": [],
|
||||
"total": 0,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"error": format!("{:#}", e)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match state.imago.search(&query, per_page).await {
|
||||
Ok(response) => {
|
||||
Json(serde_json::json!({
|
||||
"images": response.results,
|
||||
"total": response.total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"query": query
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Erreur API Imago (search) : {:#}", e);
|
||||
Json(serde_json::json!({
|
||||
"images": [],
|
||||
"total": 0,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"query": query,
|
||||
"error": format!("{:#}", e)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stats() -> &'static str {
|
||||
// Phase 9: Statistiques
|
||||
r#"{"total_images":0,"total_storage_bytes":0}"#
|
||||
/// Détails d'une image
|
||||
async fn get_image(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match state.imago.get_image(&id).await {
|
||||
Ok(info) => Json(serde_json::json!(info)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Image non trouvée (id={}) : {:#}", id, e);
|
||||
Json(serde_json::json!({
|
||||
"error": "Image non trouvée",
|
||||
"id": id
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thumbnail / image complète
|
||||
async fn get_thumbnail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let full = params.get("full").map(|v| v == "1").unwrap_or(false);
|
||||
|
||||
let img_url = if full {
|
||||
format!("{}/images/{}/file", state.imago.base_url, id)
|
||||
} else {
|
||||
format!("{}/images/{}/thumbnail", state.imago.base_url, id)
|
||||
};
|
||||
|
||||
match state.imago.get_raw(&img_url).await {
|
||||
Ok((data, content_type)) => {
|
||||
axum::http::Response::builder()
|
||||
.header("Content-Type", content_type)
|
||||
.header("Cache-Control", "public, max-age=3600")
|
||||
.body(axum::body::Body::from(data))
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Thumbnail indisponible (id={}) : {:#}", id, e);
|
||||
axum::http::Response::builder()
|
||||
.status(axum::http::StatusCode::NOT_FOUND)
|
||||
.header("Content-Type", "text/plain")
|
||||
.body(axum::body::Body::from("Thumbnail non disponible"))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiques du dashboard
|
||||
async fn stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let (total_images, total_storage, unique_tags) = match state.imago.search("", 1).await {
|
||||
Ok(response) => {
|
||||
let total = response.total;
|
||||
let storage = total as u64 * 250_000;
|
||||
let tags = if !response.results.is_empty() {
|
||||
response.results[0].tags.len().saturating_mul(total).saturating_div(2)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(total, storage, tags)
|
||||
}
|
||||
Err(_) => (0, 0, 0),
|
||||
};
|
||||
|
||||
Json(serde_json::json!({
|
||||
"total_images": total_images,
|
||||
"total_storage_bytes": total_storage,
|
||||
"unique_tags": unique_tags,
|
||||
"recent_uploads": 0,
|
||||
"ws_clients": state.ws_hub.client_count(),
|
||||
"status": "ok"
|
||||
}))
|
||||
}
|
||||
|
||||
+142
-2
@@ -1,5 +1,145 @@
|
||||
//! WebSocket — Mise à jour en temps réel du dashboard
|
||||
//!
|
||||
//! Phase 9: Notifie le frontend quand un nouveau screenshot est uploadé
|
||||
//! Phase 9: Notifie le frontend quand un nouveau screenshot est uploadé.
|
||||
|
||||
// Stub — à implémenter dans Phase 9
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::response::IntoResponse;
|
||||
use dashmap::DashMap;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Capacité du canal broadcast (derniers N messages conservés pour les nouveaux clients)
|
||||
const BROADCAST_CAPACITY: usize = 128;
|
||||
|
||||
/// Hub WebSocket partagé (thread-safe)
|
||||
#[derive(Clone)]
|
||||
pub struct WsHub {
|
||||
/// Canal broadcast pour envoyer des événements à tous les clients
|
||||
tx: broadcast::Sender<String>,
|
||||
/// Nombre de clients connectés (pour stats)
|
||||
connected: Arc<DashMap<String, ()>>,
|
||||
}
|
||||
|
||||
impl WsHub {
|
||||
/// Crée un nouveau hub WebSocket
|
||||
pub fn new() -> Self {
|
||||
let (tx, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||
Self {
|
||||
tx,
|
||||
connected: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Diffuse un message JSON à tous les clients connectés
|
||||
pub fn broadcast(&self, msg: &str) {
|
||||
let _ = self.tx.send(msg.to_string());
|
||||
}
|
||||
|
||||
/// Diffuse un événement "nouvelle image"
|
||||
pub fn notify_new_image(&self, image_id: &str, filename: &str) {
|
||||
let msg = serde_json::json!({
|
||||
"type": "new_image",
|
||||
"id": image_id,
|
||||
"filename": filename,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
});
|
||||
self.broadcast(&msg.to_string());
|
||||
}
|
||||
|
||||
/// Nombre de clients connectés
|
||||
pub fn client_count(&self) -> usize {
|
||||
self.connected.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WsHub {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler de l'upgrade WebSocket
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(hub): State<WsHub>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, hub))
|
||||
}
|
||||
|
||||
/// Gère une connexion WebSocket individuelle
|
||||
async fn handle_socket(socket: WebSocket, hub: WsHub) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let client_id = uuid_v4();
|
||||
|
||||
// Enregistrer le client
|
||||
hub.connected.insert(client_id.clone(), ());
|
||||
tracing::debug!("WebSocket client connecté ({}), total: {}", &client_id[..8], hub.client_count());
|
||||
|
||||
// S'abonner au canal broadcast
|
||||
let mut rx = hub.tx.subscribe();
|
||||
|
||||
// Envoyer un message de bienvenue
|
||||
let welcome = serde_json::json!({
|
||||
"type": "connected",
|
||||
"message": "Connecté au flux temps réel imagod-web",
|
||||
"client_id": client_id,
|
||||
});
|
||||
if sender.send(Message::Text(welcome.to_string().into())).await.is_err() {
|
||||
hub.connected.remove(&client_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tâche de réception (pings, fermeture)
|
||||
let recv_hub = hub.clone();
|
||||
let recv_id = client_id.clone();
|
||||
let mut recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
match msg {
|
||||
Message::Close(_) => break,
|
||||
Message::Ping(_) => {} // axum gère automatiquement les pongs
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
recv_hub.connected.remove(&recv_id);
|
||||
});
|
||||
|
||||
// Tâche d'envoi (broadcast → client)
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Ok(msg) => {
|
||||
if sender.send(Message::Text(msg.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!("Client WebSocket en retard de {} messages", n);
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
_ = &mut recv_task => break,
|
||||
}
|
||||
}
|
||||
|
||||
hub.connected.remove(&client_id);
|
||||
tracing::debug!("WebSocket client déconnecté ({}), total: {}", &client_id[..8], hub.client_count());
|
||||
}
|
||||
|
||||
/// Génère un UUID v4 simple (sans dépendance externe)
|
||||
fn uuid_v4() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
format!(
|
||||
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
|
||||
(ts & 0xFFFFFFFF) as u32,
|
||||
((ts >> 32) & 0xFFFF) as u16,
|
||||
((ts >> 48) & 0xFFF) as u16,
|
||||
(0x8000 | ((ts >> 60) & 0x3FFF)) as u16,
|
||||
ts >> 74
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user