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:
parent
525a09c840
commit
c5652b0d4b
5 changed files with 167 additions and 16 deletions
|
|
@ -14,6 +14,7 @@ import uuid
|
|||
import re
|
||||
import sqlite3
|
||||
import types
|
||||
import collections
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
|
@ -3866,6 +3867,36 @@ def index():
|
|||
|
||||
# --- 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():
|
||||
"""Get lightweight status for all enrichment services (no DB queries).
|
||||
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)()
|
||||
except Exception:
|
||||
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,
|
||||
'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,
|
||||
'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:
|
||||
services[key] = {
|
||||
'name': name,
|
||||
|
|
@ -3915,6 +3963,8 @@ def _get_enrichment_status():
|
|||
'running': False,
|
||||
'paused': False,
|
||||
'idle': False,
|
||||
'calls_1h': 0,
|
||||
'calls_24h': 0,
|
||||
}
|
||||
|
||||
# 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",
|
||||
"features": [
|
||||
"• 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",
|
||||
"• 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'",
|
||||
|
|
|
|||
|
|
@ -665,8 +665,13 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="enrichment-status-grid" id="enrichment-status-grid">
|
||||
<!-- Dynamically populated by JS -->
|
||||
<div class="enrichment-section">
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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: '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: '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' },
|
||||
|
|
|
|||
|
|
@ -36239,15 +36239,55 @@ function renderEnrichmentCards(enrichment) {
|
|||
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);"`
|
||||
: '';
|
||||
const titleAttr = selector && statusClass === 'not-configured'
|
||||
? 'title="Click to configure in Settings"'
|
||||
: `title="${svc.name} — ${statusLabel}"`;
|
||||
|
||||
// Build activity display — human-readable, not cryptic numbers
|
||||
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(`
|
||||
<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-name">${svc.name}</span>
|
||||
<span class="enrichment-chip-status">${statusLabel}</span>
|
||||
${activityHtml}
|
||||
<span class="enrichment-chip-status">${statusDisplay}</span>
|
||||
${metaHtml}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.enrichment-chip {
|
||||
|
|
@ -7594,8 +7611,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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 */
|
||||
.stats-grid-dashboard {
|
||||
|
|
|
|||
Loading…
Reference in a new issue