diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js
index fa653f5a..3ff57de0 100644
--- a/webui/static/api-monitor.js
+++ b/webui/static/api-monitor.js
@@ -237,6 +237,10 @@ function _renderEqualizerBars(grid, data) {
+
0
@@ -350,7 +354,36 @@ function _renderEqualizerBars(grid, data) {
bar.classList.toggle('active', value > 0 || wStatus === 'running');
bar.classList.toggle('rate-limited', isRateLimited);
- _eqDisplay[svc] = { value, pct, peak, peakAt };
+ // Cooldown drain — a ban isn't just "red", it's a COUNTDOWN. The
+ // payload only carries seconds remaining, so latch the largest value
+ // seen this ban as the denominator; the red column then drains away
+ // as the ban ticks down and the timer shows m:ss until recovery.
+ const rlRemaining = isRateLimited ? Math.max(0, Math.round(d.rl_remaining || 0)) : 0;
+ let rlTotal = prev.rlTotal || 0;
+ const cooling = rlRemaining > 0;
+ if (cooling) {
+ rlTotal = Math.max(rlTotal, rlRemaining);
+ const cdFill = bar.querySelector('.rate-eq-cooldown-fill');
+ if (cdFill) cdFill.style.height = `${(rlRemaining / rlTotal) * 100}%`;
+ const cdTime = bar.querySelector('.rate-eq-cooldown-time');
+ if (cdTime) {
+ const m = Math.floor(rlRemaining / 60);
+ const s = String(rlRemaining % 60).padStart(2, '0');
+ cdTime.textContent = `${m}:${s}`;
+ }
+ } else {
+ rlTotal = 0;
+ // Recovery moment: the ban just ended — flash the bar back alive.
+ if (prev.cooling) {
+ bar.classList.remove('recovered');
+ void bar.offsetWidth; // restart the animation
+ bar.classList.add('recovered');
+ window.setTimeout(() => bar.classList.remove('recovered'), 1300);
+ }
+ }
+ bar.classList.toggle('cooldown', cooling);
+
+ _eqDisplay[svc] = { value, pct, peak, peakAt, rlTotal, cooling };
}
}
diff --git a/webui/static/style.css b/webui/static/style.css
index a67d3318..213ed7c8 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -63265,6 +63265,77 @@ body.reduce-effects .dash-card::after {
opacity: 0.75;
}
+/* Cooldown drain — while a service is rate-limit banned, the track shows a
+ * red column that DRAINS as the ban ticks down plus an m:ss countdown.
+ * "Back in 4:00", not just "something's red". JS drives the fill height
+ * (remaining/total) and timer text; .cooldown gates visibility. */
+.rate-eq-cooldown {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.4s;
+ z-index: 3;
+}
+
+.rate-eq.cooldown .rate-eq-cooldown {
+ opacity: 1;
+}
+
+.rate-eq.cooldown .rate-eq-fill,
+.rate-eq.cooldown .rate-eq-peak {
+ opacity: 0.18; /* the live VU yields the stage to the countdown */
+}
+
+.rate-eq-cooldown-fill {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 100%;
+ background: linear-gradient(180deg,
+ rgba(239, 68, 68, 0.55) 0%,
+ rgba(239, 68, 68, 0.28) 60%,
+ rgba(239, 68, 68, 0.12) 100%);
+ border-radius: 0 0 11px 11px;
+ box-shadow: inset 0 1px 0 rgba(255, 120, 120, 0.5);
+ transition: height 0.9s linear;
+}
+
+.rate-eq-cooldown-time {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ text-align: center;
+ font-size: 11px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ color: rgba(255, 200, 200, 0.95);
+ text-shadow: 0 0 8px rgba(239, 68, 68, 0.8);
+}
+
+/* Recovery flash — the moment a ban expires the track blinks back alive */
+.rate-eq.recovered .rate-eq-track {
+ animation: rate-eq-recovered 1.2s ease-out;
+}
+
+@keyframes rate-eq-recovered {
+ 0% {
+ border-color: rgba(74, 222, 128, 0.9);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.04),
+ 0 0 26px rgba(74, 222, 128, 0.55);
+ }
+ 100% {
+ border-color: rgba(255, 255, 255, 0.06);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.04),
+ inset 0 -10px 24px rgba(0, 0, 0, 0.35);
+ }
+}
+
/* Vertical shimmer scan — runs continuously when active */
.rate-eq-shimmer {
position: absolute;