Fix sentinel countdown: sync to actual scan schedule, not page load

- Sentinel thread sets next_scan_ts = time.time() + interval BEFORE sleeping
- API returns next_scan_in derived from real next_scan_ts, not estimated
- Frontend calculates server clock offset and counts down to the actual
  target timestamp — refresh shows the same remaining time, not a reset
- Shows ✓ in green when scan fires, resumes countdown on next poll

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-03-26 04:07:15 -05:00
parent 357918013d
commit 7948089f04

View File

@ -775,13 +775,21 @@ async function loadThreats() {
sentinelCard.appendChild(sHeader); sentinelCard.appendChild(sHeader);
// Start countdown // Countdown synced to server's next_scan_ts
// Store the absolute target time so refresh doesn't reset
if (window._sentinelTimer) clearInterval(window._sentinelTimer); if (window._sentinelTimer) clearInterval(window._sentinelTimer);
window._sentinelCountdown = nextIn; var serverNow = sentinel.server_time || (Date.now()/1000);
var nextScanTs = serverNow + nextIn;
window._sentinelTargetTs = nextScanTs;
window._sentinelServerOffset = serverNow - (Date.now()/1000); // clock difference
window._sentinelTimer = setInterval(function(){ window._sentinelTimer = setInterval(function(){
window._sentinelCountdown = Math.max(0, window._sentinelCountdown - 1); var localNow = (Date.now()/1000) + (window._sentinelServerOffset||0);
var remaining = Math.max(0, (window._sentinelTargetTs||0) - localNow);
var el = document.getElementById('sentinel-countdown'); var el = document.getElementById('sentinel-countdown');
if (el) { el.textContent = Math.ceil(window._sentinelCountdown) || '...'; if (window._sentinelCountdown <= 0) { el.textContent = ''; el.style.color = '#4ade80'; clearInterval(window._sentinelTimer); } } if (el) {
if (remaining > 0) { el.textContent = Math.ceil(remaining); el.style.color = '#d946ef'; }
else { el.textContent = ''; el.style.color = '#4ade80'; }
}
}, 1000); }, 1000);
if (ss.last_error) { if (ss.last_error) {
@ -6390,7 +6398,7 @@ SENTINEL_MODEL = "qwen2.5:latest"
SENTINEL_INTERVAL = 300 # 5 minutes SENTINEL_INTERVAL = 300 # 5 minutes
_sentinel_last_pos = 0 _sentinel_last_pos = 0
_sentinel_results = [] # last 50 analyses _sentinel_results = [] # last 50 analyses
_sentinel_stats = {"scans": 0, "bans": 0, "last_run": None, "last_error": None} _sentinel_stats = {"scans": 0, "bans": 0, "last_run": None, "last_error": None, "next_scan_ts": 0}
def _sentinel_log_entry(msg): def _sentinel_log_entry(msg):
"""Write to sentinel log file.""" """Write to sentinel log file."""
@ -6573,6 +6581,7 @@ def _sentinel_loop():
_sentinel_log_entry("SENTINEL_START model=" + SENTINEL_MODEL + " interval=" + str(SENTINEL_INTERVAL) + "s") _sentinel_log_entry("SENTINEL_START model=" + SENTINEL_MODEL + " interval=" + str(SENTINEL_INTERVAL) + "s")
while True: while True:
_sentinel_stats["next_scan_ts"] = time.time() + SENTINEL_INTERVAL
time.sleep(SENTINEL_INTERVAL) time.sleep(SENTINEL_INTERVAL)
try: try:
_sentinel_scan() _sentinel_scan()
@ -6585,18 +6594,16 @@ def _sentinel_loop():
@app.route("/api/admin/sentinel") @app.route("/api/admin/sentinel")
@admin_required @admin_required
def admin_sentinel_status(): def admin_sentinel_status():
last_ts = _sentinel_stats.get("last_run_ts", 0)
now = time.time() now = time.time()
elapsed = now - last_ts if last_ts else 0 next_ts = _sentinel_stats.get("next_scan_ts", 0)
next_in = max(0, SENTINEL_INTERVAL - elapsed) next_in = max(0, next_ts - now)
return jsonify({ return jsonify({
"stats": _sentinel_stats, "stats": _sentinel_stats,
"recent_verdicts": list(reversed(_sentinel_results[-20:])), "recent_verdicts": list(reversed(_sentinel_results[-20:])),
"model": SENTINEL_MODEL, "model": SENTINEL_MODEL,
"interval": SENTINEL_INTERVAL, "interval": SENTINEL_INTERVAL,
"elapsed_since_scan": round(elapsed, 1),
"next_scan_in": round(next_in, 1), "next_scan_in": round(next_in, 1),
"server_time": now "server_time": round(now, 1)
}) })