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:
parent
9bcff9b43d
commit
559b89353f
14 changed files with 1021 additions and 3 deletions
163
core/api_call_tracker.py
Normal file
163
core/api_call_tracker.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()">×</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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue