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
Generated
+135
View File
@@ -29,6 +29,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -324,6 +333,20 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.1"
@@ -379,6 +402,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -428,6 +457,20 @@ dependencies = [
"typenum",
]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -1071,6 +1114,30 @@ dependencies = [
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.2.0"
@@ -1195,7 +1262,10 @@ dependencies = [
"anyhow",
"arboard",
"axum",
"chrono",
"clap",
"dashmap",
"futures-util",
"libc",
"notify",
"open",
@@ -1207,6 +1277,7 @@ dependencies = [
"sha2",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"toml",
"tower",
"tower-http",
@@ -2664,6 +2735,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
@@ -3154,12 +3236,65 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
+6
View File
@@ -40,6 +40,12 @@ tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "fs", "trace"] }
# Frontend embedding
rust-embed = "8"
# WebSocket broadcast
tokio-stream = "0.1"
futures-util = "0.3"
dashmap = "6"
# Time formatting
chrono = { version = "0.4", features = ["serde"] }
# Platform-specific — Windows
[target.'cfg(windows)'.dependencies]
+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>
+24 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
)
}