Add API Rate Monitor dashboard with real-time speedometer gauges

- New core/api_call_tracker.py — centralized tracker with rolling 60s
  timestamps (speedometer) and 24h minute-bucketed history (charts)
- Instrument all 9 service client rate_limited decorators to record
  actual API calls with per-endpoint tracking for Spotify
- 1-second WebSocket push loop for real-time gauge updates
- Modern radial arc gauges with service brand colors, glowing active
  arc, endpoint dot, 0/max scale labels, smooth CSS transitions
- Click any gauge to open detail modal with 24h call history chart
  (Canvas 2D, HiDPI, gradient fill, grid lines, danger zone band)
- Spotify modal shows per-endpoint history lines with color legend
  and live per-endpoint breakdown bars
- Rate limited state indicator — blinking red badge with countdown
  timer appears on gauge card when Spotify ban is active
- REST endpoint GET /api/rate-monitor/history/<service> for chart data
- Responsive grid layout (5 cols desktop, 3 tablet, 2 phone)
This commit is contained in:
Broque Thomas 2026-04-02 10:46:43 -07:00
parent 9bcff9b43d
commit 559b89353f
14 changed files with 1021 additions and 3 deletions

163
core/api_call_tracker.py Normal file
View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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."""

View file

@ -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:

View file

@ -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

View file

@ -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/<service_key>', 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)

View file

@ -679,6 +679,14 @@
</div>
</div>
<!-- API Rate Monitor Section -->
<div class="dashboard-section" id="rate-monitor-section">
<h3 class="section-title">API Rate Monitor</h3>
<div class="rate-monitor-grid" id="rate-monitor-grid">
<!-- Populated dynamically by JS -->
</div>
</div>
<!-- Recent Syncs Section -->
<div class="dashboard-section">
<h3 class="section-title">Recent Syncs</h3>

View file

@ -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 = `<span class="gauge-rl-dot"></span>RATE LIMITED<span class="gauge-rl-time">${text}</span>`;
} 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 `
<svg viewBox="0 0 ${size} ${size}" class="rate-gauge-svg">
<!-- Background track -->
<path d="${_gArc(startAngle, arcEnd)}" fill="none" stroke="rgba(255,255,255,0.05)" stroke-width="${stroke}" stroke-linecap="round"/>
<!-- Danger zone marker (last 20% of arc, subtle) -->
<path d="${_gArc(startAngle + totalArc * 0.8, arcEnd)}" fill="none" stroke="rgba(239,68,68,0.1)" stroke-width="${stroke}" stroke-linecap="round"/>
<!-- Active arc (CSS handles glow via drop-shadow) -->
${pct > 0 ? `<path class="gauge-active-arc" data-color="${color}" d="${_gArc(startAngle, endAngle)}" fill="none" stroke="${color}" stroke-width="${stroke}" stroke-linecap="round" style="filter:drop-shadow(0 0 6px ${color}60)"/>` : ''}
<!-- Endpoint dot -->
${dot ? `<circle class="gauge-dot" cx="${dot.x}" cy="${dot.y}" r="5" fill="${color}" style="filter:drop-shadow(0 0 4px ${color}80)"/><circle cx="${dot.x}" cy="${dot.y}" r="2.5" fill="#fff" opacity="0.9"/>` : ''}
<!-- Scale labels: 0 and max -->
<text x="${_gPt(startAngle, r + 16).x}" y="${_gPt(startAngle, r + 16).y}" text-anchor="middle" dominant-baseline="middle" fill="rgba(255,255,255,0.2)" font-size="9" font-family="-apple-system,sans-serif">0</text>
<text x="${_gPt(arcEnd, r + 16).x}" y="${_gPt(arcEnd, r + 16).y}" text-anchor="middle" dominant-baseline="middle" fill="rgba(255,255,255,0.2)" font-size="9" font-family="-apple-system,sans-serif">${max}</text>
<!-- Center content -->
<text class="gauge-value" x="${cx}" y="${cy - 12}" text-anchor="middle" dominant-baseline="middle">${Math.round(value)}</text>
<text class="gauge-unit" x="${cx}" y="${cy + 6}" text-anchor="middle">/min</text>
<!-- Service label -->
<text class="gauge-label" x="${cx}" y="${cy + 28}" text-anchor="middle">${label}</text>
</svg>
`;
}
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 = `
<div class="rate-modal">
<div class="rate-modal-header">
<div class="rate-modal-header-info">
<div class="rate-modal-header-dot" style="background:${accent}"></div>
<div>
<h3>${label}</h3>
<span class="rate-modal-header-sub">${currentData.cpm || 0} calls/min limit ${currentData.limit || '?'}/min</span>
</div>
</div>
<button class="watch-all-close" onclick="_closeRateModal()">&times;</button>
</div>
<div class="rate-modal-body">
<div class="rate-modal-section-title">24-Hour Call History</div>
<div class="rate-modal-chart-wrap">
<canvas id="rate-modal-chart" width="700" height="280"></canvas>
<div class="rate-modal-chart-legend" id="rate-modal-chart-legend"></div>
</div>
${isSpotify ? '<div class="rate-modal-section-title">Per-Endpoint Breakdown</div><div class="rate-modal-endpoints" id="rate-modal-endpoints"></div>' : ''}
</div>
</div>
`;
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 =>
`<span class="rate-chart-legend-item"><span class="rate-chart-legend-dot" style="background:${item.color}"></span>${item.name}</span>`
).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 = '<div class="rate-modal-ep-empty">No active Spotify endpoints — start an enrichment worker or search to see activity</div>';
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 `<div class="rate-modal-ep">
<span class="rate-modal-ep-name">${name}</span>
<div class="rate-modal-ep-bar"><div class="rate-modal-ep-fill" style="width:${pct}%;background:${color}"></div></div>
<span class="rate-modal-ep-value">${Math.round(cpm)}/min</span>
</div>`;
}).join('');
}
async function fetchAndUpdateSystemStats() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible

View file

@ -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;