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 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'",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue