Combine enrichment pills into unified rate monitor cards, debounce idle

- Merge enrichment worker status into rate monitor WebSocket payload
- Hide old enrichment pills — rate monitor cards now show: service name,
  worker status badge, arc gauge, calls/min, 1h/24h counts, budget bar
- Debounce idle detection with 5s grace period — prevents status
  flickering between Running and Idle on every worker cycle
- Responsive grid layout with richer card design
This commit is contained in:
Broque Thomas 2026-04-02 11:17:04 -07:00
parent 93bde811ed
commit d5bb0dfaa1
4 changed files with 287 additions and 71 deletions

View file

@ -4009,6 +4009,9 @@ def _get_windowed_calls(key, current_total):
return max(0, current_total - oldest_1h_total), max(0, current_total - oldest_24h_total)
_idle_since = {} # worker_key -> timestamp when current_item first became None
_IDLE_GRACE_SECONDS = 5 # Don't report idle until current_item has been None this long
def _get_enrichment_status():
"""Get lightweight status for all enrichment services (no DB queries).
Reads worker properties directly to avoid expensive get_stats() calls."""
@ -4050,12 +4053,22 @@ def _get_enrichment_status():
total_processed = stats.get('matched', 0) + stats.get('not_found', 0) + stats.get('errors', 0)
calls_1h, calls_24h = _get_windowed_calls(key, total_processed)
# Debounced idle detection — only report idle after 5s of no current_item
has_item = getattr(worker, 'current_item', None) is not None
if has_item:
_idle_since.pop(key, None)
is_idle = False
else:
if key not in _idle_since:
_idle_since[key] = time.time()
is_idle = (time.time() - _idle_since[key]) >= _IDLE_GRACE_SECONDS
svc_data = {
'name': name,
'configured': configured,
'running': worker.running and is_alive and not worker.paused,
'paused': worker.paused,
'idle': is_alive and not worker.paused and getattr(worker, 'current_item', None) is None,
'idle': is_alive and not worker.paused and is_idle,
'calls_1h': calls_1h,
'calls_24h': calls_24h,
}
@ -48300,13 +48313,43 @@ _download_yield_override = set() # Workers the user explicitly resumed during d
def _emit_rate_monitor_loop():
"""Background thread that pushes API call rate data every 1 second for speedometer gauges."""
"""Background thread that pushes API call rate data every 1 second for speedometer gauges.
Also includes enrichment worker status so the combined cards have everything."""
# Map rate monitor service keys to enrichment status keys
_enrichment_key_map = {
'spotify': 'spotify_enrichment', 'itunes': 'itunes_enrichment',
'deezer': 'deezer_enrichment', 'lastfm': 'lastfm', 'genius': 'genius',
'musicbrainz': 'musicbrainz', 'audiodb': 'audiodb',
'tidal': 'tidal_enrichment', 'qobuz': 'qobuz_enrichment',
}
while True:
socketio.sleep(1)
try:
from core.api_call_tracker import api_call_tracker
payload = api_call_tracker.get_all_rates()
# Merge enrichment worker status into each service
try:
enrichment = _get_enrichment_status()
for svc_key, entry in payload.items():
enr_key = _enrichment_key_map.get(svc_key)
enr = enrichment.get(enr_key) if enr_key else None
if enr:
entry['worker'] = {
'status': 'not_configured' if not enr.get('configured') else
'paused' if enr.get('paused') else
'idle' if enr.get('idle') else
'running' if enr.get('running') else 'stopped',
'yield_reason': enr.get('yield_reason', ''),
'calls_1h': enr.get('calls_1h', 0),
'calls_24h': enr.get('calls_24h', 0),
}
if svc_key == 'spotify' and enr.get('daily_budget'):
entry['worker']['daily_budget'] = enr['daily_budget']
except Exception:
pass
# Add Spotify rate limit state
try:
if spotify_client:

View file

@ -669,19 +669,19 @@
</div>
</div>
</div>
<div class="enrichment-section">
<div class="enrichment-section" id="enrichment-pills-section" style="display:none">
<div class="enrichment-section-header">
<span class="enrichment-section-label">Enrichment Services</span>
</div>
<div class="enrichment-status-grid" id="enrichment-status-grid">
<!-- Dynamically populated by JS -->
<!-- Legacy pills — hidden when rate monitor active -->
</div>
</div>
</div>
<!-- API Rate Monitor Section -->
<!-- API Rate Monitor Section (replaces enrichment pills with richer cards) -->
<div class="dashboard-section" id="rate-monitor-section">
<h3 class="section-title">API Rate Monitor</h3>
<h3 class="section-title">Enrichment Services</h3>
<div class="rate-monitor-grid" id="rate-monitor-grid">
<!-- Populated dynamically by JS -->
</div>

View file

@ -37685,8 +37685,8 @@ const _RATE_GAUGE_COLORS = {
audiodb: '#00BCD4', tidal: '#00FFFF', qobuz: '#FF6B35',
};
// SVG constants — 240° arc, gap at bottom, extra padding for glow
const _G = { size: 220, cx: 110, cy: 116, r: 74, stroke: 10, startAngle: 240, totalArc: 240 };
// SVG constants — 240° arc, gap at bottom
const _G = { size: 160, cx: 80, cy: 84, r: 56, stroke: 8, startAngle: 240, totalArc: 240 };
function _gPt(angle, radius) {
const rad = (angle - 90) * Math.PI / 180;
@ -37726,36 +37726,112 @@ function _handleRateMonitorUpdate(data) {
const value = d.cpm || 0;
const max = d.limit || 60;
const pct = Math.min(value / max, 1);
let svg = container.querySelector('svg');
if (!svg) {
container.innerHTML = _buildGaugeSVG(svc, value, max);
} else {
_updateGauge(container, value, max, svc);
}
container.classList.toggle('danger', pct > 0.8);
container.classList.toggle('active', value > 0);
// Rate limited badge (Spotify only for now)
const accent = _RATE_GAUGE_COLORS[svc] || '#888';
const label = _RATE_GAUGE_LABELS[svc] || svc;
const worker = d.worker || {};
const wStatus = worker.status || 'stopped';
const isRateLimited = d.rate_limited === true;
container.classList.toggle('rate-limited', isRateLimited);
let badge = container.querySelector('.gauge-rl-badge');
if (isRateLimited) {
const mins = Math.ceil((d.rl_remaining || 0) / 60);
const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
if (!badge) {
badge = document.createElement('div');
badge.className = 'gauge-rl-badge';
container.appendChild(badge);
// Build or update the card content
let gaugeWrap = container.querySelector('.gauge-arc-wrap');
if (!gaugeWrap) {
// Full rebuild
container.innerHTML = `
<div class="gauge-card-header">
<span class="gauge-card-dot" style="background:${accent}"></span>
<span class="gauge-card-name">${label}</span>
<span class="gauge-card-status" data-status="${wStatus}">${_workerStatusLabel(wStatus, worker)}</span>
</div>
<div class="gauge-arc-wrap">${_buildGaugeSVG(svc, value, max)}</div>
<div class="gauge-card-stats">
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${value.toFixed(0)}</span><span class="gauge-card-stat-label">calls/min</span></div>
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${worker.calls_1h || 0}</span><span class="gauge-card-stat-label">last hour</span></div>
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${(worker.calls_24h || 0).toLocaleString()}</span><span class="gauge-card-stat-label">24h</span></div>
</div>
${svc === 'spotify' && worker.daily_budget ? _buildBudgetBar(worker.daily_budget) : ''}
${isRateLimited ? _buildRateLimitBadge(d) : ''}
`;
} else {
// Fast update — only change values
_updateGauge(gaugeWrap, value, max, svc);
// Update status
const statusEl = container.querySelector('.gauge-card-status');
if (statusEl) {
statusEl.dataset.status = wStatus;
statusEl.textContent = _workerStatusLabel(wStatus, worker);
}
// Update stats
const statVals = container.querySelectorAll('.gauge-card-stat-val');
if (statVals[0]) statVals[0].textContent = value.toFixed(0);
if (statVals[1]) statVals[1].textContent = worker.calls_1h || 0;
if (statVals[2]) statVals[2].textContent = (worker.calls_24h || 0).toLocaleString();
// Budget bar (Spotify)
if (svc === 'spotify' && worker.daily_budget) {
let budgetEl = container.querySelector('.gauge-budget-bar');
if (!budgetEl) {
const div = document.createElement('div');
div.innerHTML = _buildBudgetBar(worker.daily_budget);
const statsEl = container.querySelector('.gauge-card-stats');
if (statsEl) statsEl.after(div.firstElementChild);
} else {
const b = worker.daily_budget;
const pctB = Math.min(100, Math.round((b.used / b.limit) * 100));
const fill = budgetEl.querySelector('.gauge-budget-fill');
if (fill) { fill.style.width = pctB + '%'; }
const label = budgetEl.querySelector('.gauge-budget-label');
if (label) label.textContent = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()} daily`;
}
}
// Rate limit badge
let badge = container.querySelector('.gauge-rl-badge');
if (isRateLimited) {
if (!badge) {
const div = document.createElement('div');
div.innerHTML = _buildRateLimitBadge(d);
container.appendChild(div.firstElementChild);
} else {
const mins = Math.ceil((d.rl_remaining || 0) / 60);
const timeEl = badge.querySelector('.gauge-rl-time');
if (timeEl) timeEl.textContent = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
}
} else if (badge) {
badge.remove();
}
badge.innerHTML = `<span class="gauge-rl-dot"></span>RATE LIMITED<span class="gauge-rl-time">${text}</span>`;
} else if (badge) {
badge.remove();
}
container.classList.toggle('danger', pct > 0.8 || isRateLimited);
container.classList.toggle('active', value > 0 || wStatus === 'running');
container.classList.toggle('rate-limited', isRateLimited);
}
}
function _workerStatusLabel(status, worker) {
if (status === 'not_configured') return 'Not configured';
if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused';
if (status === 'idle') return 'Idle';
if (status === 'running') return 'Running';
return 'Stopped';
}
function _buildBudgetBar(budget) {
const pct = Math.min(100, Math.round((budget.used / budget.limit) * 100));
const cls = budget.exhausted ? 'exhausted' : pct > 80 ? 'high' : '';
return `<div class="gauge-budget-bar ${cls}">
<div class="gauge-budget-fill" style="width:${pct}%"></div>
<span class="gauge-budget-label">${budget.used.toLocaleString()} / ${budget.limit.toLocaleString()} daily</span>
</div>`;
}
function _buildRateLimitBadge(d) {
const mins = Math.ceil((d.rl_remaining || 0) / 60);
const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
return `<div class="gauge-rl-badge"><span class="gauge-rl-dot"></span>RATE LIMITED<span class="gauge-rl-time">${text}</span></div>`;
}
function _buildGaugeSVG(svc, value, max) {
const { size, cx, cy, r, stroke, startAngle, totalArc } = _G;
const label = _RATE_GAUGE_LABELS[svc] || svc;

View file

@ -8195,45 +8195,44 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.rate-monitor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
padding: 8px 0;
}
.rate-gauge-card {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 4px 4px;
border-radius: 16px;
background: rgba(12, 12, 16, 0.6);
border: 1px solid rgba(255, 255, 255, 0.04);
flex-direction: column;
padding: 14px 14px 10px;
border-radius: 14px;
background: rgba(18, 18, 22, 0.7);
border: 1px solid rgba(255, 255, 255, 0.05);
cursor: pointer;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(8px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(12px);
overflow: visible;
}
.rate-gauge-card:hover {
background: rgba(20, 20, 28, 0.8);
background: rgba(24, 24, 30, 0.85);
border-color: rgba(255, 255, 255, 0.1);
transform: translateY(-3px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
}
.rate-gauge-card.active {
border-color: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.07);
}
.rate-gauge-card.danger {
border-color: rgba(239, 68, 68, 0.35);
border-color: rgba(239, 68, 68, 0.3);
animation: gauge-danger-pulse 1.8s ease-in-out infinite;
}
@keyframes gauge-danger-pulse {
0%, 100% { box-shadow: 0 0 0 rgba(239, 68, 68, 0), inset 0 0 0 rgba(239, 68, 68, 0); }
50% { box-shadow: 0 0 24px rgba(239, 68, 68, 0.15), inset 0 0 20px rgba(239, 68, 68, 0.03); }
0%, 100% { box-shadow: 0 0 0 rgba(239, 68, 68, 0); }
50% { box-shadow: 0 0 20px rgba(239, 68, 68, 0.12); }
}
.rate-gauge-card.rate-limited {
@ -8241,23 +8240,122 @@ body.helper-mode-active #dashboard-activity-feed:hover {
animation: gauge-danger-pulse 1.8s ease-in-out infinite;
}
.gauge-rl-badge {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
/* Card header — service name + status */
.gauge-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.gauge-card-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.gauge-card-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.3px;
}
.gauge-card-status {
font-size: 10px;
font-weight: 500;
padding: 2px 7px;
border-radius: 10px;
letter-spacing: 0.3px;
}
.gauge-card-status[data-status="running"] { background: rgba(34, 197, 94, 0.12); color: #4ade80; }
.gauge-card-status[data-status="idle"] { background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.35); }
.gauge-card-status[data-status="paused"] { background: rgba(234, 179, 8, 0.12); color: #fbbf24; }
.gauge-card-status[data-status="stopped"] { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); }
.gauge-card-status[data-status="not_configured"] { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); }
/* Arc wrap */
.gauge-arc-wrap {
display: flex;
justify-content: center;
margin: -4px 0;
}
/* Stats row below gauge */
.gauge-card-stats {
display: flex;
justify-content: space-between;
padding: 0 4px;
margin-top: -2px;
}
.gauge-card-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
}
.gauge-card-stat-val {
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.8);
}
.gauge-card-stat-label {
font-size: 8px;
color: rgba(255, 255, 255, 0.25);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Spotify budget bar */
.gauge-budget-bar {
position: relative;
height: 4px;
background: rgba(255, 255, 255, 0.06);
border-radius: 2px;
margin: 6px 4px 2px;
overflow: hidden;
}
.gauge-budget-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, rgba(29, 185, 84, 0.7), #1DB954);
transition: width 0.8s ease;
}
.gauge-budget-bar.high .gauge-budget-fill { background: linear-gradient(90deg, rgba(245, 166, 35, 0.7), #f5a623); }
.gauge-budget-bar.exhausted .gauge-budget-fill { background: linear-gradient(90deg, rgba(239, 68, 68, 0.7), #ef4444); }
.gauge-budget-label {
position: absolute;
top: 6px;
left: 0;
width: 100%;
text-align: center;
font-size: 8px;
color: rgba(255, 255, 255, 0.2);
}
/* Rate limit badge */
.gauge-rl-badge {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 3px 10px;
border-radius: 20px;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.25);
padding: 4px 10px;
margin-top: 6px;
border-radius: 8px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
font-size: 9px;
font-weight: 700;
color: #ef4444;
letter-spacing: 0.5px;
white-space: nowrap;
}
.gauge-rl-dot {
@ -8274,12 +8372,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.gauge-rl-time {
color: rgba(239, 68, 68, 0.6);
color: rgba(239, 68, 68, 0.5);
font-weight: 500;
}
/* SVG gauge */
.rate-gauge-svg {
width: 100%;
max-width: 160px;
height: auto;
overflow: visible;
}
@ -8298,24 +8398,20 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.gauge-value {
font-family: -apple-system, 'SF Pro Display', 'Segoe UI', sans-serif;
font-size: 32px;
font-size: 24px;
font-weight: 800;
fill: rgba(255, 255, 255, 0.95);
letter-spacing: -1px;
letter-spacing: -0.5px;
}
.gauge-unit {
font-family: -apple-system, sans-serif;
font-size: 10px;
font-size: 9px;
fill: rgba(255, 255, 255, 0.2);
}
.gauge-label {
font-family: -apple-system, sans-serif;
font-size: 11px;
font-weight: 600;
fill: rgba(255, 255, 255, 0.45);
letter-spacing: 0.5px;
display: none; /* Label is now in the card header, not the SVG */
}
/* Rate Monitor Detail Modal */
@ -8470,18 +8566,19 @@ body.helper-mode-active #dashboard-activity-feed:hover {
font-size: 12px;
}
@media (max-width: 768px) {
@media (max-width: 900px) {
.rate-monitor-grid {
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.rate-gauge-card { padding: 10px 8px 6px; }
}
@media (max-width: 480px) {
@media (max-width: 600px) {
.rate-monitor-grid {
grid-template-columns: repeat(2, 1fr);
}
.gauge-card-stats { gap: 2px; }
.gauge-card-stat-val { font-size: 11px; }
}
/* System Stats Grid */