Ajout de la webapp Docker (FastAPI + HTML/JS vanilla)
- Backend FastAPI avec auth par cookie (users dans config.json) - Upload PDF drag & drop, progression en temps réel (SSE) - Identification des séries via Ollama (config URL dans config.json) - Téléchargement ICS par série + historique des traitements - Bouton vider le cache (site web + mapping LLM) - Docker Swarm ready (docker-compose.yml + Dockerfile) - Compatible iOS/Android/PC (responsive mobile-first) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
'use strict';
|
||||
|
||||
// ── Helpers API ───────────────────────────────────────────────────────────────
|
||||
async function api(method, path, body = null) {
|
||||
const isForm = body instanceof FormData;
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: (!isForm && body) ? {'Content-Type': 'application/json'} : {},
|
||||
body: body ? (isForm ? body : JSON.stringify(body)) : undefined,
|
||||
});
|
||||
if (res.status === 401) { showPage('login'); return null; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || 'Erreur serveur');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
// ── Pages ─────────────────────────────────────────────────────────────────────
|
||||
function showPage(name) {
|
||||
document.getElementById('page-login').classList.toggle('hidden', name !== 'login');
|
||||
document.getElementById('page-app').classList.toggle('hidden', name !== 'app');
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
async function checkAuth() {
|
||||
const me = await api('GET', '/api/auth/me');
|
||||
if (!me) return;
|
||||
document.getElementById('header-user').textContent = me.username;
|
||||
showPage('app');
|
||||
loadAll();
|
||||
}
|
||||
|
||||
document.getElementById('form-login').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('login-error');
|
||||
errEl.classList.add('hidden');
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
errEl.textContent = err.detail || 'Identifiants incorrects';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
document.getElementById('header-user').textContent = data.username;
|
||||
showPage('app');
|
||||
loadAll();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-logout').addEventListener('click', async () => {
|
||||
await api('POST', '/api/auth/logout');
|
||||
showPage('login');
|
||||
});
|
||||
|
||||
// ── Init globale ──────────────────────────────────────────────────────────────
|
||||
async function loadAll() {
|
||||
await Promise.all([loadConfig(), loadHistory(), loadCacheStatus()]);
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
async function loadConfig() {
|
||||
const cfg = await api('GET', '/api/config');
|
||||
if (!cfg) return;
|
||||
document.getElementById('cfg-ollama-url').textContent = cfg.ollama_url;
|
||||
document.getElementById('cfg-cluster-model').textContent = cfg.cluster_model;
|
||||
}
|
||||
|
||||
// ── Upload ────────────────────────────────────────────────────────────────────
|
||||
let selectedFiles = [];
|
||||
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', e => {
|
||||
e.preventDefault(); dropZone.classList.add('drag-over');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||||
dropZone.addEventListener('drop', e => {
|
||||
e.preventDefault(); dropZone.classList.remove('drag-over');
|
||||
addFiles(Array.from(e.dataTransfer.files).filter(f => f.name.toLowerCase().endsWith('.pdf')));
|
||||
});
|
||||
fileInput.addEventListener('change', e => {
|
||||
addFiles(Array.from(e.target.files));
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
function addFiles(newFiles) {
|
||||
const existing = new Set(selectedFiles.map(f => f.name));
|
||||
newFiles.forEach(f => { if (!existing.has(f.name)) selectedFiles.push(f); });
|
||||
renderFileList();
|
||||
}
|
||||
|
||||
function renderFileList() {
|
||||
const listEl = document.getElementById('file-list');
|
||||
const actionsEl = document.getElementById('upload-actions');
|
||||
if (!selectedFiles.length) {
|
||||
listEl.classList.add('hidden');
|
||||
actionsEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
listEl.classList.remove('hidden');
|
||||
actionsEl.classList.remove('hidden');
|
||||
listEl.innerHTML = selectedFiles.map((f, i) => `
|
||||
<div class="file-item">
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">${esc(f.name)}</span>
|
||||
<button class="file-remove" data-i="${i}" title="Retirer">✕</button>
|
||||
</div>`).join('');
|
||||
listEl.querySelectorAll('.file-remove').forEach(btn =>
|
||||
btn.addEventListener('click', () => {
|
||||
selectedFiles.splice(+btn.dataset.i, 1);
|
||||
renderFileList();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
document.getElementById('btn-clear-files').addEventListener('click', () => {
|
||||
selectedFiles = [];
|
||||
renderFileList();
|
||||
});
|
||||
|
||||
// ── Traitement ────────────────────────────────────────────────────────────────
|
||||
let currentJobId = null;
|
||||
|
||||
document.getElementById('btn-process').addEventListener('click', async () => {
|
||||
if (!selectedFiles.length) return;
|
||||
|
||||
const btn = document.getElementById('btn-process');
|
||||
const progSect = document.getElementById('section-progress');
|
||||
const resSect = document.getElementById('section-results');
|
||||
const fill = document.getElementById('progress-fill');
|
||||
const log = document.getElementById('progress-log');
|
||||
|
||||
btn.disabled = true;
|
||||
resSect.classList.add('hidden');
|
||||
progSect.classList.remove('hidden');
|
||||
log.innerHTML = '';
|
||||
fill.style.width = '3%';
|
||||
fill.classList.add('running');
|
||||
|
||||
let steps = 0;
|
||||
|
||||
// Upload
|
||||
const fd = new FormData();
|
||||
selectedFiles.forEach(f => fd.append('files', f));
|
||||
|
||||
let jobId;
|
||||
try {
|
||||
const res = await fetch('/api/process', {
|
||||
method: 'POST', credentials: 'include', body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).detail);
|
||||
jobId = (await res.json()).job_id;
|
||||
currentJobId = jobId;
|
||||
} catch (err) {
|
||||
addLog('Erreur : ' + err.message, 'error');
|
||||
fill.classList.remove('running');
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// SSE
|
||||
const es = new EventSource(`/api/progress/${jobId}`);
|
||||
es.onmessage = e => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'ping') return;
|
||||
|
||||
if (msg.type === 'progress') {
|
||||
addLog(msg.message);
|
||||
steps++;
|
||||
fill.style.width = Math.min(5 + steps * 7, 90) + '%';
|
||||
}
|
||||
|
||||
if (msg.type === 'done') {
|
||||
fill.classList.remove('running');
|
||||
fill.style.width = '100%';
|
||||
addLog('Traitement termin\u00e9 avec succ\u00e8s', 'done');
|
||||
es.close();
|
||||
renderResults(jobId, msg.series);
|
||||
loadHistory();
|
||||
loadCacheStatus();
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
if (msg.type === 'error') {
|
||||
fill.classList.remove('running');
|
||||
addLog('Erreur : ' + msg.message, 'error');
|
||||
es.close();
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
es.onerror = () => { es.close(); btn.disabled = false; };
|
||||
});
|
||||
|
||||
function addLog(msg, cls = '') {
|
||||
const log = document.getElementById('progress-log');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-line' + (cls ? ' ' + cls : '');
|
||||
div.textContent = msg;
|
||||
log.appendChild(div);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
// ── R\u00e9sultats ──────────────────────────────────────────────────────────────────
|
||||
function renderResults(jobId, series) {
|
||||
const sect = document.getElementById('section-results');
|
||||
const grid = document.getElementById('series-grid');
|
||||
sect.classList.remove('hidden');
|
||||
|
||||
grid.innerHTML = series.map(s => `
|
||||
<div class="series-card">
|
||||
<h3>${esc(s.name)}</h3>
|
||||
<span class="series-count">${s.event_count} \u00e9v\u00e9nement${s.event_count > 1 ? 's' : ''}</span>
|
||||
<a href="/api/download/${esc(jobId)}/${esc(s.filename)}"
|
||||
class="btn btn-secondary" download="${esc(s.filename)}">
|
||||
⇓ T\u00e9l\u00e9charger ICS
|
||||
</a>
|
||||
</div>`).join('');
|
||||
|
||||
// Bouton "tout t\u00e9l\u00e9charger" : t\u00e9l\u00e9charge l'un apr\u00e8s l'autre
|
||||
document.getElementById('btn-dl-all').onclick = () => {
|
||||
series.forEach((s, i) => {
|
||||
setTimeout(() => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/download/${jobId}/${encodeURIComponent(s.filename)}`;
|
||||
a.download = s.filename;
|
||||
a.click();
|
||||
}, i * 400);
|
||||
});
|
||||
};
|
||||
|
||||
sect.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// ── Historique ────────────────────────────────────────────────────────────────
|
||||
async function loadHistory() {
|
||||
const jobs = await api('GET', '/api/jobs');
|
||||
if (!jobs) return;
|
||||
const listEl = document.getElementById('history-list');
|
||||
if (!jobs.length) {
|
||||
listEl.innerHTML = '<p class="empty-msg">Aucun traitement pr\u00e9c\u00e9dent</p>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = jobs.slice(0, 15).map(j => `
|
||||
<div class="history-item" data-jobid="${esc(j.job_id)}">
|
||||
<div class="hist-header">
|
||||
<span class="hist-title">${j.series ? j.series.length : '?'} s\u00e9rie${j.series?.length > 1 ? 's' : ''}</span>
|
||||
<span class="hist-date">${fmtDate(j.created_at)}</span>
|
||||
</div>
|
||||
<div class="hist-files">${esc((j.pdf_names || []).join(', '))}</div>
|
||||
</div>`).join('');
|
||||
|
||||
listEl.querySelectorAll('.history-item').forEach(item =>
|
||||
item.addEventListener('click', async () => {
|
||||
const job = await api('GET', `/api/jobs/${item.dataset.jobid}`);
|
||||
if (job?.series) renderResults(job.job_id, job.series);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
async function loadCacheStatus() {
|
||||
const status = await api('GET', '/api/cache/status');
|
||||
if (!status) return;
|
||||
const set = (id, ok) => {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = ok ? '\u2713 En cache' : '\u2715 Non mis en cache';
|
||||
el.className = 'setting-value ' + (ok ? 'ok' : 'nok');
|
||||
};
|
||||
set('cache-website', status.website_cached);
|
||||
set('cache-series', status.series_cached);
|
||||
}
|
||||
|
||||
document.getElementById('btn-clear-cache').addEventListener('click', async () => {
|
||||
if (!confirm('Vider le cache ?\nLe prochain traitement re-scrapera le site et re-classifiera les s\u00e9ries avec l\'IA.')) return;
|
||||
const result = await api('DELETE', '/api/cache');
|
||||
if (!result) return;
|
||||
alert(result.deleted.length
|
||||
? 'Cache vid\u00e9 : ' + result.deleted.join(', ')
|
||||
: 'Cache d\u00e9j\u00e0 vide');
|
||||
loadCacheStatus();
|
||||
});
|
||||
|
||||
// ── D\u00e9marrage ──────────────────────────────────────────────────────────────────
|
||||
checkAuth();
|
||||
Reference in New Issue
Block a user