Add API call counts and Spotify budget to dashboard service chips

Enrichment chips now show live activity: 24h call count for all
services and daily budget usage (used/3,000) with gradient progress
bar for Spotify. Tracking is centralized in _get_enrichment_status
using cumulative stat diffs over a rolling deque — no worker files
modified. Added section header, "Configure →" label for unconfigured
services, and full 1h/24h breakdown in tooltips.
This commit is contained in:
Broque Thomas 2026-03-30 13:20:22 -07:00
parent 525a09c840
commit c5652b0d4b
5 changed files with 167 additions and 16 deletions

View file

@ -14,6 +14,7 @@ import uuid
import re import re
import sqlite3 import sqlite3
import types import types
import collections
from pathlib import Path from pathlib import Path
from urllib.parse import urljoin from urllib.parse import urljoin
@ -3866,6 +3867,36 @@ def index():
# --- API Endpoints --- # --- API Endpoints ---
# Tracks cumulative item-processed totals over time for windowed counting.
# Each entry: (timestamp, cumulative_total). Polled every ~5s, 24h = ~17280 entries.
_enrichment_activity_log = {} # key -> deque of (timestamp, total)
def _get_windowed_calls(key, current_total):
"""Record current cumulative total and return (calls_1h, calls_24h).
Deque stores (timestamp, cumulative_total) in chronological order.
To get calls in a window: current_total minus the oldest total within that window."""
now = time.time()
history = _enrichment_activity_log.setdefault(key, collections.deque(maxlen=17300))
history.append((now, current_total))
cutoff_1h = now - 3600
cutoff_24h = now - 86400
# Forward scan: first entry with ts >= cutoff is the oldest in that window
oldest_1h_total = current_total
oldest_24h_total = current_total
found_24h = False
for ts, total in history:
if not found_24h and ts >= cutoff_24h:
oldest_24h_total = total
found_24h = True
if ts >= cutoff_1h:
oldest_1h_total = total
break
return max(0, current_total - oldest_1h_total), max(0, current_total - oldest_24h_total)
def _get_enrichment_status(): def _get_enrichment_status():
"""Get lightweight status for all enrichment services (no DB queries). """Get lightweight status for all enrichment services (no DB queries).
Reads worker properties directly to avoid expensive get_stats() calls.""" Reads worker properties directly to avoid expensive get_stats() calls."""
@ -3901,13 +3932,30 @@ def _get_enrichment_status():
configured = configured_checks.get(key, lambda: True)() configured = configured_checks.get(key, lambda: True)()
except Exception: except Exception:
configured = False configured = False
services[key] = {
# Compute windowed API call counts from cumulative stats
stats = worker.stats
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)
svc_data = {
'name': name, 'name': name,
'configured': configured, 'configured': configured,
'running': worker.running and is_alive and not worker.paused, 'running': worker.running and is_alive and not worker.paused,
'paused': 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 getattr(worker, 'current_item', None) is None,
'calls_1h': calls_1h,
'calls_24h': calls_24h,
} }
# Spotify-specific: include daily budget info
if key == 'spotify_enrichment':
try:
svc_data['daily_budget'] = worker._get_daily_budget_info()
except Exception:
pass
services[key] = svc_data
else: else:
services[key] = { services[key] = {
'name': name, 'name': name,
@ -3915,6 +3963,8 @@ def _get_enrichment_status():
'running': False, 'running': False,
'paused': False, 'paused': False,
'idle': False, 'idle': False,
'calls_1h': 0,
'calls_24h': 0,
} }
# Non-worker services (configured status only) # Non-worker services (configured status only)
@ -19190,7 +19240,8 @@ def get_version_info():
"description": "Dashboard now shows connection status for all external services, not just the core three", "description": "Dashboard now shows connection status for all external services, not just the core three",
"features": [ "features": [
"• Enrichment services shown as color-coded chips below core service cards", "• Enrichment services shown as color-coded chips below core service cards",
"• Status indicators: Running, Idle, Paused, Stopped, or Not Configured with accent-colored left borders", "• API call counts per service: 1-hour and 24-hour windowed totals shown on each chip",
"• Spotify chip includes daily budget bar (used/3000) with color-coded fill",
"• Unconfigured services show dashed border — click to jump directly to their Settings section", "• Unconfigured services show dashed border — click to jump directly to their Settings section",
"• All configurable services clickable — navigates to Settings → Connections and scrolls to the service", "• All configurable services clickable — navigates to Settings → Connections and scrolls to the service",
"• Spotify card always labeled 'Spotify' — no longer confusingly switches to 'Apple Music'", "• Spotify card always labeled 'Spotify' — no longer confusingly switches to 'Apple Music'",

View file

@ -665,8 +665,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="enrichment-status-grid" id="enrichment-status-grid"> <div class="enrichment-section">
<!-- Dynamically populated by JS --> <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 -->
</div>
</div> </div>
</div> </div>

View file

@ -3406,7 +3406,7 @@ const WHATS_NEW = {
{ title: 'Fix Collab Artist on Singles', desc: 'Single/playlist path templates now respect First Listed Artist setting — $albumartist available for all template types' }, { title: 'Fix Collab Artist on Singles', desc: 'Single/playlist path templates now respect First Listed Artist setting — $albumartist available for all template types' },
{ title: 'Fix Enrichment Breaking Manual Matches', desc: 'Enriching a manually matched artist no longer reverts status to not_found — uses stored ID for direct lookup' }, { title: 'Fix Enrichment Breaking Manual Matches', desc: 'Enriching a manually matched artist no longer reverts status to not_found — uses stored ID for direct lookup' },
{ title: 'Fix Spotify OAuth Empty Response', desc: 'OAuth callback server now always sends a response in Docker — added health check and proper logging' }, { title: 'Fix Spotify OAuth Empty Response', desc: 'OAuth callback server now always sends a response in Docker — added health check and proper logging' },
{ title: 'All Services on Dashboard', desc: 'Dashboard shows all enrichment services as live-status chips — click unconfigured ones to jump to Settings. Spotify card no longer shows "Apple Music"', page: 'dashboard' }, { title: 'All Services on Dashboard', desc: 'Dashboard shows all enrichment services with live API call counts (1h/24h), Spotify budget bar, and click-to-configure', page: 'dashboard' },
{ title: 'Qobuz on Connections Tab', desc: 'Qobuz credentials now on Settings → Connections for metadata enrichment without needing it as download source' }, { title: 'Qobuz on Connections Tab', desc: 'Qobuz credentials now on Settings → Connections for metadata enrichment without needing it as download source' },
{ title: 'Fix Enrichment Status Widget', desc: 'Enrichment tooltip now shows Rate Limited or Daily Limit instead of stuck on Running' }, { title: 'Fix Enrichment Status Widget', desc: 'Enrichment tooltip now shows Rate Limited or Daily Limit instead of stuck on Running' },
{ title: 'Cache Maintenance', desc: 'Cache evictor now cleans junk entities, orphaned searches, and stale MusicBrainz nulls' }, { title: 'Cache Maintenance', desc: 'Cache evictor now cleans junk entities, orphaned searches, and stale MusicBrainz nulls' },

View file

@ -36239,15 +36239,55 @@ function renderEnrichmentCards(enrichment) {
const clickAttr = selector const clickAttr = selector
? `onclick="navigateToPage('settings'); setTimeout(() => { switchSettingsTab('connections'); setTimeout(() => { const el = document.querySelector('${selector}'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); }, 50);"` ? `onclick="navigateToPage('settings'); setTimeout(() => { switchSettingsTab('connections'); setTimeout(() => { const el = document.querySelector('${selector}'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); }, 50);"`
: ''; : '';
const titleAttr = selector && statusClass === 'not-configured'
? 'title="Click to configure in Settings"' // Build activity display — human-readable, not cryptic numbers
: `title="${svc.name}${statusLabel}"`; let activityHtml = '';
let metaHtml = '';
const isSpotify = key === 'spotify_enrichment';
if ('running' in svc && svc.configured) {
const c1h = svc.calls_1h || 0;
const c24h = svc.calls_24h || 0;
if (isSpotify && svc.daily_budget) {
// Spotify: show budget usage prominently
const b = svc.daily_budget;
const pct = Math.min(100, Math.round((b.used / b.limit) * 100));
const barClass = b.exhausted ? 'exhausted' : pct > 80 ? 'high' : '';
activityHtml = `<span class="enrichment-chip-activity">${b.used.toLocaleString()} / ${b.limit.toLocaleString()}</span>`;
metaHtml = `<div class="enrichment-chip-budget">
<div class="enrichment-chip-budget-bar ${barClass}" style="width: ${pct}%"></div>
</div>`;
} else if (c24h > 0) {
// Other services: show 24h count
activityHtml = `<span class="enrichment-chip-activity">${c24h.toLocaleString()} / 24h</span>`;
}
}
// Tooltip: full details including 1h breakdown
let tooltipLines = [svc.name + ' — ' + statusLabel];
if ('running' in svc && svc.configured) {
const c1h = svc.calls_1h || 0;
const c24h = svc.calls_24h || 0;
if (c24h > 0 || c1h > 0) tooltipLines.push('Last hour: ' + c1h + ' · Last 24h: ' + c24h);
}
if (isSpotify && svc.daily_budget) {
const b = svc.daily_budget;
tooltipLines.push('Daily budget: ' + b.used + ' / ' + b.limit + (b.exhausted ? ' (exhausted)' : ''));
}
if (selector && statusClass === 'not-configured') {
tooltipLines = ['Click to configure in Settings'];
}
const statusDisplay = statusClass === 'not-configured' && selector ? 'Configure →' : statusLabel;
chips.push(` chips.push(`
<div class="enrichment-chip status-${statusClass}" ${clickAttr} ${titleAttr}> <div class="enrichment-chip status-${statusClass}" ${clickAttr} title="${tooltipLines.join('\n')}">
<span class="enrichment-chip-dot"></span> <span class="enrichment-chip-dot"></span>
<span class="enrichment-chip-name">${svc.name}</span> <span class="enrichment-chip-name">${svc.name}</span>
<span class="enrichment-chip-status">${statusLabel}</span> ${activityHtml}
<span class="enrichment-chip-status">${statusDisplay}</span>
${metaHtml}
</div> </div>
`); `);
} }

View file

@ -7462,14 +7462,31 @@ body.helper-mode-active #dashboard-activity-feed:hover {
} }
/* Enrichment Services Grid */ /* Enrichment Services Section */
.enrichment-section {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.enrichment-section-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.enrichment-section-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: rgba(255, 255, 255, 0.3);
}
.enrichment-status-grid { .enrichment-status-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
} }
.enrichment-chip { .enrichment-chip {
@ -7594,8 +7611,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
font-size: 10px; font-size: 10px;
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
white-space: nowrap; white-space: nowrap;
margin-left: auto;
padding-left: 8px;
} }
.enrichment-chip.status-not-configured .enrichment-chip-status { .enrichment-chip.status-not-configured .enrichment-chip-status {
@ -7610,6 +7625,46 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: rgba(255, 71, 87, 0.7); color: rgba(255, 71, 87, 0.7);
} }
.enrichment-chip-activity {
font-size: 10px;
color: rgba(255, 255, 255, 0.35);
white-space: nowrap;
font-variant-numeric: tabular-nums;
margin-left: auto;
padding-left: 8px;
}
/* Spotify budget bar — parent chip needs extra bottom padding */
.enrichment-chip:has(.enrichment-chip-budget) {
padding-bottom: 12px;
}
.enrichment-chip-budget {
position: absolute;
bottom: 3px;
left: 8px;
right: 8px;
height: 3px;
background: rgba(255, 255, 255, 0.06);
border-radius: 3px;
overflow: hidden;
}
.enrichment-chip-budget-bar {
height: 100%;
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.7), var(--accent));
border-radius: 3px;
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
.enrichment-chip-budget-bar.high {
background: linear-gradient(90deg, rgba(245, 166, 35, 0.7), #f5a623);
}
.enrichment-chip-budget-bar.exhausted {
background: linear-gradient(90deg, rgba(255, 71, 87, 0.7), #ff4757);
}
/* System Stats Grid */ /* System Stats Grid */
.stats-grid-dashboard { .stats-grid-dashboard {