mirror of
https://github.com/R0m1k3/Apiflow-gateway.git
synced 2026-10-22 22:48:12 +02:00
feat: gateway sécurisé, miroir de schéma automatique et sync incrémentale
- Schéma Hostinger recopié automatiquement depuis PG Debian (colonnes, PK, index) : plus d'init.sql maintenu à la main (corrige articles.artcentrale, mvtart.no_id et les 12 tables manquantes) - Stratégies : full transactionnel (DELETE + INSERT, garde-fou de volume) et window (signature mensuelle + 10 derniers jours) pour les grosses tables - Attente de la fin de la sync Debian (_run), pg_try_advisory_lock, cron 07:30 Europe/Paris, force honoré, alertes webhook - Dashboard : basic auth, CSP sans script inline, plus d'innerHTML, port 3002 non publié (accès via Nginx Proxy Manager) - Secrets hors du compose (.env), frps épinglé 0.71.0 avec TLS forcé, allowPorts et additionalScopes - Suppression de sync.sh, tables.js, utils.js, init.sql ; README de déploiement L'ancien mot de passe de la base `post` figure dans l'historique git : il doit être changé sur le serveur (voir README). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8575b9ff2
commit
ea084034b9
26 files changed
+2767
-922
No files matched your search
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
// Dashboard sync Hostinger — toutes les données dynamiques passent par textContent (pas d'innerHTML).
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
function el(tag, opts = {}, children = []) {
|
||||
const e = document.createElement(tag);
|
||||
if (opts.className) e.className = opts.className;
|
||||
if (opts.text != null) e.textContent = String(opts.text);
|
||||
if (opts.title) e.title = String(opts.title);
|
||||
if (opts.colSpan) e.colSpan = opts.colSpan;
|
||||
for (const c of children) e.appendChild(c);
|
||||
return e;
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return isNaN(d) ? '—' : d.toLocaleString('fr-FR');
|
||||
}
|
||||
function fmtDuration(ms) {
|
||||
if (ms == null) return '—';
|
||||
const s = Math.round(ms / 1000);
|
||||
return s < 60 ? s + ' s' : Math.floor(s / 60) + ' min ' + (s % 60) + ' s';
|
||||
}
|
||||
function fmtInt(n) {
|
||||
return n == null ? '—' : Number(n).toLocaleString('fr-FR');
|
||||
}
|
||||
|
||||
function renderProgress(data) {
|
||||
const box = $('progressBox');
|
||||
if (!data.running) { box.classList.add('hidden'); return; }
|
||||
box.classList.remove('hidden');
|
||||
const p = data.progress;
|
||||
const pct = p ? Math.round((p.index / p.total) * 100) : 0;
|
||||
$('progressBar').style.width = pct + '%';
|
||||
$('progressPct').textContent = pct + '%';
|
||||
$('progressLabel').textContent = 'Sync ' + (data.force ? 'FORCÉE ' : '') + 'en cours — ' +
|
||||
(data.phase || '') + (p ? ' — table ' + p.index + '/' + p.total : '');
|
||||
$('progressDetail').textContent = p
|
||||
? 'En cours : ' + p.current + ' | Durée : ' + fmtDuration(Date.now() - new Date(p.startedAt))
|
||||
: '';
|
||||
}
|
||||
|
||||
function renderRunInfo(data) {
|
||||
const run = data.tables.find(t => t.table_name === '_run');
|
||||
const box = $('runInfo');
|
||||
box.textContent = '';
|
||||
if (data.dbError) {
|
||||
box.appendChild(el('span', { className: 'st-error', text: 'Erreur : ' + data.dbError }));
|
||||
return;
|
||||
}
|
||||
if (!run) { box.textContent = 'Aucune exécution enregistrée.'; return; }
|
||||
box.appendChild(el('span', { text: 'Dernière exécution : ' }));
|
||||
box.appendChild(el('strong', { className: 'st-' + run.status, text: run.status }));
|
||||
box.appendChild(el('span', { text: ' — ' + fmtDate(run.last_sync) + ' — ' + fmtDuration(run.duration_ms) + ' — ' + (run.detail || '') }));
|
||||
if (run.error_msg) box.appendChild(el('div', { className: 'st-error', text: 'Tables en erreur : ' + run.error_msg }));
|
||||
}
|
||||
|
||||
function renderTables(data) {
|
||||
const body = $('tableBody');
|
||||
body.textContent = '';
|
||||
const rows = data.tables.filter(t => t.table_name !== '_run');
|
||||
if (!rows.length) {
|
||||
body.appendChild(el('tr', {}, [el('td', { colSpan: 7, text: 'Aucune table synchronisée pour le moment.' })]));
|
||||
return;
|
||||
}
|
||||
for (const t of rows) {
|
||||
const status = t.status || '—';
|
||||
body.appendChild(el('tr', {}, [
|
||||
el('td', {}, [el('strong', { text: t.table_name })]),
|
||||
el('td', {}, [el('span', { className: 'strat ' + (t.strategy || ''), text: t.strategy || '—' })]),
|
||||
el('td', { text: fmtDate(t.last_sync) }),
|
||||
el('td', { className: 'num', text: fmtInt(t.rows_synced) }),
|
||||
el('td', { className: 'num', text: fmtDuration(t.duration_ms) }),
|
||||
el('td', { className: 'st-' + status, text: status }),
|
||||
el('td', { className: 'detail', text: t.error_msg || t.detail || '' }),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus(data) {
|
||||
const badge = $('statusBadge');
|
||||
badge.textContent = data.running ? 'En cours…' : 'Inactif';
|
||||
badge.className = 'badge ' + (data.running ? 'running' : 'idle');
|
||||
$('btnSync').disabled = data.running;
|
||||
$('btnForce').disabled = data.running;
|
||||
renderProgress(data);
|
||||
renderRunInfo(data);
|
||||
renderTables(data);
|
||||
}
|
||||
|
||||
function renderLogs(logs) {
|
||||
const box = $('logBox');
|
||||
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 10;
|
||||
box.textContent = '';
|
||||
if (!logs.length) { box.textContent = 'Aucun log.'; return; }
|
||||
for (const l of logs) {
|
||||
const line = el('div', { className: 'log-line ' + (['info', 'warn', 'error'].includes(l.level) ? l.level : '') });
|
||||
line.appendChild(el('span', { className: 'ts', text: new Date(l.ts).toLocaleTimeString('fr-FR') }));
|
||||
line.appendChild(document.createTextNode(l.msg));
|
||||
box.appendChild(line);
|
||||
}
|
||||
if (atBottom) box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
async function getJson(url) {
|
||||
const r = await fetch(url, { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error(url + ' : HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
let lastStatus = null;
|
||||
async function refresh() {
|
||||
try {
|
||||
const [s, logs, next] = await Promise.all([getJson('/api/status'), getJson('/api/logs?limit=200'), getJson('/api/next')]);
|
||||
lastStatus = s;
|
||||
renderStatus(s);
|
||||
renderLogs(logs);
|
||||
$('nextSync').textContent = 'Prochaine sync auto : ' + fmtDate(next.next) + ' (' + next.cron + ', ' + next.timeZone + ')';
|
||||
$('lastRefresh').textContent = 'Mis à jour ' + new Date().toLocaleTimeString('fr-FR');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function showMsg(ok, text) {
|
||||
const msg = $('msg');
|
||||
msg.textContent = text;
|
||||
msg.className = ok ? 'ok' : 'err';
|
||||
setTimeout(() => msg.classList.add('hidden'), 6000);
|
||||
}
|
||||
|
||||
async function runSync(force) {
|
||||
if (force && !confirm('Forcer la sync ?\n\nLe garde-fou de volume sera ignoré et les tables de mouvements (mvtart, mvtreg…) seront entièrement rechargées. Cela peut prendre longtemps.')) return;
|
||||
try {
|
||||
const r = await fetch('/api/sync/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Requested-By': 'ff-dashboard' },
|
||||
body: JSON.stringify({ force: !!force }),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
showMsg(r.ok, r.ok ? data.message : (data.error || 'HTTP ' + r.status));
|
||||
} catch (e) {
|
||||
showMsg(false, e.message);
|
||||
}
|
||||
setTimeout(refresh, 800);
|
||||
}
|
||||
|
||||
function copyLogs() {
|
||||
const text = Array.from($('logBox').querySelectorAll('.log-line')).map(l => l.textContent).join('\n');
|
||||
const btn = $('btnCopyLogs');
|
||||
const done = () => { btn.textContent = 'Copié !'; setTimeout(() => { btn.textContent = 'Copier les logs'; }, 2000); };
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(done).catch(() => alert('Copie impossible : sélectionnez les logs puis Ctrl+C'));
|
||||
} else {
|
||||
alert('Copie impossible hors HTTPS : sélectionnez les logs puis Ctrl+C');
|
||||
}
|
||||
}
|
||||
|
||||
$('btnSync').addEventListener('click', () => runSync(false));
|
||||
$('btnForce').addEventListener('click', () => runSync(true));
|
||||
$('btnRefresh').addEventListener('click', refresh);
|
||||
$('btnCopyLogs').addEventListener('click', copyLogs);
|
||||
|
||||
async function loop() {
|
||||
await refresh();
|
||||
setTimeout(loop, lastStatus && lastStatus.running ? 3000 : 15000);
|
||||
}
|
||||
loop();
|
||||
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sync Hostinger</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
|
||||
header{background:#1e293b;padding:1rem 2rem;display:flex;align-items:center;gap:1rem;flex-wrap:wrap;border-bottom:1px solid #334155}
|
||||
header h1{font-size:1.2rem;font-weight:700;color:#f8fafc}
|
||||
.subtitle{font-size:.75rem;color:#64748b}
|
||||
.badge{font-size:.75rem;padding:.25rem .6rem;border-radius:9999px;font-weight:600}
|
||||
.badge.idle{background:#1e3a5f;color:#93c5fd}
|
||||
.badge.running{background:#713f12;color:#fde68a;animation:pulse 1s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
|
||||
main{padding:1.5rem 2rem;max-width:1300px;margin:0 auto}
|
||||
.actions{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:1.5rem}
|
||||
button{padding:.55rem 1.3rem;border:none;border-radius:.5rem;cursor:pointer;font-weight:600;font-size:.85rem;transition:.15s}
|
||||
button:disabled{opacity:.4;cursor:not-allowed}
|
||||
#btnSync{background:#2563eb;color:#fff}
|
||||
#btnSync:hover:not(:disabled){background:#1d4ed8}
|
||||
#btnForce{background:#7c2d12;color:#fed7aa}
|
||||
#btnForce:hover:not(:disabled){background:#9a3412}
|
||||
#btnRefresh,#btnCopyLogs{background:#334155;color:#cbd5e1}
|
||||
#btnRefresh:hover,#btnCopyLogs:hover{background:#475569}
|
||||
#btnCopyLogs{font-size:.75rem;padding:.35rem .8rem}
|
||||
#nextSync{font-size:.8rem;color:#64748b;margin-left:auto}
|
||||
#msg{font-size:.82rem;padding:.35rem .75rem;border-radius:.4rem}
|
||||
#msg.ok{background:#14532d;color:#86efac}
|
||||
#msg.err{background:#450a0a;color:#fca5a5}
|
||||
.hidden{display:none !important}
|
||||
.box{background:#1e293b;border:1px solid #334155;border-radius:.75rem;padding:1rem 1.25rem;margin-bottom:1.5rem}
|
||||
.progress-header{display:flex;justify-content:space-between;margin-bottom:.6rem;font-size:.85rem}
|
||||
.progress-bar-bg{background:#0f172a;border-radius:9999px;height:12px;overflow:hidden}
|
||||
.progress-bar-fill{height:100%;background:linear-gradient(90deg,#059669,#2563eb);border-radius:9999px;transition:width .5s ease;width:0}
|
||||
.progress-detail{margin-top:.5rem;font-size:.78rem;color:#64748b}
|
||||
#runInfo{font-size:.85rem}
|
||||
.card{background:#1e293b;border:1px solid #334155;border-radius:.75rem;overflow:hidden;margin-bottom:1.5rem}
|
||||
.card-header{padding:.65rem 1rem;background:#0f172a;border-bottom:1px solid #334155;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;display:flex;justify-content:space-between;align-items:center}
|
||||
.table-wrap{overflow-x:auto}
|
||||
table{width:100%;border-collapse:collapse;font-size:.82rem}
|
||||
th{padding:.5rem .8rem;text-align:left;color:#64748b;font-weight:500;border-bottom:1px solid #334155;white-space:nowrap}
|
||||
td{padding:.5rem .8rem;border-bottom:1px solid #263449;vertical-align:top}
|
||||
td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
td.detail{color:#94a3b8;max-width:520px;word-break:break-word}
|
||||
.st-ok{color:#4ade80}.st-error{color:#f87171}.st-running{color:#fde68a}
|
||||
.strat{font-size:.72rem;padding:.1rem .45rem;border-radius:.3rem;background:#334155;color:#cbd5e1}
|
||||
.strat.window{background:#1e3a5f;color:#93c5fd}
|
||||
.log-box{height:340px;overflow-y:auto;font-family:monospace;font-size:.76rem;padding:.75rem 1rem;background:#0a0f1e;color:#a5f3fc;line-height:1.7}
|
||||
.log-line{white-space:pre-wrap;word-break:break-all}
|
||||
.log-line.error{color:#f87171}
|
||||
.log-line.warn{color:#fbbf24}
|
||||
.ts{color:#475569;margin-right:.5rem}
|
||||
#lastRefresh{font-size:.75rem;color:#475569;margin-left:auto}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Sync Hostinger</h1>
|
||||
<span class="subtitle">PG Debian (frps:15432) → PG Hostinger (miroir automatique du schéma)</span>
|
||||
<span id="statusBadge" class="badge idle">Inactif</span>
|
||||
<span id="lastRefresh"></span>
|
||||
</header>
|
||||
<main>
|
||||
<div class="actions">
|
||||
<button id="btnSync" type="button">Lancer la sync</button>
|
||||
<button id="btnForce" type="button" title="Ignore le garde-fou volume et recharge entièrement les tables de mouvements">Forcer (rechargement complet)</button>
|
||||
<button id="btnRefresh" type="button">Rafraîchir</button>
|
||||
<span id="msg" class="hidden"></span>
|
||||
<span id="nextSync"></span>
|
||||
</div>
|
||||
|
||||
<div id="progressBox" class="box hidden">
|
||||
<div class="progress-header">
|
||||
<span id="progressLabel">Synchronisation en cours…</span>
|
||||
<span id="progressPct">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar-bg"><div class="progress-bar-fill" id="progressBar"></div></div>
|
||||
<div class="progress-detail" id="progressDetail"></div>
|
||||
</div>
|
||||
|
||||
<div class="box"><div id="runInfo">Chargement…</div></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><span>Statut des tables</span></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Table</th><th>Stratégie</th><th>Dernière sync</th><th>Lignes</th><th>Durée</th><th>Statut</th><th>Détail / erreur</th></tr></thead>
|
||||
<tbody id="tableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><span>Logs</span><button id="btnCopyLogs" type="button">Copier les logs</button></div>
|
||||
<div class="log-box" id="logBox">Chargement…</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user