253 lines
9.4 KiB
HTML
253 lines
9.4 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Controllo Mirror S3</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<link href="/static/style.css" rel="stylesheet">
|
||
</head>
|
||
<body>
|
||
<div class="aurora aurora-a"></div>
|
||
<div class="aurora aurora-b"></div>
|
||
|
||
<main class="container py-4 position-relative">
|
||
<section class="hero-panel p-4 p-md-5 mb-4">
|
||
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-3">
|
||
<div>
|
||
<p class="eyebrow">Mirror S3 su Locale</p>
|
||
<h1 class="mb-2">Dashboard Sync</h1>
|
||
<p class="lead mb-0">Controlla tutti i job, avvia sync manuali e consulta i log in tempo reale.</p>
|
||
</div>
|
||
<div class="text-md-end">
|
||
<button id="runAllBtn" class="btn btn-accent btn-lg">Avvia Tutto Ora</button>
|
||
<p class="small mt-2 mb-0" id="serverTime">loading...</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<div id="jobsGrid" class="row g-3"></div>
|
||
</section>
|
||
|
||
<section class="log-panel mt-4 p-3 p-md-4">
|
||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||
<div class="d-flex align-items-center gap-2">
|
||
<h2 class="h4 mb-0">Attivita Recenti</h2>
|
||
<span id="liveIndicator" class="live-dot d-none"></span>
|
||
</div>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<span id="logCount" class="pill">0 eventi</span>
|
||
<span class="pill">UTC</span>
|
||
</div>
|
||
</div>
|
||
<div class="log-scroll">
|
||
<table class="table table-dark table-borderless align-middle mb-0" id="logsTable">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:160px">Ora</th>
|
||
<th style="width:90px">Livello</th>
|
||
<th style="width:130px">Job</th>
|
||
<th>Messaggio</th>
|
||
<th>Dettagli</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
async function fetchStatus() {
|
||
const response = await fetch('/api/status');
|
||
if (!response.ok) {
|
||
throw new Error('Impossibile caricare lo stato');
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
function fmtDate(value) {
|
||
if (!value) return '-';
|
||
return new Date(value).toLocaleString();
|
||
}
|
||
|
||
function fmtSummary(summary, running) {
|
||
if (running) {
|
||
return '<span class="sync-in-progress">▶ Sincronizzazione in corso…</span>';
|
||
}
|
||
if (!summary || Object.keys(summary).length === 0) {
|
||
return '<span class="text-secondary">Nessuna esecuzione ancora</span>';
|
||
}
|
||
const labels = { downloaded: 'scaricati', kept: 'invariati', deleted: 'eliminati', errors: 'errori', trigger: 'avviato da', error: 'errore' };
|
||
const parts = [];
|
||
for (const [key, val] of Object.entries(summary)) {
|
||
const label = labels[key] || key;
|
||
parts.push(`<span class="summary-kv"><span class="summary-key">${label}</span> ${val}</span>`);
|
||
}
|
||
return parts.join('');
|
||
}
|
||
|
||
function statusClass(status, running) {
|
||
if (running) return 'status-running';
|
||
if (status === 'success') return 'status-ok';
|
||
if (status === 'failed') return 'status-fail';
|
||
return 'status-idle';
|
||
}
|
||
|
||
function renderJobs(jobs) {
|
||
const grid = document.getElementById('jobsGrid');
|
||
grid.innerHTML = '';
|
||
|
||
jobs.forEach((job) => {
|
||
const col = document.createElement('div');
|
||
col.className = 'col-12 col-lg-6';
|
||
|
||
const statusText = job.running ? 'in esecuzione' : job.last_status;
|
||
|
||
// Barra di avanzamento (visibile solo durante il download)
|
||
const total = job.files_total || 0;
|
||
const done = job.files_done || 0;
|
||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||
const showProgress = job.running && total > 0;
|
||
const progressHtml = showProgress ? `
|
||
<div class="mt-3">
|
||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||
<span class="mono small progress-phase">${job.phase || 'in corso\u2026'}</span>
|
||
<span class="mono small">${done} / ${total} <span class="text-secondary">(${pct}%)</span></span>
|
||
</div>
|
||
<div class="progress-track"><div class="progress-fill" style="width:${pct}%"></div></div>
|
||
</div>
|
||
` : (job.running ? `<div class="mt-3 mono small progress-phase">${job.phase || 'in corso\u2026'}</div>` : '');
|
||
|
||
// File corrente
|
||
const fileHtml = job.running && job.current_file ? `
|
||
<div class="mt-2 current-file-box mono">
|
||
<span class="current-file-label">copia</span>
|
||
<span class="current-file-name" title="${escHtml(job.current_file)}">${escHtml(job.current_file)}</span>
|
||
</div>
|
||
` : '';
|
||
col.innerHTML = `
|
||
<article class="job-card p-4">
|
||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||
<div>
|
||
<h3 class="h5 mb-1">${job.name}</h3>
|
||
<p class="mono small mb-1">bucket: ${job.bucket}</p>
|
||
<p class="mono small mb-0">locale: ${job.local_dir}</p>
|
||
</div>
|
||
<span class="status-chip ${statusClass(job.last_status, job.running)}">${statusText}</span>
|
||
</div>
|
||
|
||
<div class="mt-3 small text-secondary-emphasis">
|
||
<div>Ultimo avvio: ${fmtDate(job.last_start)}</div>
|
||
<div>Ultima fine: ${fmtDate(job.last_end)}</div>
|
||
</div>
|
||
|
||
${progressHtml}
|
||
${fileHtml}
|
||
|
||
<div class="mt-3 summary-box mono small">${fmtSummary(job.last_summary, job.running)}</div>
|
||
|
||
<div class="mt-3 d-flex gap-2">
|
||
<button class="btn btn-outline-light btn-sm" data-job="${job.name}">Avvia ora</button>
|
||
</div>
|
||
</article>
|
||
`;
|
||
|
||
const runBtn = col.querySelector('button[data-job]');
|
||
runBtn.addEventListener('click', async () => {
|
||
runBtn.disabled = true;
|
||
try {
|
||
await fetch(`/api/run/${encodeURIComponent(job.name)}`, { method: 'POST' });
|
||
await reload();
|
||
} finally {
|
||
runBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
grid.appendChild(col);
|
||
});
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
let prevLogTime = null;
|
||
|
||
function renderLogs(logs) {
|
||
const tbody = document.querySelector('#logsTable tbody');
|
||
const entries = logs.slice(0, 200);
|
||
const newTopTime = entries.length > 0 ? entries[0].time : null;
|
||
const hasNew = newTopTime && newTopTime !== prevLogTime;
|
||
prevLogTime = newTopTime;
|
||
|
||
tbody.innerHTML = '';
|
||
entries.forEach((entry, i) => {
|
||
const tr = document.createElement('tr');
|
||
if (hasNew && i < 3) tr.classList.add('row-new');
|
||
tr.innerHTML = `
|
||
<td class="mono small">${fmtDate(entry.time)}</td>
|
||
<td><span class="pill level-${entry.level.toLowerCase()}">${entry.level}</span></td>
|
||
<td class="mono small">${entry.job || '-'}</td>
|
||
<td>${entry.message}</td>
|
||
<td class="mono small text-truncate" style="max-width:260px" title="${encodeURIComponent(JSON.stringify(entry.details || {})).replace(/'/g,"'").replace(/%/g,'%')}">` +
|
||
JSON.stringify(entry.details || {}) +
|
||
`</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
document.getElementById('logCount').textContent = entries.length + ' eventi';
|
||
|
||
if (hasNew) {
|
||
const scroll = document.querySelector('.log-scroll');
|
||
scroll.scrollTop = 0;
|
||
}
|
||
}
|
||
|
||
let pollTimer = null;
|
||
|
||
async function reload() {
|
||
try {
|
||
const data = await fetchStatus();
|
||
document.getElementById('serverTime').textContent = `Server UTC: ${fmtDate(data.server_time)}`;
|
||
renderJobs(data.jobs || []);
|
||
renderLogs(data.logs || []);
|
||
const anyRunning = (data.jobs || []).some(j => j.running);
|
||
const dot = document.getElementById('liveIndicator');
|
||
if (anyRunning) {
|
||
dot.classList.remove('d-none');
|
||
} else {
|
||
dot.classList.add('d-none');
|
||
}
|
||
// Polling veloce (1 s) durante sync attiva, lento (5 s) a riposo
|
||
const interval = anyRunning ? 1000 : 5000;
|
||
clearTimeout(pollTimer);
|
||
pollTimer = setTimeout(reload, interval);
|
||
} catch (err) {
|
||
document.getElementById('serverTime').textContent = `Errore: ${err.message}`;
|
||
pollTimer = setTimeout(reload, 5000);
|
||
}
|
||
}
|
||
|
||
document.getElementById('runAllBtn').addEventListener('click', async () => {
|
||
const btn = document.getElementById('runAllBtn');
|
||
btn.disabled = true;
|
||
try {
|
||
await fetch('/api/run-all', { method: 'POST' });
|
||
await reload();
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
reload();
|
||
// il timer ricorsivo in reload() gestisce il polling adattivo
|
||
</script>
|
||
</body>
|
||
</html>
|