feat: scheduler live status — endpoint + UI polling every 15s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-17 15:21:32 -05:00
co-authored by Claude Sonnet 4.6
parent 9d79fe932b
commit 467a24baa4
2 changed files with 57 additions and 3 deletions
+37 -3
View File
@@ -37,11 +37,12 @@
<h3 class="font-semibold text-gray-800">
<i class="fas fa-robot mr-2 text-green-500"></i>Estado del Scheduler
</h3>
<span class="flex items-center gap-2 text-sm text-green-600 font-medium">
<span class="w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
Activo — cada 1 minuto
<span id="sched-badge" class="flex items-center gap-2 text-sm font-medium text-gray-400">
<span id="sched-dot" class="w-2 h-2 rounded-full bg-gray-300"></span>
<span id="sched-label">Verificando…</span>
</span>
</div>
<div id="sched-live" class="px-5 pt-3 pb-2 text-xs text-gray-500 flex flex-wrap gap-x-6 gap-y-1 border-b border-gray-100"></div>
<div class="p-5 grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-600">
<div class="flex items-start gap-3">
<i class="fas fa-database text-blue-400 mt-0.5"></i>
@@ -170,11 +171,44 @@ function switchTab(name) {
if (name === 'scheduler' && !_schedulerLoaded) {
_schedulerLoaded = true;
cargarHistorialScheduler();
pollSchedulerStatus();
}
sessionStorage.setItem('erp-tab', name);
}
let _schedPollTimer = null;
async function pollSchedulerStatus() {
try {
const r = await fetch('/api/scheduler/status');
const d = await r.json();
const dot = document.getElementById('sched-dot');
const label = document.getElementById('sched-label');
const live = document.getElementById('sched-live');
if (d.running && d.job_exists) {
dot.className = 'w-2 h-2 rounded-full bg-green-400 animate-pulse';
label.textContent = 'Activo — cada 1 minuto';
label.className = 'text-green-600';
} else {
dot.className = 'w-2 h-2 rounded-full bg-red-400';
label.textContent = d.running ? 'Sin tarea registrada' : 'DETENIDO';
label.className = 'text-red-600';
}
const parts = [];
if (d.next_run) {
const nx = new Date(d.next_run);
parts.push(`<span><i class="fas fa-clock mr-1 text-blue-400"></i>Próxima ejecución: <strong>${nx.toLocaleTimeString('es-CO')}</strong></span>`);
}
if (d.ultimo_log) {
const ul = d.ultimo_log;
const ts = new Date(ul.created_at).toLocaleTimeString('es-CO');
parts.push(`<span><i class="fas fa-history mr-1 text-purple-400"></i>Último sync scheduler: <strong>${ts}</strong> · ${ul.total_procesados ?? 0} procesados, ${ul.total_errores ?? 0} errores</span>`);
}
live.innerHTML = parts.join('');
} catch(_) {}
_schedPollTimer = setTimeout(pollSchedulerStatus, 15000);
}
const _origenLabel = { manual: 'Manual', tercero: 'Tercero', scheduler: 'Scheduler', automation: 'Automatización' };
const _modoLabel = { insertar: 'Solo nuevos', upsert: 'Upsert' };
+20
View File
@@ -73,6 +73,26 @@ async def shutdown():
_scheduler.shutdown(wait=False)
@app.get("/api/scheduler/status")
async def scheduler_status():
from fastapi.responses import JSONResponse
from app.database import get_connection
job = _scheduler.get_job("wa_sync_recientes")
next_run = job.next_run_time.isoformat() if job and job.next_run_time else None
conn = get_connection()
row = conn.execute(
"SELECT created_at, total_procesados, total_errores, origen "
"FROM sync_wa_log WHERE origen='scheduler' ORDER BY id DESC LIMIT 1"
).fetchone()
conn.close()
return JSONResponse({
"running": _scheduler.running,
"job_exists": job is not None,
"next_run": next_run,
"ultimo_log": dict(row) if row else None,
})
@app.get("/")
async def root():
return RedirectResponse(url="/dashboard")