diff --git a/core/api_call_tracker.py b/core/api_call_tracker.py new file mode 100644 index 00000000..7499c956 --- /dev/null +++ b/core/api_call_tracker.py @@ -0,0 +1,163 @@ +""" +Centralized API call tracker for all enrichment services. + +Tracks actual API calls (not items processed) with rolling timestamps +for real-time rate monitoring and minute-bucketed history for 24-hour graphs. +Thread-safe, no persistence (resets on restart). +""" + +import threading +import time +from collections import deque, defaultdict + + +# Known rate limits per service (calls/minute) +RATE_LIMITS = { + 'spotify': 171, # MIN_API_INTERVAL=0.35s → ~171/min + 'itunes': 20, # MIN_API_INTERVAL=3.0s → ~20/min + 'deezer': 60, # MIN_API_INTERVAL=1.0s → ~60/min + 'lastfm': 300, # MIN_API_INTERVAL=0.2s → ~300/min + 'genius': 30, # MIN_API_INTERVAL=2.0s → ~30/min + 'musicbrainz': 60, # MIN_API_INTERVAL=1.0s → ~60/min + 'audiodb': 30, # MIN_API_INTERVAL=2.0s → ~30/min + 'tidal': 120, # MIN_API_INTERVAL=0.5s → ~120/min + 'qobuz': 60, # Variable throttle, ~60/min estimate +} + +# Display names for UI +SERVICE_LABELS = { + 'spotify': 'Spotify', + 'itunes': 'Apple Music', + 'deezer': 'Deezer', + 'lastfm': 'Last.fm', + 'genius': 'Genius', + 'musicbrainz': 'MusicBrainz', + 'audiodb': 'AudioDB', + 'tidal': 'Tidal', + 'qobuz': 'Qobuz', +} + +# Display order +SERVICE_ORDER = [ + 'spotify', 'itunes', 'deezer', 'lastfm', 'genius', + 'musicbrainz', 'audiodb', 'tidal', 'qobuz', +] + + +class ApiCallTracker: + """Centralized tracker for actual API calls across all enrichment services.""" + + def __init__(self): + self._lock = threading.Lock() + # Recent call timestamps per service (last 60s window for speedometer) + # maxlen=600 covers 10 calls/sec for 60s — more than any service does + self._recent_calls = defaultdict(lambda: deque(maxlen=600)) + + # 24-hour minute-bucketed history per service + # Each entry: (minute_floor_timestamp, call_count) + self._minute_history = defaultdict(lambda: deque(maxlen=1440)) + self._current_minute_counts = defaultdict(int) + self._current_minute_ts = {} + + def record_call(self, service_key, endpoint=None): + """Record an API call. Called from rate_limited decorators. + + Args: + service_key: Service identifier ('spotify', 'itunes', etc.) + endpoint: Optional endpoint name for per-endpoint tracking (Spotify only) + """ + now = time.time() + minute_floor = int(now // 60) * 60 + + with self._lock: + # Record in recent timestamps + self._recent_calls[service_key].append(now) + # Roll minute bucket + self._roll_minute(service_key, minute_floor) + + # Spotify per-endpoint tracking + if endpoint and service_key == 'spotify': + ep_key = f"spotify:{endpoint}" + self._recent_calls[ep_key].append(now) + self._roll_minute(ep_key, minute_floor) + + def _roll_minute(self, key, minute_floor): + """Roll the minute bucket forward if we've moved to a new minute. + Must be called while holding self._lock.""" + prev_ts = self._current_minute_ts.get(key) + + if prev_ts is None or minute_floor > prev_ts: + # Flush previous minute's count to history + if prev_ts is not None and self._current_minute_counts[key] > 0: + self._minute_history[key].append((prev_ts, self._current_minute_counts[key])) + # Fill gaps with zeros (if minutes were skipped) + if prev_ts is not None: + gap_start = prev_ts + 60 + while gap_start < minute_floor: + self._minute_history[key].append((gap_start, 0)) + gap_start += 60 + # Start new minute + self._current_minute_ts[key] = minute_floor + self._current_minute_counts[key] = 1 + else: + self._current_minute_counts[key] += 1 + + def get_calls_per_minute(self, service_key): + """Get current calls/minute rate from last 60 seconds.""" + now = time.time() + cutoff = now - 60.0 + + with self._lock: + dq = self._recent_calls.get(service_key) + if not dq: + return 0.0 + count = sum(1 for ts in dq if ts > cutoff) + return float(count) + + def get_24h_history(self, service_key): + """Return list of [minute_timestamp, count] for last 24 hours. + Includes the current in-progress minute.""" + now = time.time() + cutoff = now - 86400 + + with self._lock: + history = [] + for ts, count in self._minute_history.get(service_key, []): + if ts >= cutoff: + history.append([ts, count]) + + # Include current minute in progress + cur_ts = self._current_minute_ts.get(service_key) + cur_count = self._current_minute_counts.get(service_key, 0) + if cur_ts is not None and cur_count > 0 and cur_ts >= cutoff: + history.append([cur_ts, cur_count]) + + return history + + def get_all_rates(self): + """Get current rates for all services. Used by WebSocket emission.""" + result = {} + for svc in SERVICE_ORDER: + cpm = self.get_calls_per_minute(svc) + entry = { + 'cpm': round(cpm, 1), + 'limit': RATE_LIMITS.get(svc, 60), + } + + # Spotify per-endpoint breakdown + if svc == 'spotify': + endpoints = {} + for key in list(self._recent_calls.keys()): + if key.startswith('spotify:'): + ep_name = key[8:] # strip 'spotify:' + ep_cpm = self.get_calls_per_minute(key) + if ep_cpm > 0: + endpoints[ep_name] = round(ep_cpm, 1) + entry['endpoints'] = endpoints + + result[svc] = entry + return result + + +# Singleton instance +api_call_tracker = ApiCallTracker() diff --git a/core/audiodb_client.py b/core/audiodb_client.py index dec5322e..ee3d6f73 100644 --- a/core/audiodb_client.py +++ b/core/audiodb_client.py @@ -28,6 +28,9 @@ def rate_limited(func): _last_api_call_time = time.time() + from core.api_call_tracker import api_call_tracker + api_call_tracker.record_call('audiodb') + try: result = func(*args, **kwargs) return result diff --git a/core/deezer_client.py b/core/deezer_client.py index 95c3549e..53076a29 100644 --- a/core/deezer_client.py +++ b/core/deezer_client.py @@ -31,6 +31,9 @@ def rate_limited(func): _last_api_call_time = time.time() + from core.api_call_tracker import api_call_tracker + api_call_tracker.record_call('deezer') + try: result = func(*args, **kwargs) return result diff --git a/core/genius_client.py b/core/genius_client.py index fac8e228..f255a2a3 100644 --- a/core/genius_client.py +++ b/core/genius_client.py @@ -37,6 +37,9 @@ def rate_limited(func): _last_api_call_time = time.time() + from core.api_call_tracker import api_call_tracker + api_call_tracker.record_call('genius') + try: result = func(*args, **kwargs) # Success — gradually reduce backoff diff --git a/core/itunes_client.py b/core/itunes_client.py index 2e0af7f8..0fbac47c 100644 --- a/core/itunes_client.py +++ b/core/itunes_client.py @@ -29,7 +29,10 @@ def rate_limited(func): time.sleep(sleep_time) _last_api_call_time = time.time() - + + from core.api_call_tracker import api_call_tracker + api_call_tracker.record_call('itunes') + try: result = func(*args, **kwargs) return result diff --git a/core/lastfm_client.py b/core/lastfm_client.py index 6f9f2bda..37e995d5 100644 --- a/core/lastfm_client.py +++ b/core/lastfm_client.py @@ -29,6 +29,9 @@ def rate_limited(func): _last_api_call_time = time.time() + from core.api_call_tracker import api_call_tracker + api_call_tracker.record_call('lastfm') + try: result = func(*args, **kwargs) return result diff --git a/core/musicbrainz_client.py b/core/musicbrainz_client.py index c8b9e800..b7b2725b 100644 --- a/core/musicbrainz_client.py +++ b/core/musicbrainz_client.py @@ -27,7 +27,10 @@ def rate_limited(func): time.sleep(sleep_time) _last_api_call_time = time.time() - + + from core.api_call_tracker import api_call_tracker + api_call_tracker.record_call('musicbrainz') + try: result = func(*args, **kwargs) return result diff --git a/core/qobuz_client.py b/core/qobuz_client.py index f617f44e..75c12123 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -54,6 +54,9 @@ def _qobuz_throttle(): time.sleep(_QOBUZ_MIN_INTERVAL - elapsed) _qobuz_last_api_call = time.time() + from core.api_call_tracker import api_call_tracker + api_call_tracker.record_call('qobuz') + def _qobuz_set_rate_limit(retry_after: float = 60.0): """Set a global rate limit ban for all Qobuz instances.""" diff --git a/core/spotify_client.py b/core/spotify_client.py index 2e21afeb..488753f3 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -235,6 +235,9 @@ def rate_limited(func): _last_api_call_time = time.time() + from core.api_call_tracker import api_call_tracker + api_call_tracker.record_call('spotify', endpoint=func.__name__) + try: return func(*args, **kwargs) except SpotifyRateLimitError: diff --git a/core/tidal_client.py b/core/tidal_client.py index a61953d8..ffea437f 100644 --- a/core/tidal_client.py +++ b/core/tidal_client.py @@ -43,6 +43,9 @@ def rate_limited(func): _last_api_call_time = time.time() + from core.api_call_tracker import api_call_tracker + api_call_tracker.record_call('tidal') + try: result = func(*args, **kwargs) return result diff --git a/web_server.py b/web_server.py index f9776c7d..49997518 100644 --- a/web_server.py +++ b/web_server.py @@ -45673,6 +45673,22 @@ except Exception as e: print(f"⚠️ Spotify enrichment worker initialization failed: {e}") spotify_enrichment_worker = None +# --- API Rate Monitor Endpoints --- + +@app.route('/api/rate-monitor/history/', methods=['GET']) +def get_rate_monitor_history(service_key): + """Get 24-hour minute-bucketed call history for a service. Used by the detail modal graph.""" + try: + from core.api_call_tracker import api_call_tracker, RATE_LIMITS + history = api_call_tracker.get_24h_history(service_key) + return jsonify({ + 'service': service_key, + 'rate_limit': RATE_LIMITS.get(service_key.split(':')[0], 60), + 'history': history, + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + # --- Spotify API Endpoints --- @app.route('/api/spotify-enrichment/status', methods=['GET']) @@ -48244,6 +48260,30 @@ _download_auto_paused = set() _download_yield_override = set() # Workers the user explicitly resumed during downloads — don't re-pause +def _emit_rate_monitor_loop(): + """Background thread that pushes API call rate data every 1 second for speedometer gauges.""" + while True: + socketio.sleep(1) + try: + from core.api_call_tracker import api_call_tracker + payload = api_call_tracker.get_all_rates() + + # Add Spotify rate limit state + try: + if spotify_client: + rl_info = spotify_client.get_rate_limit_info() + if rl_info: + payload['spotify']['rate_limited'] = True + payload['spotify']['rl_remaining'] = rl_info.get('remaining_seconds', 0) + payload['spotify']['rl_endpoint'] = rl_info.get('endpoint', '') + except Exception: + pass + + socketio.emit('rate-monitor:update', payload) + except Exception as e: + logger.debug(f"Error emitting rate monitor: {e}") + + def _emit_enrichment_status_loop(): """Background thread that pushes all enrichment worker statuses every 2 seconds. Also auto-pauses rate-limited enrichment workers during active downloads.""" @@ -48656,6 +48696,8 @@ if __name__ == '__main__': socketio.start_background_task(_emit_repair_progress_loop) # Hydrabase auto-reconnect monitor socketio.start_background_task(_hydrabase_reconnect_loop) - print("✅ WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair)") + # API Rate Monitor — 1s push for speedometer gauges + socketio.start_background_task(_emit_rate_monitor_loop) + print("✅ WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair + rate monitor)") socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True) diff --git a/webui/index.html b/webui/index.html index 5c4dd647..7e4a17de 100644 --- a/webui/index.html +++ b/webui/index.html @@ -679,6 +679,14 @@ + +
+

API Rate Monitor

+
+ +
+
+

Recent Syncs

diff --git a/webui/static/script.js b/webui/static/script.js index 2668797f..54968225 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -331,6 +331,7 @@ function initializeWebSocket() { socket.on('downloads:batch_update', handleDownloadBatchUpdate); // Phase 2 event listeners (dashboard pollers) + socket.on('rate-monitor:update', _handleRateMonitorUpdate); socket.on('dashboard:stats', handleDashboardStats); socket.on('dashboard:activity', handleDashboardActivity); socket.on('dashboard:toast', handleDashboardToast); @@ -37664,6 +37665,488 @@ function renderEnrichmentCards(enrichment) { grid.innerHTML = chips.join(''); } +// =============================== +// == API RATE MONITOR GAUGES == +// =============================== + +const _rateMonitorState = {}; +const _RATE_GAUGE_SERVICES = [ + 'spotify', 'itunes', 'deezer', 'lastfm', 'genius', + 'musicbrainz', 'audiodb', 'tidal', 'qobuz', +]; +const _RATE_GAUGE_LABELS = { + spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', + lastfm: 'Last.fm', genius: 'Genius', musicbrainz: 'MusicBrainz', + audiodb: 'AudioDB', tidal: 'Tidal', qobuz: 'Qobuz', +}; +const _RATE_GAUGE_COLORS = { + spotify: '#1DB954', itunes: '#FC3C44', deezer: '#A238FF', + lastfm: '#D51007', genius: '#FFFF64', musicbrainz: '#BA478F', + 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 }; + +function _gPt(angle, radius) { + const rad = (angle - 90) * Math.PI / 180; + const r = radius || _G.r; + return { x: _G.cx + r * Math.cos(rad), y: _G.cy + r * Math.sin(rad) }; +} + +function _gArc(startDeg, endDeg, radius) { + const r = radius || _G.r; + const s = _gPt(startDeg, r), e = _gPt(endDeg, r); + const sweep = ((endDeg - startDeg + 360) % 360); + const large = sweep > 180 ? 1 : 0; + return `M${s.x},${s.y} A${r},${r} 0 ${large} 1 ${e.x},${e.y}`; +} + +function _handleRateMonitorUpdate(data) { + const grid = document.getElementById('rate-monitor-grid'); + if (!grid) return; + + if (!grid.children.length) { + for (const svc of _RATE_GAUGE_SERVICES) { + const div = document.createElement('div'); + div.className = 'rate-gauge-card'; + div.id = `rate-gauge-${svc}`; + div.onclick = () => _openRateModal(svc); + grid.appendChild(div); + } + } + + for (const svc of _RATE_GAUGE_SERVICES) { + const d = data[svc]; + if (!d) continue; + _rateMonitorState[svc] = d; + const container = document.getElementById(`rate-gauge-${svc}`); + if (!container) continue; + + 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 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); + } + badge.innerHTML = `RATE LIMITED${text}`; + } else if (badge) { + badge.remove(); + } + } +} + +function _buildGaugeSVG(svc, value, max) { + const { size, cx, cy, r, stroke, startAngle, totalArc } = _G; + const label = _RATE_GAUGE_LABELS[svc] || svc; + const accent = _RATE_GAUGE_COLORS[svc] || '#888'; + const pct = Math.min(value / max, 1); + const endAngle = startAngle + pct * totalArc; + const arcEnd = startAngle + totalArc; + const glowId = `glow-${svc}`; + + // Endpoint dot position + const dot = pct > 0 ? _gPt(endAngle, r) : null; + + // Gradient ID for the colored arc + const gradId = `grad-${svc}`; + + const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent; + + return ` + + + + + + + + + ${pct > 0 ? `` : ''} + + + ${dot ? `` : ''} + + + 0 + ${max} + + + ${Math.round(value)} + /min + + + ${label} + + `; +} + +function _updateGauge(container, value, max, svc) { + const { r, stroke, startAngle, totalArc } = _G; + const accent = _RATE_GAUGE_COLORS[svc] || '#888'; + const pct = Math.min(value / max, 1); + const endAngle = startAngle + pct * totalArc; + const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent; + + // Update center value + const valText = container.querySelector('.gauge-value'); + if (valText) valText.textContent = Math.round(value); + + // Update active arc + const activeArc = container.querySelector('.gauge-active-arc'); + if (pct > 0) { + const d = _gArc(startAngle, endAngle); + if (activeArc) { + activeArc.setAttribute('d', d); + activeArc.setAttribute('stroke', color); + activeArc.style.filter = `drop-shadow(0 0 6px ${color}60)`; + } else { + // Rebuild the whole gauge when transitioning from 0 to active + container.innerHTML = _buildGaugeSVG(svc, value, max); + return; + } + } else if (activeArc) { + activeArc.remove(); + // Also remove dots + container.querySelectorAll('.gauge-dot').forEach(d => d.remove()); + const innerDot = container.querySelector('.gauge-dot + circle'); + if (innerDot) innerDot.remove(); + return; + } + + // Update endpoint dot + const gaugeDot = container.querySelector('.gauge-dot'); + if (pct > 0 && gaugeDot) { + const dot = _gPt(endAngle, r); + gaugeDot.setAttribute('cx', dot.x); + gaugeDot.setAttribute('cy', dot.y); + gaugeDot.setAttribute('fill', color); + gaugeDot.style.filter = `drop-shadow(0 0 4px ${color}80)`; + const inner = gaugeDot.nextElementSibling; + if (inner && inner.tagName === 'circle') { + inner.setAttribute('cx', dot.x); + inner.setAttribute('cy', dot.y); + } + } +} + +// ── Rate Monitor Detail Modal ── + +let _rateModalService = null; +let _rateModalInterval = null; + +function _openRateModal(serviceKey) { + _rateModalService = serviceKey; + const label = _RATE_GAUGE_LABELS[serviceKey] || serviceKey; + const accent = _RATE_GAUGE_COLORS[serviceKey] || '#888'; + + let overlay = document.getElementById('rate-modal-overlay'); + if (overlay) overlay.remove(); + + overlay = document.createElement('div'); + overlay.id = 'rate-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) _closeRateModal(); }; + + const isSpotify = serviceKey === 'spotify'; + const currentData = _rateMonitorState[serviceKey] || {}; + + overlay.innerHTML = ` +
+
+
+
+
+

${label}

+ ${currentData.cpm || 0} calls/min — limit ${currentData.limit || '?'}/min +
+
+ +
+
+
24-Hour Call History
+
+ +
+
+ ${isSpotify ? '
Per-Endpoint Breakdown
' : ''} +
+
+ `; + document.body.appendChild(overlay); + + // Fetch main history + per-endpoint histories for Spotify + const historyPromises = [ + fetch(`/api/rate-monitor/history/${serviceKey}`).then(r => r.json()) + ]; + if (isSpotify) { + const activeEps = Object.keys(_rateMonitorState.spotify?.endpoints || {}); + for (const ep of activeEps) { + historyPromises.push( + fetch(`/api/rate-monitor/history/spotify:${ep}`).then(r => r.json()).catch(() => null) + ); + } + } + Promise.all(historyPromises).then(results => { + const main = results[0]; + const epHistories = isSpotify ? results.slice(1).filter(Boolean) : []; + _renderRateChart(main.history || [], main.rate_limit || 60, accent, epHistories); + }).catch(() => {}); + + if (isSpotify) { + _updateSpotifyEndpoints(); + _rateModalInterval = setInterval(_updateSpotifyEndpoints, 1000); + } +} + +function _closeRateModal() { + const overlay = document.getElementById('rate-modal-overlay'); + if (overlay) overlay.remove(); + if (_rateModalInterval) { clearInterval(_rateModalInterval); _rateModalInterval = null; } + _rateModalService = null; +} + +function _renderRateChart(history, rateLimit, accent, epHistories = []) { + const canvas = document.getElementById('rate-modal-chart'); + if (!canvas) return; + + // HiDPI support + const dpr = window.devicePixelRatio || 1; + const W = 700, H = 280; + canvas.width = W * dpr; + canvas.height = H * dpr; + canvas.style.width = W + 'px'; + canvas.style.height = H + 'px'; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + + const pad = { top: 24, right: 24, bottom: 36, left: 50 }; + const plotW = W - pad.left - pad.right; + const plotH = H - pad.top - pad.bottom; + + ctx.clearRect(0, 0, W, H); + + // Build data points + const now = Math.floor(Date.now() / 1000); + const start = now - 86400; + const points = []; + + if (history.length > 0) { + const histMap = new Map(history.map(h => [h[0], h[1]])); + for (let t = start; t <= now; t += 300) { + const bucket = Math.floor(t / 60) * 60; + let sum = 0; + for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0; + points.push({ t, v: sum / 5 }); + } + } + + const maxVal = Math.max(rateLimit * 1.15, ...points.map(p => p.v), 1); + + // Grid lines (horizontal) + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; + ctx.lineWidth = 1; + for (let i = 1; i <= 4; i++) { + const y = pad.top + plotH * (1 - i / 4); + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(pad.left + plotW, y); + ctx.stroke(); + } + + // Danger zone band + const dangerY = pad.top + plotH * (1 - rateLimit / maxVal); + const grad = ctx.createLinearGradient(0, pad.top, 0, dangerY); + grad.addColorStop(0, 'rgba(239, 68, 68, 0.08)'); + grad.addColorStop(1, 'rgba(239, 68, 68, 0.02)'); + ctx.fillStyle = grad; + ctx.fillRect(pad.left, pad.top, plotW, dangerY - pad.top); + + // Rate limit line + ctx.strokeStyle = 'rgba(239, 68, 68, 0.5)'; + ctx.setLineDash([8, 5]); + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(pad.left, dangerY); + ctx.lineTo(pad.left + plotW, dangerY); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.fillStyle = 'rgba(239, 68, 68, 0.6)'; + ctx.font = '10px -apple-system, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(`Rate limit: ${rateLimit}/min`, pad.left + 6, dangerY - 6); + + // Draw area fill + line + if (points.length > 1) { + // Area gradient fill + const areaGrad = ctx.createLinearGradient(0, pad.top, 0, pad.top + plotH); + // Parse accent to rgba + areaGrad.addColorStop(0, accent + '30'); + areaGrad.addColorStop(1, accent + '05'); + + ctx.beginPath(); + points.forEach((p, i) => { + const x = pad.left + (i / (points.length - 1)) * plotW; + const y = pad.top + plotH * (1 - p.v / maxVal); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.lineTo(pad.left + plotW, pad.top + plotH); + ctx.lineTo(pad.left, pad.top + plotH); + ctx.closePath(); + ctx.fillStyle = areaGrad; + ctx.fill(); + + // Line + ctx.beginPath(); + points.forEach((p, i) => { + const x = pad.left + (i / (points.length - 1)) * plotW; + const y = pad.top + plotH * (1 - p.v / maxVal); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.strokeStyle = accent; + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // Glow effect + ctx.shadowColor = accent; + ctx.shadowBlur = 8; + ctx.stroke(); + ctx.shadowBlur = 0; + } + + // Per-endpoint lines (Spotify breakdown) + const legendEl = document.getElementById('rate-modal-chart-legend'); + if (epHistories.length > 0) { + const epColors = ['#1DB954', '#FF6B6B', '#4ECDC4', '#FFE66D', '#A78BFA', '#F97316', '#06B6D4', '#EC4899', '#F472B6', '#34D399']; + const legendItems = []; + + epHistories.forEach((epData, idx) => { + if (!epData || !epData.history || epData.history.length === 0) return; + const epName = (epData.service || '').replace('spotify:', '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + const color = epColors[idx % epColors.length]; + legendItems.push({ name: epName, color }); + + const histMap = new Map(epData.history.map(h => [h[0], h[1]])); + const epPoints = []; + for (let t = start; t <= now; t += 300) { + const bucket = Math.floor(t / 60) * 60; + let sum = 0; + for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0; + epPoints.push({ t, v: sum / 5 }); + } + + if (epPoints.length > 1) { + ctx.beginPath(); + epPoints.forEach((p, i) => { + const x = pad.left + (i / (epPoints.length - 1)) * plotW; + const y = pad.top + plotH * (1 - p.v / maxVal); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.strokeStyle = color + 'BB'; + ctx.lineWidth = 1.5; + ctx.lineJoin = 'round'; + ctx.stroke(); + } + }); + + // HTML legend below chart + if (legendEl && legendItems.length > 0) { + legendEl.innerHTML = legendItems.map(item => + `${item.name}` + ).join(''); + } + } else if (legendEl) { + legendEl.innerHTML = ''; + } + + // X-axis labels + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = '10px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + for (let i = 0; i <= 6; i++) { + const t = start + (86400 * i / 6); + const x = pad.left + (i / 6) * plotW; + const d = new Date(t * 1000); + const hr = d.getHours(); + const label = hr === 0 ? '12am' : hr < 12 ? `${hr}am` : hr === 12 ? '12pm' : `${hr - 12}pm`; + ctx.fillText(label, x, H - 10); + // Subtle vertical grid + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, pad.top + plotH); + ctx.stroke(); + } + + // Y-axis labels + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.textAlign = 'right'; + ctx.font = '10px -apple-system, sans-serif'; + for (let i = 0; i <= 4; i++) { + const v = maxVal * i / 4; + const y = pad.top + plotH * (1 - i / 4); + ctx.fillText(Math.round(v), pad.left - 8, y + 4); + } + + // Empty state + if (points.length === 0) { + ctx.fillStyle = 'rgba(255,255,255,0.15)'; + ctx.font = '13px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('No call history yet — data populates as API calls are made', W / 2, H / 2); + } +} + +function _updateSpotifyEndpoints() { + const container = document.getElementById('rate-modal-endpoints'); + if (!container) return; + const endpoints = _rateMonitorState.spotify?.endpoints || {}; + const entries = Object.entries(endpoints).sort((a, b) => b[1] - a[1]); + + if (entries.length === 0) { + container.innerHTML = '
No active Spotify endpoints — start an enrichment worker or search to see activity
'; + return; + } + + const limit = _rateMonitorState.spotify?.limit || 171; + container.innerHTML = entries.map(([ep, cpm]) => { + const pct = Math.min(cpm / limit * 100, 100); + const name = ep.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + const color = pct > 80 ? '#ef4444' : pct > 60 ? '#eab308' : '#1DB954'; + return `
+ ${name} +
+ ${Math.round(cpm)}/min +
`; + }).join(''); +} + async function fetchAndUpdateSystemStats() { if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible diff --git a/webui/static/style.css b/webui/static/style.css index 5eb35402..732ae009 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -8189,6 +8189,301 @@ body.helper-mode-active #dashboard-activity-feed:hover { } +/* ========================================= */ +/* API Rate Monitor Gauges */ +/* ========================================= */ + +.rate-monitor-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 14px; + 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); + cursor: pointer; + transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(8px); + overflow: visible; +} + +.rate-gauge-card:hover { + background: rgba(20, 20, 28, 0.8); + border-color: rgba(255, 255, 255, 0.1); + transform: translateY(-3px); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); +} + +.rate-gauge-card.active { + border-color: rgba(255, 255, 255, 0.06); +} + +.rate-gauge-card.danger { + border-color: rgba(239, 68, 68, 0.35); + 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); } +} + +.rate-gauge-card.rate-limited { + border-color: rgba(239, 68, 68, 0.4); + animation: gauge-danger-pulse 1.8s ease-in-out infinite; +} + +.gauge-rl-badge { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: 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); + font-size: 9px; + font-weight: 700; + color: #ef4444; + letter-spacing: 0.5px; + white-space: nowrap; +} + +.gauge-rl-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #ef4444; + animation: gauge-rl-blink 1s ease-in-out infinite; +} + +@keyframes gauge-rl-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.gauge-rl-time { + color: rgba(239, 68, 68, 0.6); + font-weight: 500; +} + +.rate-gauge-svg { + width: 100%; + height: auto; + overflow: visible; +} + +.gauge-active-arc { + transition: d 0.6s cubic-bezier(0.4, 0, 0.2, 1), stroke 0.4s ease; +} + +.gauge-dot { + transition: cx 0.6s cubic-bezier(0.4, 0, 0.2, 1), cy 0.6s cubic-bezier(0.4, 0, 0.2, 1), fill 0.4s ease; +} + +.gauge-dot + circle { + transition: cx 0.6s cubic-bezier(0.4, 0, 0.2, 1), cy 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +.gauge-value { + font-family: -apple-system, 'SF Pro Display', 'Segoe UI', sans-serif; + font-size: 32px; + font-weight: 800; + fill: rgba(255, 255, 255, 0.95); + letter-spacing: -1px; +} + +.gauge-unit { + font-family: -apple-system, sans-serif; + font-size: 10px; + 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; +} + +/* Rate Monitor Detail Modal */ +.rate-modal { + background: #141418; + border-radius: 20px; + width: 740px; + max-width: 95vw; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.7); + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.rate-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.rate-modal-header-info { + display: flex; + align-items: center; + gap: 12px; +} + +.rate-modal-header-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.rate-modal-header h3 { + margin: 0; + font-size: 16px; + color: #fff; + font-weight: 600; +} + +.rate-modal-header-sub { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); +} + +.rate-modal-body { + padding: 20px 24px; + overflow-y: auto; +} + +.rate-modal-chart-wrap { + background: rgba(0, 0, 0, 0.3); + border-radius: 14px; + padding: 16px; + margin-bottom: 20px; + border: 1px solid rgba(255, 255, 255, 0.03); +} + +.rate-modal-chart-wrap canvas { + width: 100%; + height: auto; + display: block; +} + +.rate-modal-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + padding: 10px 4px 0; + min-height: 0; +} + +.rate-chart-legend-item { + display: flex; + align-items: center; + gap: 5px; + font-size: 10px; + color: rgba(255, 255, 255, 0.45); + white-space: nowrap; +} + +.rate-chart-legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.rate-modal-section-title { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.4); + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 10px; +} + +.rate-modal-endpoints { + display: flex; + flex-direction: column; + gap: 6px; +} + +.rate-modal-ep { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 0; +} + +.rate-modal-ep-name { + width: 140px; + flex-shrink: 0; + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rate-modal-ep-bar { + flex: 1; + height: 8px; + background: rgba(255, 255, 255, 0.04); + border-radius: 4px; + overflow: hidden; +} + +.rate-modal-ep-fill { + height: 100%; + border-radius: 4px; + transition: width 0.5s ease, background 0.3s ease; + box-shadow: 0 0 8px rgba(255, 255, 255, 0.05); +} + +.rate-modal-ep-value { + width: 60px; + flex-shrink: 0; + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + text-align: right; +} + +.rate-modal-ep-empty { + text-align: center; + padding: 16px; + color: rgba(255, 255, 255, 0.2); + font-size: 12px; +} + +@media (max-width: 768px) { + .rate-monitor-grid { + grid-template-columns: repeat(3, 1fr); + gap: 10px; + } + .rate-gauge-card { padding: 10px 8px 6px; } +} + +@media (max-width: 480px) { + .rate-monitor-grid { + grid-template-columns: repeat(2, 1fr); + } +} + /* System Stats Grid */ .stats-grid-dashboard { display: grid;