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:
sylvain
2026-03-08 13:08:18 +01:00
parent 325d676ccf
commit d4067e9105
10 changed files with 1478 additions and 0 deletions
+315
View File
@@ -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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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">&#128196;</span>
<span class="file-name">${esc(f.name)}</span>
<button class="file-remove" data-i="${i}" title="Retirer">&#10005;</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)}">
&#8659; 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();
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Planning &#8594; ICS</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- ── Page Login ─────────────────────────────────────────── -->
<div id="page-login" class="page">
<div class="login-card">
<div class="login-logo">&#9835;</div>
<h1>Planning &#8594; ICS</h1>
<p class="subtitle">Op&#233;ra Orchestre National Montpellier</p>
<form id="form-login">
<div class="field">
<label for="username">Utilisateur</label>
<input type="text" id="username" autocomplete="username" required>
</div>
<div class="field">
<label for="password">Mot de passe</label>
<input type="password" id="password" autocomplete="current-password" required>
</div>
<div id="login-error" class="error-msg hidden"></div>
<button type="submit" class="btn btn-primary btn-full">Se connecter</button>
</form>
</div>
</div>
<!-- ── Page App ───────────────────────────────────────────── -->
<div id="page-app" class="page hidden">
<header>
<div class="header-left">
<span class="header-icon">&#9835;</span>
<span class="header-title">Planning &#8594; ICS</span>
</div>
<div class="header-right">
<span id="header-user"></span>
<button id="btn-logout" class="btn btn-ghost-white">D&#233;connexion</button>
</div>
</header>
<main>
<!-- Upload -->
<section class="card">
<h2>Importer des plannings PDF</h2>
<div id="drop-zone" class="drop-zone">
<div class="drop-content">
<div class="drop-icon">&#128196;</div>
<p>Glissez vos PDFs ici</p>
<p class="drop-hint">ou</p>
<label for="file-input" class="btn btn-secondary">Choisir des fichiers</label>
<input type="file" id="file-input" accept=".pdf" multiple hidden>
</div>
</div>
<div id="file-list" class="file-list hidden"></div>
<div id="upload-actions" class="upload-actions hidden">
<button id="btn-process" class="btn btn-primary">Convertir en ICS</button>
<button id="btn-clear-files" class="btn btn-ghost">Effacer</button>
</div>
</section>
<!-- Progression -->
<section id="section-progress" class="card hidden">
<h2>Traitement en cours&#8230;</h2>
<div class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
<div id="progress-log" class="progress-log"></div>
</section>
<!-- R&#233;sultats -->
<section id="section-results" class="card hidden">
<div class="section-header">
<h2>S&#233;ries g&#233;n&#233;r&#233;es</h2>
<button id="btn-dl-all" class="btn btn-secondary btn-sm">T&#233;l&#233;charger tout</button>
</div>
<div id="series-grid" class="series-grid"></div>
</section>
<!-- Historique -->
<section class="card">
<h2>Historique</h2>
<div id="history-list"><p class="empty-msg">Aucun traitement pr&#233;c&#233;dent</p></div>
</section>
<!-- Param&#232;tres -->
<section class="card">
<h2>Param&#232;tres</h2>
<div class="settings-grid">
<div class="setting-item">
<div class="setting-label">Serveur Ollama</div>
<div id="cfg-ollama-url" class="setting-value">&#8212;</div>
</div>
<div class="setting-item">
<div class="setting-label">Mod&#232;le clustering</div>
<div id="cfg-cluster-model" class="setting-value">&#8212;</div>
</div>
<div class="setting-item">
<div class="setting-label">Cache site web</div>
<div id="cache-website" class="setting-value">&#8212;</div>
</div>
<div class="setting-item">
<div class="setting-label">Cache s&#233;ries LLM</div>
<div id="cache-series" class="setting-value">&#8212;</div>
</div>
</div>
<button id="btn-clear-cache" class="btn btn-danger">Vider le cache</button>
</section>
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
/* ── Variables ───────────────────────────────────────────── */
:root {
--bg: #f4f5f7;
--surface: #ffffff;
--border: #e1e4e8;
--text: #1a1a2e;
--text-sub: #6b7280;
--primary: #1a237e;
--primary-h: #283593;
--accent: #3949ab;
--success: #1b5e20;
--success-bg:#e8f5e9;
--danger: #b71c1c;
--danger-bg: #ffebee;
--shadow: 0 1px 4px rgba(0,0,0,.08), 0 4px 16px rgba(0,0,0,.06);
--radius: 14px;
--radius-sm: 8px;
}
/* ── Reset ───────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh; font-size: 15px; }
.hidden { display: none !important; }
/* ── Buttons ─────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 18px; border-radius: var(--radius-sm);
border: none; cursor: pointer; font-size: 14px; font-weight: 600;
transition: opacity .15s, transform .1s, background .15s;
white-space: nowrap; text-decoration: none;
}
.btn:hover { opacity: .88; }
.btn:active { transform: scale(.97); }
.btn:disabled{ opacity: .45; cursor: not-allowed; pointer-events: none; }
.btn-sm { padding: 6px 13px; font-size: 12px; }
.btn-full { width: 100%; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-h); opacity: 1; }
.btn-secondary { background: var(--surface); color: var(--primary);
border: 1.5px solid var(--primary); }
.btn-ghost { background: transparent; color: var(--text-sub); }
.btn-ghost:hover { background: var(--border); }
.btn-ghost-white { background: transparent; color: rgba(255,255,255,.85);
border: 1px solid rgba(255,255,255,.3); }
.btn-ghost-white:hover { background: rgba(255,255,255,.15); opacity: 1; }
.btn-danger { background: var(--danger-bg); color: var(--danger);
border: 1.5px solid #ef9a9a; }
/* ── Login ───────────────────────────────────────────────── */
#page-login {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(145deg, #0d1757 0%, #1a237e 60%, #283593 100%);
padding: 16px;
}
.login-card {
background: var(--surface); border-radius: var(--radius);
padding: 44px 36px; width: 100%; max-width: 380px;
box-shadow: 0 24px 64px rgba(0,0,0,.35); text-align: center;
}
.login-logo { font-size: 44px; margin-bottom: 14px; }
.login-card h1 { font-size: 22px; margin-bottom: 4px; }
.subtitle { color: var(--text-sub); font-size: 13px; margin-bottom: 32px; }
.field { text-align: left; margin-bottom: 16px; }
.field label { display: block; font-size: 12px; font-weight: 700;
color: var(--text-sub); margin-bottom: 5px; text-transform: uppercase;
letter-spacing: .4px; }
.field input {
width: 100%; padding: 11px 13px;
border: 1.5px solid var(--border); border-radius: var(--radius-sm);
font-size: 15px; transition: border-color .2s;
}
.field input:focus { outline: none; border-color: var(--accent); }
.error-msg { background: var(--danger-bg); color: var(--danger);
padding: 10px 14px; border-radius: var(--radius-sm);
font-size: 13px; margin-bottom: 12px; text-align: left; }
.login-card .btn { margin-top: 6px; }
/* ── Header ──────────────────────────────────────────────── */
header {
background: var(--primary); color: #fff;
padding: 0 20px; height: 56px;
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,.25);
}
.header-left { display: flex; align-items: center; gap: 10px; }
.header-icon { font-size: 20px; }
.header-title { font-size: 17px; font-weight: 700; letter-spacing: -.2px; }
.header-right { display: flex; align-items: center; gap: 10px; }
#header-user { font-size: 13px; opacity: .75; }
/* ── Main layout ─────────────────────────────────────────── */
main { max-width: 820px; margin: 0 auto; padding: 20px 14px;
display: flex; flex-direction: column; gap: 18px; }
/* ── Cards ───────────────────────────────────────────────── */
.card { background: var(--surface); border-radius: var(--radius);
padding: 22px; box-shadow: var(--shadow); }
.card h2 { font-size: 16px; font-weight: 700; margin-bottom: 18px; }
.section-header { display: flex; align-items: center;
justify-content: space-between; margin-bottom: 18px; }
.section-header h2 { margin-bottom: 0; }
/* ── Drop zone ───────────────────────────────────────────── */
.drop-zone {
border: 2px dashed var(--border); border-radius: var(--radius);
padding: 36px 20px; text-align: center; cursor: pointer;
background: #fafbff; transition: border-color .2s, background .2s;
}
.drop-zone.drag-over { border-color: var(--accent); background: #eef0fb; }
.drop-content { pointer-events: none; }
.drop-icon { font-size: 32px; display: block; margin-bottom: 10px; }
.drop-zone p { color: var(--text-sub); font-size: 13px; margin-bottom: 6px; }
.drop-hint { font-size: 11px; margin: 4px 0 12px; }
/* ── File list ───────────────────────────────────────────── */
.file-list { margin-top: 14px; display: flex; flex-direction: column; gap: 7px; }
.file-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 13px; background: #f0f1f8; border-radius: var(--radius-sm);
font-size: 13px;
}
.file-icon { font-size: 16px; flex-shrink: 0; }
.file-name { flex: 1; font-weight: 500; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.file-remove { border: none; background: none; cursor: pointer;
color: var(--text-sub); font-size: 14px; padding: 0; flex-shrink: 0; }
.upload-actions { margin-top: 14px; display: flex; gap: 10px; }
/* ── Progress ────────────────────────────────────────────── */
.progress-bar { height: 7px; background: var(--border); border-radius: 4px;
overflow: hidden; margin-bottom: 14px; }
.progress-fill { height: 100%; background: var(--accent); border-radius: 4px;
width: 0%; transition: width .4s ease; }
.progress-fill.running { animation: shimmer 1.8s infinite; }
@keyframes shimmer {
0% { opacity: 1; }
50% { opacity: .65; }
100% { opacity: 1; }
}
.progress-log {
background: #f7f8fc; border-radius: var(--radius-sm);
padding: 11px 14px; max-height: 200px; overflow-y: auto;
font-size: 12px; font-family: 'SF Mono', 'Consolas', monospace;
color: var(--text-sub); display: flex; flex-direction: column; gap: 3px;
}
.log-line { line-height: 1.45; }
.log-line.done { color: var(--success); font-weight: 700; }
.log-line.error { color: var(--danger); font-weight: 700; }
/* ── Series grid ─────────────────────────────────────────── */
.series-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px,1fr)); gap: 11px; }
.series-card {
background: #f7f8ff; border: 1.5px solid #c5cae9;
border-radius: var(--radius-sm); padding: 15px;
display: flex; flex-direction: column; gap: 7px;
}
.series-card h3 { font-size: 13px; font-weight: 700; color: var(--primary);
line-height: 1.35; }
.series-count { font-size: 11px; color: var(--text-sub); }
.series-card .btn { margin-top: auto; font-size: 12px; padding: 7px 12px; }
/* ── History ─────────────────────────────────────────────── */
#history-list { display: flex; flex-direction: column; gap: 9px; }
.history-item {
padding: 13px 15px; border: 1px solid var(--border);
border-radius: var(--radius-sm); cursor: pointer; transition: background .15s;
}
.history-item:hover { background: #f4f5ff; }
.hist-header { display: flex; justify-content: space-between;
align-items: baseline; margin-bottom: 4px; }
.hist-title { font-size: 13px; font-weight: 700; }
.hist-date { font-size: 11px; color: var(--text-sub); }
.hist-files { font-size: 11px; color: var(--text-sub); }
.empty-msg { color: var(--text-sub); font-size: 13px;
text-align: center; padding: 18px 0; }
/* ── Settings ────────────────────────────────────────────── */
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }
.setting-item { padding: 11px 13px; background: #f7f8fc; border-radius: var(--radius-sm); }
.setting-label { font-size: 11px; font-weight: 700; color: var(--text-sub);
text-transform: uppercase; letter-spacing: .5px; margin-bottom: 3px; }
.setting-value { font-size: 12px; font-family: monospace; word-break: break-all; }
.setting-value.ok { color: var(--success); }
.setting-value.nok { color: var(--danger); }
/* ── Responsive ──────────────────────────────────────────── */
@media (max-width: 580px) {
.login-card { padding: 32px 22px; }
.card { padding: 16px; }
.series-grid { grid-template-columns: 1fr 1fr; }
.settings-grid { grid-template-columns: 1fr; }
.header-title { font-size: 14px; }
}
@media (max-width: 360px) {
.series-grid { grid-template-columns: 1fr; }
}