Add metadata cache maintenance and health monitoring
Cache maintenance: - Input validation rejects junk entities (Unknown Artist, empty names) from being cached, with exemptions for synthetic entries (_features, _tracks suffixes) - CacheEvictorJob expanded to 4 phases: TTL eviction, junk cleanup, orphaned search cleanup, MusicBrainz failed lookup cleanup - MusicBrainz null results now expire after 30 days (was 90) so failed lookups get retried sooner Cache health UI: - Polished modal accessible from Dashboard "Cache Health" button and repair dashboard health bar - Shows health status banner (healthy/fair/poor), stat cards, source breakdown with colored progress bars, type pills, and metrics table - Repair dashboard shows compact bar with health dot indicator
This commit is contained in:
parent
4e6b424bc7
commit
59587162cd
8 changed files with 540 additions and 16 deletions
|
|
@ -90,12 +90,27 @@ class MetadataCache:
|
|||
logger.debug(f"Cache lookup error ({source}/{entity_type}/{entity_id}): {e}")
|
||||
return None
|
||||
|
||||
# Names that indicate junk/placeholder data — should not be cached
|
||||
_JUNK_NAMES = frozenset({
|
||||
'', 'unknown', 'unknown artist', 'unknown album', 'unknown track',
|
||||
'untitled', 'none', 'n/a', 'null',
|
||||
})
|
||||
|
||||
def _is_junk_entity(self, fields: dict) -> bool:
|
||||
"""Check if extracted fields represent junk/placeholder data."""
|
||||
name = (fields.get('name') or '').strip().lower()
|
||||
return name in self._JUNK_NAMES
|
||||
|
||||
def store_entity(self, source: str, entity_type: str, entity_id: str, raw_data: dict) -> None:
|
||||
"""Store an entity in the cache. Extracts structured fields from raw_data."""
|
||||
if not entity_id or not raw_data:
|
||||
return
|
||||
try:
|
||||
fields = self._extract_fields(source, entity_type, raw_data)
|
||||
# Skip validation for synthetic cache entries (_features, _tracks suffixes)
|
||||
if not entity_id.endswith('_features') and not entity_id.endswith('_tracks') and self._is_junk_entity(fields):
|
||||
logger.debug(f"Rejecting junk entity ({source}/{entity_type}/{entity_id}): name='{fields.get('name')}'")
|
||||
return
|
||||
raw_json = json.dumps(raw_data, default=str)
|
||||
db = self._get_db()
|
||||
conn = db._get_connection()
|
||||
|
|
@ -175,6 +190,8 @@ class MetadataCache:
|
|||
continue
|
||||
|
||||
fields = self._extract_fields(source, entity_type, raw_data)
|
||||
if self._is_junk_entity(fields):
|
||||
continue
|
||||
raw_json = json.dumps(raw_data, default=str)
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO metadata_cache_entities
|
||||
|
|
@ -556,6 +573,187 @@ class MetadataCache:
|
|||
logger.error(f"Cache eviction error: {e}")
|
||||
return 0
|
||||
|
||||
def clean_junk_entities(self) -> int:
|
||||
"""Delete cached entities with empty/placeholder names."""
|
||||
try:
|
||||
db = self._get_db()
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
junk_names = "', '".join(self._JUNK_NAMES - {''}) # exclude empty, handled separately
|
||||
cursor.execute(f"""
|
||||
DELETE FROM metadata_cache_entities
|
||||
WHERE (name IS NULL
|
||||
OR TRIM(name) = ''
|
||||
OR LOWER(TRIM(name)) IN ('{junk_names}'))
|
||||
AND entity_id NOT LIKE '%\\_features' ESCAPE '\\'
|
||||
AND entity_id NOT LIKE '%\\_tracks' ESCAPE '\\'
|
||||
""")
|
||||
count = cursor.rowcount
|
||||
conn.commit()
|
||||
if count > 0:
|
||||
logger.info(f"Cleaned {count} junk entities from cache")
|
||||
return count
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Junk cleanup error: {e}")
|
||||
return 0
|
||||
|
||||
def clean_orphaned_searches(self) -> int:
|
||||
"""Delete search results where <50% of referenced entities still exist."""
|
||||
try:
|
||||
db = self._get_db()
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id, source, search_type, result_ids FROM metadata_cache_searches")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
dead_ids = []
|
||||
for row in rows:
|
||||
try:
|
||||
result_ids = json.loads(row['result_ids'] or '[]')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
dead_ids.append(row['id'])
|
||||
continue
|
||||
|
||||
if not result_ids:
|
||||
dead_ids.append(row['id'])
|
||||
continue
|
||||
|
||||
# Check how many referenced entities still exist
|
||||
placeholders = ','.join('?' * len(result_ids))
|
||||
cursor.execute(f"""
|
||||
SELECT COUNT(*) FROM metadata_cache_entities
|
||||
WHERE source = ? AND entity_type = ? AND entity_id IN ({placeholders})
|
||||
""", [row['source'], row['search_type']] + list(result_ids))
|
||||
found = cursor.fetchone()[0]
|
||||
|
||||
if found < len(result_ids) * 0.5:
|
||||
dead_ids.append(row['id'])
|
||||
|
||||
if dead_ids:
|
||||
# Delete in chunks to stay under SQLite variable limit
|
||||
for i in range(0, len(dead_ids), 400):
|
||||
chunk = dead_ids[i:i + 400]
|
||||
placeholders = ','.join('?' * len(chunk))
|
||||
cursor.execute(f"DELETE FROM metadata_cache_searches WHERE id IN ({placeholders})", chunk)
|
||||
conn.commit()
|
||||
|
||||
count = len(dead_ids)
|
||||
if count > 0:
|
||||
logger.info(f"Cleaned {count} orphaned search results from cache")
|
||||
return count
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Orphan search cleanup error: {e}")
|
||||
return 0
|
||||
|
||||
def clean_stale_musicbrainz_nulls(self, max_age_days: int = 30) -> int:
|
||||
"""Delete MusicBrainz cache entries where lookup found nothing (null MBID) and age > max_age_days."""
|
||||
try:
|
||||
db = self._get_db()
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
DELETE FROM musicbrainz_cache
|
||||
WHERE musicbrainz_id IS NULL
|
||||
AND julianday('now') - julianday(last_updated) > ?
|
||||
""", (max_age_days,))
|
||||
count = cursor.rowcount
|
||||
conn.commit()
|
||||
if count > 0:
|
||||
logger.info(f"Cleaned {count} stale MusicBrainz null entries (>{max_age_days} days)")
|
||||
return count
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"MusicBrainz null cleanup error: {e}")
|
||||
return 0
|
||||
|
||||
def get_health_stats(self) -> dict:
|
||||
"""Return cache health statistics for the repair dashboard."""
|
||||
try:
|
||||
db = self._get_db()
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
stats = {}
|
||||
|
||||
# Total counts
|
||||
cursor.execute("SELECT COUNT(*) FROM metadata_cache_entities")
|
||||
stats['total_entities'] = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM metadata_cache_searches")
|
||||
stats['total_searches'] = cursor.fetchone()[0]
|
||||
|
||||
# Junk entity count
|
||||
junk_names = "', '".join(self._JUNK_NAMES - {''})
|
||||
cursor.execute(f"""
|
||||
SELECT COUNT(*) FROM metadata_cache_entities
|
||||
WHERE (name IS NULL OR TRIM(name) = '' OR LOWER(TRIM(name)) IN ('{junk_names}'))
|
||||
AND entity_id NOT LIKE '%\\_features' ESCAPE '\\'
|
||||
AND entity_id NOT LIKE '%\\_tracks' ESCAPE '\\'
|
||||
""")
|
||||
stats['junk_entities'] = cursor.fetchone()[0]
|
||||
|
||||
# By source
|
||||
cursor.execute("""
|
||||
SELECT source, COUNT(*) FROM metadata_cache_entities GROUP BY source
|
||||
""")
|
||||
stats['by_source'] = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
|
||||
# By entity type
|
||||
cursor.execute("""
|
||||
SELECT entity_type, COUNT(*) FROM metadata_cache_entities GROUP BY entity_type
|
||||
""")
|
||||
stats['by_type'] = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
|
||||
# Expiring soon
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM metadata_cache_entities
|
||||
WHERE julianday('now') - julianday(updated_at) > ttl_days - 1
|
||||
""")
|
||||
stats['expiring_24h'] = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM metadata_cache_entities
|
||||
WHERE julianday('now') - julianday(updated_at) > ttl_days - 7
|
||||
""")
|
||||
stats['expiring_7d'] = cursor.fetchone()[0]
|
||||
|
||||
# Average age
|
||||
cursor.execute("""
|
||||
SELECT AVG(julianday('now') - julianday(updated_at)) FROM metadata_cache_entities
|
||||
""")
|
||||
avg = cursor.fetchone()[0]
|
||||
stats['avg_age_days'] = round(avg, 1) if avg else 0
|
||||
|
||||
# MusicBrainz stats
|
||||
try:
|
||||
cursor.execute("SELECT COUNT(*) FROM musicbrainz_cache")
|
||||
stats['total_musicbrainz'] = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM musicbrainz_cache WHERE musicbrainz_id IS NULL")
|
||||
stats['stale_mb_nulls'] = cursor.fetchone()[0]
|
||||
except Exception:
|
||||
stats['total_musicbrainz'] = 0
|
||||
stats['stale_mb_nulls'] = 0
|
||||
|
||||
# Total access hits
|
||||
cursor.execute("SELECT SUM(access_count) FROM metadata_cache_entities")
|
||||
hits = cursor.fetchone()[0]
|
||||
stats['total_access_hits'] = hits or 0
|
||||
|
||||
return stats
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Cache health stats error: {e}")
|
||||
return {}
|
||||
|
||||
def clear(self, source: str = None, entity_type: str = None) -> int:
|
||||
"""Clear cache entries. Optional filters by source and/or entity_type."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -60,10 +60,11 @@ class MusicBrainzService:
|
|||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
# Don't use cache if it's older than 90 days
|
||||
# Shorter TTL for null results (failed lookups) so they get retried sooner
|
||||
last_updated = datetime.fromisoformat(row[3]) if row[3] else None
|
||||
if last_updated and (datetime.now() - last_updated).days > 90:
|
||||
logger.debug(f"Cache entry for {entity_type} '{entity_name}' is stale (> 90 days)")
|
||||
ttl_days = 30 if row[0] is None else 90 # row[0] is musicbrainz_id
|
||||
if last_updated and (datetime.now() - last_updated).days > ttl_days:
|
||||
logger.debug(f"Cache entry for {entity_type} '{entity_name}' is stale (> {ttl_days} days)")
|
||||
return None
|
||||
|
||||
# Parse JSON with error handling
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Metadata Cache Evictor Job — periodically cleans expired cache entries."""
|
||||
"""Metadata Cache Maintenance Job — cleans expired, junk, and orphaned cache entries."""
|
||||
|
||||
from core.repair_jobs import register_job
|
||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||
|
|
@ -10,14 +10,17 @@ logger = get_logger("repair_job.cache_evictor")
|
|||
@register_job
|
||||
class CacheEvictorJob(RepairJob):
|
||||
job_id = 'cache_evictor'
|
||||
display_name = 'Cache Evictor'
|
||||
description = 'Removes expired metadata cache entries'
|
||||
display_name = 'Cache Maintenance'
|
||||
description = 'Removes expired, junk, and orphaned metadata cache entries'
|
||||
help_text = (
|
||||
'Automatically cleans up stale metadata cache entries from the database. '
|
||||
'SoulSync caches search results, album metadata, and cover art URLs to avoid '
|
||||
'hitting API rate limits. Over time these entries expire and take up space.\n\n'
|
||||
'This is the only fully automatic job — it runs silently and removes expired '
|
||||
'entries without creating findings. Safe to leave enabled at all times.'
|
||||
'Maintains the metadata cache that stores search results, album metadata, '
|
||||
'and cover art URLs from Spotify, iTunes, and Deezer.\n\n'
|
||||
'Runs four maintenance phases:\n'
|
||||
'1. TTL eviction — removes entries past their expiration date\n'
|
||||
'2. Junk cleanup — removes entries with empty or placeholder names (Unknown Artist, etc.)\n'
|
||||
'3. Orphan cleanup — removes search results pointing to deleted entities\n'
|
||||
'4. MusicBrainz cleanup — removes stale "not found" entries older than 30 days\n\n'
|
||||
'Fully automatic — runs silently without creating findings. Safe to leave enabled.'
|
||||
)
|
||||
icon = 'repair-icon-cache'
|
||||
default_enabled = True
|
||||
|
|
@ -33,13 +36,57 @@ class CacheEvictorJob(RepairJob):
|
|||
logger.debug("No metadata cache available — skipping")
|
||||
return result
|
||||
|
||||
# Phase 1: Evict expired entries (existing behavior)
|
||||
try:
|
||||
evicted = cache.evict_expired()
|
||||
result.auto_fixed = evicted
|
||||
result.scanned = evicted
|
||||
logger.info("Cache evictor: removed %d expired entries", evicted)
|
||||
result.auto_fixed += evicted
|
||||
result.scanned += evicted
|
||||
if evicted > 0:
|
||||
logger.info("Phase 1 — TTL eviction: removed %d expired entries", evicted)
|
||||
except Exception as e:
|
||||
logger.error("Cache eviction failed: %s", e, exc_info=True)
|
||||
result.errors = 1
|
||||
logger.error("TTL eviction failed: %s", e, exc_info=True)
|
||||
result.errors += 1
|
||||
|
||||
if context.check_stop():
|
||||
return result
|
||||
|
||||
# Phase 2: Clean junk entities (empty/placeholder names)
|
||||
try:
|
||||
junk = cache.clean_junk_entities()
|
||||
result.auto_fixed += junk
|
||||
result.scanned += junk
|
||||
if junk > 0:
|
||||
logger.info("Phase 2 — junk cleanup: removed %d junk entries", junk)
|
||||
except Exception as e:
|
||||
logger.error("Junk cleanup failed: %s", e, exc_info=True)
|
||||
result.errors += 1
|
||||
|
||||
if context.check_stop():
|
||||
return result
|
||||
|
||||
# Phase 3: Clean orphaned search results
|
||||
try:
|
||||
orphans = cache.clean_orphaned_searches()
|
||||
result.auto_fixed += orphans
|
||||
result.scanned += orphans
|
||||
if orphans > 0:
|
||||
logger.info("Phase 3 — orphan cleanup: removed %d orphaned searches", orphans)
|
||||
except Exception as e:
|
||||
logger.error("Orphan cleanup failed: %s", e, exc_info=True)
|
||||
result.errors += 1
|
||||
|
||||
if context.check_stop():
|
||||
return result
|
||||
|
||||
# Phase 4: Clean stale MusicBrainz null results (failed lookups > 30 days)
|
||||
try:
|
||||
mb_nulls = cache.clean_stale_musicbrainz_nulls(max_age_days=30)
|
||||
result.auto_fixed += mb_nulls
|
||||
result.scanned += mb_nulls
|
||||
if mb_nulls > 0:
|
||||
logger.info("Phase 4 — MB null cleanup: removed %d stale null entries", mb_nulls)
|
||||
except Exception as e:
|
||||
logger.error("MB null cleanup failed: %s", e, exc_info=True)
|
||||
result.errors += 1
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -19071,6 +19071,17 @@ def get_version_info():
|
|||
"title": "What's New in SoulSync",
|
||||
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
|
||||
"sections": [
|
||||
{
|
||||
"title": "🧹 Metadata Cache Maintenance",
|
||||
"description": "The cache evictor now runs four maintenance phases to keep the metadata cache clean",
|
||||
"features": [
|
||||
"• Input validation prevents junk entities (Unknown Artist, empty names) from being cached",
|
||||
"• Junk cleanup removes existing placeholder entries from the cache",
|
||||
"• Orphan cleanup removes search results pointing to deleted entities",
|
||||
"• MusicBrainz null cleanup removes failed lookups after 30 days (was 90) so they get retried",
|
||||
"• Health stats available in the repair dashboard"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "🔧 Fix Wishlist Download Selection Ignoring Checkboxes",
|
||||
"description": "Download Selection now respects which tracks are checked in the wishlist overview",
|
||||
|
|
@ -44129,6 +44140,17 @@ def repair_findings_counts():
|
|||
logger.error(f"Error getting findings counts: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/repair/cache-health', methods=['GET'])
|
||||
def repair_cache_health():
|
||||
"""Get metadata cache health stats for the repair dashboard"""
|
||||
try:
|
||||
from core.metadata_cache import get_metadata_cache
|
||||
cache = get_metadata_cache()
|
||||
return jsonify(cache.get_health_stats()), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cache health: {e}")
|
||||
return jsonify({}), 500
|
||||
|
||||
@app.route('/api/repair/findings/<int:finding_id>/fix', methods=['POST'])
|
||||
def repair_finding_fix(finding_id):
|
||||
"""Execute the actual fix action for a finding"""
|
||||
|
|
|
|||
|
|
@ -1016,6 +1016,7 @@
|
|||
</div>
|
||||
<div class="tool-card-controls">
|
||||
<button id="mcache-browse-button" onclick="openMetadataCacheModal()">Browse Cache</button>
|
||||
<button onclick="openCacheHealthModal()" class="tool-card-btn-secondary">Cache Health</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3403,6 +3403,7 @@ function closeHelperSearch() {
|
|||
const WHATS_NEW = {
|
||||
'2.1': [
|
||||
// Newest features first
|
||||
{ title: 'Cache Maintenance', desc: 'Cache evictor now cleans junk entities, orphaned searches, and stale MusicBrainz nulls' },
|
||||
{ title: 'Fix Wishlist Download Selection', desc: 'Download Selection now only downloads checked tracks instead of the entire category' },
|
||||
{ title: 'Fix Tidal OAuth in Docker', desc: 'Tidal redirect URI now uses configured setting instead of Docker container hostname' },
|
||||
{ title: 'High-Res Cover Art', desc: 'Album art now sourced from Cover Art Archive (1200x1200+) when MusicBrainz release ID is available' },
|
||||
|
|
|
|||
|
|
@ -55468,12 +55468,148 @@ async function loadRepairFindingsDashboard() {
|
|||
}
|
||||
|
||||
dashboard.innerHTML = html;
|
||||
|
||||
// Load cache health stats
|
||||
_loadCacheHealthStats(dashboard);
|
||||
} catch (error) {
|
||||
console.error('Error loading findings dashboard:', error);
|
||||
dashboard.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function _loadCacheHealthStats(dashboard) {
|
||||
try {
|
||||
const response = await fetch('/api/repair/cache-health');
|
||||
if (!response.ok) return;
|
||||
const stats = await response.json();
|
||||
if (!stats.total_entities && !stats.total_searches) return;
|
||||
|
||||
const healthScore = stats.junk_entities === 0 && stats.stale_mb_nulls === 0 ? 'healthy' : stats.junk_entities > 50 ? 'poor' : 'fair';
|
||||
const healthLabel = healthScore === 'healthy' ? 'Healthy' : healthScore === 'fair' ? 'Needs Cleanup' : 'Needs Attention';
|
||||
|
||||
const section = document.createElement('div');
|
||||
section.className = 'repair-cache-health';
|
||||
section.innerHTML = `
|
||||
<div class="repair-cache-health-bar" onclick="openCacheHealthModal()">
|
||||
<span class="repair-cache-health-dot ${healthScore}"></span>
|
||||
<span class="repair-cache-health-title">Metadata Cache</span>
|
||||
<span class="repair-cache-health-summary">${stats.total_entities.toLocaleString()} entities · ${healthLabel}</span>
|
||||
<span class="repair-cache-health-action">View Details ›</span>
|
||||
</div>
|
||||
`;
|
||||
dashboard.appendChild(section);
|
||||
} catch (error) {
|
||||
console.error('Error loading cache health:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function openCacheHealthModal() {
|
||||
if (document.getElementById('cache-health-modal-overlay')) return;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'cache-health-modal-overlay';
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="cache-health-modal">
|
||||
<div class="cache-health-header">
|
||||
<div class="cache-health-header-content">
|
||||
<div class="cache-health-header-icon">📊</div>
|
||||
<div>
|
||||
<h2 class="cache-health-title">Cache Health</h2>
|
||||
<p class="cache-health-subtitle">Metadata cache status across all sources</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="watch-all-close" onclick="document.getElementById('cache-health-modal-overlay').remove()">×</button>
|
||||
</div>
|
||||
<div class="cache-health-body">
|
||||
<div class="cache-health-loading">
|
||||
<div class="watch-all-loading-spinner"></div>
|
||||
<div>Loading cache stats...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cache-health-footer">
|
||||
<button class="watch-all-btn watch-all-btn-cancel" onclick="document.getElementById('cache-health-modal-overlay').remove()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/repair/cache-health');
|
||||
if (!response.ok) throw new Error('Failed to load');
|
||||
const s = await response.json();
|
||||
|
||||
const body = overlay.querySelector('.cache-health-body');
|
||||
const healthScore = s.junk_entities === 0 && s.stale_mb_nulls === 0 ? 'healthy' : s.junk_entities > 50 ? 'poor' : 'fair';
|
||||
const healthEmoji = healthScore === 'healthy' ? '✓' : healthScore === 'fair' ? '⚠' : '❌';
|
||||
const healthLabel = healthScore === 'healthy' ? 'Cache is healthy' : healthScore === 'fair' ? 'Minor issues detected' : 'Cleanup recommended';
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="cache-health-status ${healthScore}">
|
||||
<div class="cache-health-status-icon">${healthEmoji}</div>
|
||||
<div class="cache-health-status-text">${healthLabel}</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-health-cards">
|
||||
<div class="cache-health-card">
|
||||
<div class="cache-health-card-value">${s.total_entities.toLocaleString()}</div>
|
||||
<div class="cache-health-card-label">Total Entities</div>
|
||||
</div>
|
||||
<div class="cache-health-card">
|
||||
<div class="cache-health-card-value">${s.total_searches.toLocaleString()}</div>
|
||||
<div class="cache-health-card-label">Search Results</div>
|
||||
</div>
|
||||
<div class="cache-health-card">
|
||||
<div class="cache-health-card-value ${s.junk_entities > 0 ? 'warn' : ''}">${s.junk_entities}</div>
|
||||
<div class="cache-health-card-label">Junk Entries</div>
|
||||
</div>
|
||||
<div class="cache-health-card">
|
||||
<div class="cache-health-card-value ${s.stale_mb_nulls > 10 ? 'warn' : ''}">${s.stale_mb_nulls}</div>
|
||||
<div class="cache-health-card-label">Failed Lookups</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-health-section">
|
||||
<div class="cache-health-section-title">By Source</div>
|
||||
<div class="cache-health-source-bars">
|
||||
${Object.entries(s.by_source || {}).map(([src, count]) => {
|
||||
const pct = s.total_entities > 0 ? Math.round(count / s.total_entities * 100) : 0;
|
||||
const color = src === 'spotify' ? '#1DB954' : src === 'itunes' ? '#FC3C44' : src === 'deezer' ? '#A238FF' : '#666';
|
||||
return `<div class="cache-health-source-row">
|
||||
<span class="cache-health-source-name">${src}</span>
|
||||
<div class="cache-health-source-track"><div class="cache-health-source-fill" style="width:${pct}%;background:${color}"></div></div>
|
||||
<span class="cache-health-source-count">${count.toLocaleString()}</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-health-section">
|
||||
<div class="cache-health-section-title">By Type</div>
|
||||
<div class="cache-health-type-pills">
|
||||
${Object.entries(s.by_type || {}).map(([type, count]) => `<span class="cache-health-pill">${type}s <strong>${count.toLocaleString()}</strong></span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-health-section">
|
||||
<div class="cache-health-section-title">Metrics</div>
|
||||
<div class="cache-health-metrics">
|
||||
<div class="cache-health-metric"><span class="cache-health-metric-label">Average Age</span><span class="cache-health-metric-value">${s.avg_age_days} days</span></div>
|
||||
<div class="cache-health-metric"><span class="cache-health-metric-label">Total Cache Hits</span><span class="cache-health-metric-value">${s.total_access_hits.toLocaleString()}</span></div>
|
||||
<div class="cache-health-metric"><span class="cache-health-metric-label">Expiring in 24h</span><span class="cache-health-metric-value">${s.expiring_24h}</span></div>
|
||||
<div class="cache-health-metric"><span class="cache-health-metric-label">Expiring in 7 days</span><span class="cache-health-metric-value">${s.expiring_7d}</span></div>
|
||||
<div class="cache-health-metric"><span class="cache-health-metric-label">MusicBrainz Entries</span><span class="cache-health-metric-value">${s.total_musicbrainz}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
const body = overlay.querySelector('.cache-health-body');
|
||||
body.innerHTML = '<div class="cache-health-loading">Failed to load cache stats</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function filterFindingsByJob(jobId) {
|
||||
const jobFilter = document.getElementById('repair-findings-job-filter');
|
||||
if (!jobFilter) return;
|
||||
|
|
|
|||
|
|
@ -46650,6 +46650,124 @@ tr.tag-diff-same {
|
|||
box-shadow: 0 0 4px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* ── Cache Health Bar (Repair Dashboard) ── */
|
||||
.repair-cache-health { margin-top: 12px; }
|
||||
.repair-cache-health-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px; border-radius: 10px; cursor: pointer;
|
||||
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.repair-cache-health-bar:hover { background: rgba(255,255,255,0.05); border-color: rgba(var(--accent-rgb), 0.15); }
|
||||
.repair-cache-health-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.repair-cache-health-dot.healthy { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.4); }
|
||||
.repair-cache-health-dot.fair { background: #f59e0b; box-shadow: 0 0 6px rgba(245,158,11,0.4); }
|
||||
.repair-cache-health-dot.poor { background: #ef4444; box-shadow: 0 0 6px rgba(239,68,68,0.4); }
|
||||
.repair-cache-health-title { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.7); }
|
||||
.repair-cache-health-summary { flex: 1; font-size: 12px; color: rgba(255,255,255,0.3); }
|
||||
.repair-cache-health-action { font-size: 12px; color: rgb(var(--accent-rgb)); white-space: nowrap; }
|
||||
|
||||
/* ── Cache Health Modal ── */
|
||||
.cache-health-modal {
|
||||
width: 680px; max-width: 95vw; max-height: 85vh;
|
||||
background: linear-gradient(135deg, rgba(20,20,20,0.97) 0%, rgba(12,12,12,0.99) 100%);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-top: 1px solid rgba(255,255,255,0.15);
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 8px 32px rgba(0,0,0,0.4),
|
||||
0 0 40px rgba(var(--accent-rgb), 0.08), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.cache-health-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.06) 0%, transparent 60%);
|
||||
border-bottom: 1px solid rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
.cache-health-header-content { display: flex; align-items: center; gap: 14px; }
|
||||
.cache-health-header-icon { font-size: 28px; opacity: 0.9; }
|
||||
.cache-health-title {
|
||||
font-size: 20px; font-weight: 700; color: #fff; letter-spacing: -0.3px;
|
||||
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
.cache-health-subtitle { font-size: 13px; color: rgba(255,255,255,0.45); margin-top: 2px; }
|
||||
.cache-health-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
||||
.cache-health-footer {
|
||||
display: flex; justify-content: flex-end; padding: 16px 24px;
|
||||
border-top: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.cache-health-loading {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 48px; gap: 12px; color: rgba(255,255,255,0.5); font-size: 14px;
|
||||
}
|
||||
|
||||
/* Status banner */
|
||||
.cache-health-status {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 18px; border-radius: 12px; margin-bottom: 20px;
|
||||
}
|
||||
.cache-health-status.healthy { background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.2); }
|
||||
.cache-health-status.fair { background: rgba(245,158,11,0.08); border: 1px solid rgba(245,158,11,0.2); }
|
||||
.cache-health-status.poor { background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2); }
|
||||
.cache-health-status-icon { font-size: 20px; }
|
||||
.cache-health-status.healthy .cache-health-status-icon { color: #22c55e; }
|
||||
.cache-health-status.fair .cache-health-status-icon { color: #f59e0b; }
|
||||
.cache-health-status.poor .cache-health-status-icon { color: #ef4444; }
|
||||
.cache-health-status-text { font-size: 14px; font-weight: 600; color: rgba(255,255,255,0.8); }
|
||||
|
||||
/* Stat cards */
|
||||
.cache-health-cards {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 20px;
|
||||
}
|
||||
.cache-health-card {
|
||||
text-align: center; padding: 14px 8px; border-radius: 12px;
|
||||
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.cache-health-card:hover { border-color: rgba(255,255,255,0.1); }
|
||||
.cache-health-card-value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||
.cache-health-card-value.warn { color: rgba(255,180,80,0.9); }
|
||||
.cache-health-card-label { font-size: 10px; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
|
||||
|
||||
/* Sections */
|
||||
.cache-health-section { margin-bottom: 16px; }
|
||||
.cache-health-section-title {
|
||||
font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.35);
|
||||
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Source bars */
|
||||
.cache-health-source-bars { display: flex; flex-direction: column; gap: 8px; }
|
||||
.cache-health-source-row { display: flex; align-items: center; gap: 10px; }
|
||||
.cache-health-source-name { font-size: 13px; color: rgba(255,255,255,0.6); width: 60px; text-transform: capitalize; }
|
||||
.cache-health-source-track {
|
||||
flex: 1; height: 8px; border-radius: 4px; background: rgba(255,255,255,0.06); overflow: hidden;
|
||||
}
|
||||
.cache-health-source-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
|
||||
.cache-health-source-count { font-size: 12px; color: rgba(255,255,255,0.4); width: 50px; text-align: right; }
|
||||
|
||||
/* Type pills */
|
||||
.cache-health-type-pills { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.cache-health-pill {
|
||||
padding: 6px 14px; border-radius: 20px; font-size: 13px; color: rgba(255,255,255,0.5);
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.cache-health-pill strong { color: #fff; margin-left: 4px; }
|
||||
|
||||
/* Metrics grid */
|
||||
.cache-health-metrics { display: flex; flex-direction: column; gap: 6px; }
|
||||
.cache-health-metric {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
.cache-health-metric:last-child { border-bottom: none; }
|
||||
.cache-health-metric-label { font-size: 13px; color: rgba(255,255,255,0.4); }
|
||||
.cache-health-metric-value { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.8); }
|
||||
|
||||
/* ── Findings Tab ── */
|
||||
.repair-findings-toolbar {
|
||||
display: flex;
|
||||
|
|
|
|||
Loading…
Reference in a new issue