From d5bb0dfaa1deb89f72701672f5b357d19b0f9697 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:17:04 -0700 Subject: [PATCH] Combine enrichment pills into unified rate monitor cards, debounce idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge enrichment worker status into rate monitor WebSocket payload - Hide old enrichment pills — rate monitor cards now show: service name, worker status badge, arc gauge, calls/min, 1h/24h counts, budget bar - Debounce idle detection with 5s grace period — prevents status flickering between Running and Idle on every worker cycle - Responsive grid layout with richer card design --- web_server.py | 47 ++++++++++- webui/index.html | 8 +- webui/static/script.js | 128 ++++++++++++++++++++++++------ webui/static/style.css | 175 ++++++++++++++++++++++++++++++++--------- 4 files changed, 287 insertions(+), 71 deletions(-) diff --git a/web_server.py b/web_server.py index fa054e94..8a41290f 100644 --- a/web_server.py +++ b/web_server.py @@ -4009,6 +4009,9 @@ def _get_windowed_calls(key, current_total): return max(0, current_total - oldest_1h_total), max(0, current_total - oldest_24h_total) +_idle_since = {} # worker_key -> timestamp when current_item first became None +_IDLE_GRACE_SECONDS = 5 # Don't report idle until current_item has been None this long + def _get_enrichment_status(): """Get lightweight status for all enrichment services (no DB queries). Reads worker properties directly to avoid expensive get_stats() calls.""" @@ -4050,12 +4053,22 @@ def _get_enrichment_status(): total_processed = stats.get('matched', 0) + stats.get('not_found', 0) + stats.get('errors', 0) calls_1h, calls_24h = _get_windowed_calls(key, total_processed) + # Debounced idle detection — only report idle after 5s of no current_item + has_item = getattr(worker, 'current_item', None) is not None + if has_item: + _idle_since.pop(key, None) + is_idle = False + else: + if key not in _idle_since: + _idle_since[key] = time.time() + is_idle = (time.time() - _idle_since[key]) >= _IDLE_GRACE_SECONDS + svc_data = { 'name': name, 'configured': configured, 'running': worker.running and is_alive and not worker.paused, 'paused': worker.paused, - 'idle': is_alive and not worker.paused and getattr(worker, 'current_item', None) is None, + 'idle': is_alive and not worker.paused and is_idle, 'calls_1h': calls_1h, 'calls_24h': calls_24h, } @@ -48300,13 +48313,43 @@ _download_yield_override = set() # Workers the user explicitly resumed during d def _emit_rate_monitor_loop(): - """Background thread that pushes API call rate data every 1 second for speedometer gauges.""" + """Background thread that pushes API call rate data every 1 second for speedometer gauges. + Also includes enrichment worker status so the combined cards have everything.""" + # Map rate monitor service keys to enrichment status keys + _enrichment_key_map = { + 'spotify': 'spotify_enrichment', 'itunes': 'itunes_enrichment', + 'deezer': 'deezer_enrichment', 'lastfm': 'lastfm', 'genius': 'genius', + 'musicbrainz': 'musicbrainz', 'audiodb': 'audiodb', + 'tidal': 'tidal_enrichment', 'qobuz': 'qobuz_enrichment', + } + while True: socketio.sleep(1) try: from core.api_call_tracker import api_call_tracker payload = api_call_tracker.get_all_rates() + # Merge enrichment worker status into each service + try: + enrichment = _get_enrichment_status() + for svc_key, entry in payload.items(): + enr_key = _enrichment_key_map.get(svc_key) + enr = enrichment.get(enr_key) if enr_key else None + if enr: + entry['worker'] = { + 'status': 'not_configured' if not enr.get('configured') else + 'paused' if enr.get('paused') else + 'idle' if enr.get('idle') else + 'running' if enr.get('running') else 'stopped', + 'yield_reason': enr.get('yield_reason', ''), + 'calls_1h': enr.get('calls_1h', 0), + 'calls_24h': enr.get('calls_24h', 0), + } + if svc_key == 'spotify' and enr.get('daily_budget'): + entry['worker']['daily_budget'] = enr['daily_budget'] + except Exception: + pass + # Add Spotify rate limit state try: if spotify_client: diff --git a/webui/index.html b/webui/index.html index 7e4a17de..98475985 100644 --- a/webui/index.html +++ b/webui/index.html @@ -669,19 +669,19 @@ -
+
- +
-

API Rate Monitor

+

Enrichment Services

diff --git a/webui/static/script.js b/webui/static/script.js index 54968225..83dbc74c 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -37685,8 +37685,8 @@ const _RATE_GAUGE_COLORS = { audiodb: '#00BCD4', tidal: '#00FFFF', qobuz: '#FF6B35', }; -// SVG constants — 240° arc, gap at bottom, extra padding for glow -const _G = { size: 220, cx: 110, cy: 116, r: 74, stroke: 10, startAngle: 240, totalArc: 240 }; +// SVG constants — 240° arc, gap at bottom +const _G = { size: 160, cx: 80, cy: 84, r: 56, stroke: 8, startAngle: 240, totalArc: 240 }; function _gPt(angle, radius) { const rad = (angle - 90) * Math.PI / 180; @@ -37726,36 +37726,112 @@ function _handleRateMonitorUpdate(data) { const value = d.cpm || 0; const max = d.limit || 60; const pct = Math.min(value / max, 1); - - let svg = container.querySelector('svg'); - if (!svg) { - container.innerHTML = _buildGaugeSVG(svc, value, max); - } else { - _updateGauge(container, value, max, svc); - } - - container.classList.toggle('danger', pct > 0.8); - container.classList.toggle('active', value > 0); - - // Rate limited badge (Spotify only for now) + const accent = _RATE_GAUGE_COLORS[svc] || '#888'; + const label = _RATE_GAUGE_LABELS[svc] || svc; + const worker = d.worker || {}; + const wStatus = worker.status || 'stopped'; const isRateLimited = d.rate_limited === true; - container.classList.toggle('rate-limited', isRateLimited); - let badge = container.querySelector('.gauge-rl-badge'); - if (isRateLimited) { - const mins = Math.ceil((d.rl_remaining || 0) / 60); - const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; - if (!badge) { - badge = document.createElement('div'); - badge.className = 'gauge-rl-badge'; - container.appendChild(badge); + + // Build or update the card content + let gaugeWrap = container.querySelector('.gauge-arc-wrap'); + if (!gaugeWrap) { + // Full rebuild + container.innerHTML = ` +
+ + ${label} + ${_workerStatusLabel(wStatus, worker)} +
+
${_buildGaugeSVG(svc, value, max)}
+
+
${value.toFixed(0)}calls/min
+
${worker.calls_1h || 0}last hour
+
${(worker.calls_24h || 0).toLocaleString()}24h
+
+ ${svc === 'spotify' && worker.daily_budget ? _buildBudgetBar(worker.daily_budget) : ''} + ${isRateLimited ? _buildRateLimitBadge(d) : ''} + `; + } else { + // Fast update — only change values + _updateGauge(gaugeWrap, value, max, svc); + + // Update status + const statusEl = container.querySelector('.gauge-card-status'); + if (statusEl) { + statusEl.dataset.status = wStatus; + statusEl.textContent = _workerStatusLabel(wStatus, worker); + } + + // Update stats + const statVals = container.querySelectorAll('.gauge-card-stat-val'); + if (statVals[0]) statVals[0].textContent = value.toFixed(0); + if (statVals[1]) statVals[1].textContent = worker.calls_1h || 0; + if (statVals[2]) statVals[2].textContent = (worker.calls_24h || 0).toLocaleString(); + + // Budget bar (Spotify) + if (svc === 'spotify' && worker.daily_budget) { + let budgetEl = container.querySelector('.gauge-budget-bar'); + if (!budgetEl) { + const div = document.createElement('div'); + div.innerHTML = _buildBudgetBar(worker.daily_budget); + const statsEl = container.querySelector('.gauge-card-stats'); + if (statsEl) statsEl.after(div.firstElementChild); + } else { + const b = worker.daily_budget; + const pctB = Math.min(100, Math.round((b.used / b.limit) * 100)); + const fill = budgetEl.querySelector('.gauge-budget-fill'); + if (fill) { fill.style.width = pctB + '%'; } + const label = budgetEl.querySelector('.gauge-budget-label'); + if (label) label.textContent = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()} daily`; + } + } + + // Rate limit badge + let badge = container.querySelector('.gauge-rl-badge'); + if (isRateLimited) { + if (!badge) { + const div = document.createElement('div'); + div.innerHTML = _buildRateLimitBadge(d); + container.appendChild(div.firstElementChild); + } else { + const mins = Math.ceil((d.rl_remaining || 0) / 60); + const timeEl = badge.querySelector('.gauge-rl-time'); + if (timeEl) timeEl.textContent = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; + } + } else if (badge) { + badge.remove(); } - badge.innerHTML = `RATE LIMITED${text}`; - } else if (badge) { - badge.remove(); } + + container.classList.toggle('danger', pct > 0.8 || isRateLimited); + container.classList.toggle('active', value > 0 || wStatus === 'running'); + container.classList.toggle('rate-limited', isRateLimited); } } +function _workerStatusLabel(status, worker) { + if (status === 'not_configured') return 'Not configured'; + if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused'; + if (status === 'idle') return 'Idle'; + if (status === 'running') return 'Running'; + return 'Stopped'; +} + +function _buildBudgetBar(budget) { + const pct = Math.min(100, Math.round((budget.used / budget.limit) * 100)); + const cls = budget.exhausted ? 'exhausted' : pct > 80 ? 'high' : ''; + return `
+
+ ${budget.used.toLocaleString()} / ${budget.limit.toLocaleString()} daily +
`; +} + +function _buildRateLimitBadge(d) { + const mins = Math.ceil((d.rl_remaining || 0) / 60); + const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; + return `
RATE LIMITED${text}
`; +} + function _buildGaugeSVG(svc, value, max) { const { size, cx, cy, r, stroke, startAngle, totalArc } = _G; const label = _RATE_GAUGE_LABELS[svc] || svc; diff --git a/webui/static/style.css b/webui/static/style.css index 732ae009..01802116 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -8195,45 +8195,44 @@ body.helper-mode-active #dashboard-activity-feed:hover { .rate-monitor-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); - gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; padding: 8px 0; } .rate-gauge-card { position: relative; display: flex; - align-items: center; - justify-content: center; - padding: 8px 4px 4px; - border-radius: 16px; - background: rgba(12, 12, 16, 0.6); - border: 1px solid rgba(255, 255, 255, 0.04); + flex-direction: column; + padding: 14px 14px 10px; + border-radius: 14px; + background: rgba(18, 18, 22, 0.7); + border: 1px solid rgba(255, 255, 255, 0.05); cursor: pointer; - transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); - backdrop-filter: blur(8px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(12px); overflow: visible; } .rate-gauge-card:hover { - background: rgba(20, 20, 28, 0.8); + background: rgba(24, 24, 30, 0.85); border-color: rgba(255, 255, 255, 0.1); - transform: translateY(-3px); - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); + transform: translateY(-2px); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); } .rate-gauge-card.active { - border-color: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.07); } .rate-gauge-card.danger { - border-color: rgba(239, 68, 68, 0.35); + border-color: rgba(239, 68, 68, 0.3); animation: gauge-danger-pulse 1.8s ease-in-out infinite; } @keyframes gauge-danger-pulse { - 0%, 100% { box-shadow: 0 0 0 rgba(239, 68, 68, 0), inset 0 0 0 rgba(239, 68, 68, 0); } - 50% { box-shadow: 0 0 24px rgba(239, 68, 68, 0.15), inset 0 0 20px rgba(239, 68, 68, 0.03); } + 0%, 100% { box-shadow: 0 0 0 rgba(239, 68, 68, 0); } + 50% { box-shadow: 0 0 20px rgba(239, 68, 68, 0.12); } } .rate-gauge-card.rate-limited { @@ -8241,23 +8240,122 @@ body.helper-mode-active #dashboard-activity-feed:hover { animation: gauge-danger-pulse 1.8s ease-in-out infinite; } -.gauge-rl-badge { - position: absolute; - bottom: 8px; - left: 50%; - transform: translateX(-50%); +/* Card header — service name + status */ +.gauge-card-header { display: flex; align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.gauge-card-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.gauge-card-name { + flex: 1; + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.85); + letter-spacing: 0.3px; +} + +.gauge-card-status { + font-size: 10px; + font-weight: 500; + padding: 2px 7px; + border-radius: 10px; + letter-spacing: 0.3px; +} +.gauge-card-status[data-status="running"] { background: rgba(34, 197, 94, 0.12); color: #4ade80; } +.gauge-card-status[data-status="idle"] { background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.35); } +.gauge-card-status[data-status="paused"] { background: rgba(234, 179, 8, 0.12); color: #fbbf24; } +.gauge-card-status[data-status="stopped"] { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); } +.gauge-card-status[data-status="not_configured"] { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); } + +/* Arc wrap */ +.gauge-arc-wrap { + display: flex; + justify-content: center; + margin: -4px 0; +} + +/* Stats row below gauge */ +.gauge-card-stats { + display: flex; + justify-content: space-between; + padding: 0 4px; + margin-top: -2px; +} + +.gauge-card-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; +} + +.gauge-card-stat-val { + font-size: 13px; + font-weight: 700; + color: rgba(255, 255, 255, 0.8); +} + +.gauge-card-stat-label { + font-size: 8px; + color: rgba(255, 255, 255, 0.25); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Spotify budget bar */ +.gauge-budget-bar { + position: relative; + height: 4px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + margin: 6px 4px 2px; + overflow: hidden; +} + +.gauge-budget-fill { + height: 100%; + border-radius: 2px; + background: linear-gradient(90deg, rgba(29, 185, 84, 0.7), #1DB954); + transition: width 0.8s ease; +} + +.gauge-budget-bar.high .gauge-budget-fill { background: linear-gradient(90deg, rgba(245, 166, 35, 0.7), #f5a623); } +.gauge-budget-bar.exhausted .gauge-budget-fill { background: linear-gradient(90deg, rgba(239, 68, 68, 0.7), #ef4444); } + +.gauge-budget-label { + position: absolute; + top: 6px; + left: 0; + width: 100%; + text-align: center; + font-size: 8px; + color: rgba(255, 255, 255, 0.2); +} + +/* Rate limit badge */ +.gauge-rl-badge { + display: flex; + align-items: center; + justify-content: center; gap: 5px; - padding: 3px 10px; - border-radius: 20px; - background: rgba(239, 68, 68, 0.15); - border: 1px solid rgba(239, 68, 68, 0.25); + padding: 4px 10px; + margin-top: 6px; + border-radius: 8px; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); font-size: 9px; font-weight: 700; color: #ef4444; letter-spacing: 0.5px; - white-space: nowrap; } .gauge-rl-dot { @@ -8274,12 +8372,14 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .gauge-rl-time { - color: rgba(239, 68, 68, 0.6); + color: rgba(239, 68, 68, 0.5); font-weight: 500; } +/* SVG gauge */ .rate-gauge-svg { width: 100%; + max-width: 160px; height: auto; overflow: visible; } @@ -8298,24 +8398,20 @@ body.helper-mode-active #dashboard-activity-feed:hover { .gauge-value { font-family: -apple-system, 'SF Pro Display', 'Segoe UI', sans-serif; - font-size: 32px; + font-size: 24px; font-weight: 800; fill: rgba(255, 255, 255, 0.95); - letter-spacing: -1px; + letter-spacing: -0.5px; } .gauge-unit { font-family: -apple-system, sans-serif; - font-size: 10px; + font-size: 9px; fill: rgba(255, 255, 255, 0.2); } .gauge-label { - font-family: -apple-system, sans-serif; - font-size: 11px; - font-weight: 600; - fill: rgba(255, 255, 255, 0.45); - letter-spacing: 0.5px; + display: none; /* Label is now in the card header, not the SVG */ } /* Rate Monitor Detail Modal */ @@ -8470,18 +8566,19 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-size: 12px; } -@media (max-width: 768px) { +@media (max-width: 900px) { .rate-monitor-grid { grid-template-columns: repeat(3, 1fr); gap: 10px; } - .rate-gauge-card { padding: 10px 8px 6px; } } -@media (max-width: 480px) { +@media (max-width: 600px) { .rate-monitor-grid { grid-template-columns: repeat(2, 1fr); } + .gauge-card-stats { gap: 2px; } + .gauge-card-stat-val { font-size: 11px; } } /* System Stats Grid */