From 3cdfc018351662e20648a67fa546c927d7911f05 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 26 Mar 2026 04:01:09 -0500 Subject: [PATCH] Sentinel countdown ring timer with live stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SVG progress ring shows time until next scan (magenta arc) - Countdown ticks every second: "245s → 244s → ... → scanning..." - Ring fills as time progresses, resets on scan - Turns green and shows "scanning..." when timer hits 0 - Stats grid: Scans count, AI Bans count, Last Run time, Interval - Backend API returns elapsed_since_scan and next_scan_in Co-Authored-By: Claude Opus 4.6 (1M context) --- llm_team_ui.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/llm_team_ui.py b/llm_team_ui.py index 252430b..5cc3c5d 100644 --- a/llm_team_ui.py +++ b/llm_team_ui.py @@ -749,14 +749,74 @@ async function loadThreats() { sTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:#d946ef;font-weight:700'; sTitle.textContent = 'AI Sentinel — ' + (sentinel.model || '?'); sHeader.appendChild(sDot);sHeader.appendChild(sTitle);sentinelCard.appendChild(sHeader); - var sStats = document.createElement('div'); - sStats.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#7a7872;display:flex;gap:16px;margin-bottom:8px'; + + // Countdown + metrics row var ss = sentinel.stats || {}; - sStats.textContent = 'Scans: ' + (ss.scans||0) + ' | AI Bans: ' + (ss.bans||0) + ' | Last: ' + (ss.last_run||'not yet') + ' | Interval: ' + (sentinel.interval||300) + 's'; - sentinelCard.appendChild(sStats); + var nextIn = sentinel.next_scan_in || 0; + var interval = sentinel.interval || 300; + + var metricsRow = document.createElement('div'); + metricsRow.style.cssText = 'display:grid;grid-template-columns:auto 1fr;gap:14px;align-items:center;margin-bottom:10px'; + + // Countdown ring + var ringWrap = document.createElement('div'); + ringWrap.style.cssText = 'position:relative;width:64px;height:64px;flex-shrink:0'; + var pct = interval > 0 ? ((interval - nextIn) / interval) : 0; + var deg = Math.round(pct * 360); + ringWrap.innerHTML = '' + + '' + + '' + + ''; + var countText = document.createElement('div'); + countText.id = 'sentinel-countdown'; + countText.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:JetBrains Mono,monospace'; + var countNum = document.createElement('div'); + countNum.style.cssText = 'font-size:16px;font-weight:700;color:#d946ef;line-height:1'; + countNum.textContent = Math.ceil(nextIn) + 's'; + var countLabel = document.createElement('div'); + countLabel.style.cssText = 'font-size:7px;color:#7a7872;text-transform:uppercase;letter-spacing:1px;margin-top:2px'; + countLabel.textContent = 'next scan'; + countText.appendChild(countNum); countText.appendChild(countLabel); + ringWrap.appendChild(countText); + metricsRow.appendChild(ringWrap); + + // Stats grid + var statsGrid = document.createElement('div'); + statsGrid.style.cssText = 'display:grid;grid-template-columns:repeat(4,1fr);gap:6px'; + [{v:ss.scans||0,l:'Scans',c:'#d946ef'},{v:ss.bans||0,l:'AI Bans',c:'#e05252'},{v:ss.last_run||'—',l:'Last Run',c:'#e8e6e3',small:true},{v:(sentinel.interval||300)+'s',l:'Interval',c:'#7a7872'}].forEach(function(m){ + var box = document.createElement('div'); + box.style.cssText = 'text-align:center'; + var val = document.createElement('div'); + val.style.cssText = 'font-family:JetBrains Mono,monospace;font-weight:700;color:'+m.c+';font-size:'+(m.small?'10px':'14px'); + val.textContent = m.v; + var lab = document.createElement('div'); + lab.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:7px;text-transform:uppercase;letter-spacing:1px;color:#7a7872;margin-top:2px'; + lab.textContent = m.l; + box.appendChild(val);box.appendChild(lab);statsGrid.appendChild(box); + }); + metricsRow.appendChild(statsGrid); + sentinelCard.appendChild(metricsRow); + + // Start countdown timer + if (window._sentinelTimer) clearInterval(window._sentinelTimer); + window._sentinelCountdown = nextIn; + window._sentinelTimer = setInterval(function(){ + window._sentinelCountdown = Math.max(0, window._sentinelCountdown - 1); + var el = document.getElementById('sentinel-countdown'); + if (el) el.querySelector('div').textContent = Math.ceil(window._sentinelCountdown) + 's'; + if (window._sentinelCountdown <= 0) { + clearInterval(window._sentinelTimer); + var el2 = document.getElementById('sentinel-countdown'); + if (el2) { el2.querySelector('div').textContent = 'scanning...'; el2.querySelector('div').style.color = '#4ade80'; } + } + }, 1000); + if (ss.last_error) { var sErr = document.createElement('div'); - sErr.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#e05252;border-left:2px solid #e05252;padding-left:8px'; + sErr.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#e05252;border-left:2px solid #e05252;padding-left:8px;margin-bottom:8px'; sErr.textContent = 'Last error: ' + ss.last_error; sentinelCard.appendChild(sErr); } @@ -6369,6 +6429,7 @@ def _sentinel_scan(): import subprocess, collections _sentinel_stats["last_run"] = time.strftime("%Y-%m-%d %H:%M:%S") + _sentinel_stats["last_run_ts"] = time.time() _sentinel_stats["scans"] += 1 # Read new lines since last scan @@ -6547,11 +6608,18 @@ def _sentinel_loop(): @app.route("/api/admin/sentinel") @admin_required def admin_sentinel_status(): + last_ts = _sentinel_stats.get("last_run_ts", 0) + now = time.time() + elapsed = now - last_ts if last_ts else 0 + next_in = max(0, SENTINEL_INTERVAL - elapsed) return jsonify({ "stats": _sentinel_stats, "recent_verdicts": list(reversed(_sentinel_results[-20:])), "model": SENTINEL_MODEL, - "interval": SENTINEL_INTERVAL + "interval": SENTINEL_INTERVAL, + "elapsed_since_scan": round(elapsed, 1), + "next_scan_in": round(next_in, 1), + "server_time": now })