diff --git a/core/metadata_cache.py b/core/metadata_cache.py index b6243893..e6f8139b 100644 --- a/core/metadata_cache.py +++ b/core/metadata_cache.py @@ -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: diff --git a/core/musicbrainz_service.py b/core/musicbrainz_service.py index f2ce020b..fced6dcb 100644 --- a/core/musicbrainz_service.py +++ b/core/musicbrainz_service.py @@ -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 diff --git a/core/repair_jobs/cache_evictor.py b/core/repair_jobs/cache_evictor.py index 2bf179df..e2559fc9 100644 --- a/core/repair_jobs/cache_evictor.py +++ b/core/repair_jobs/cache_evictor.py @@ -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 diff --git a/web_server.py b/web_server.py index b3900a50..7cec5ef0 100644 --- a/web_server.py +++ b/web_server.py @@ -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//fix', methods=['POST']) def repair_finding_fix(finding_id): """Execute the actual fix action for a finding""" diff --git a/webui/index.html b/webui/index.html index 84f6af87..49903477 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1016,6 +1016,7 @@
+
diff --git a/webui/static/helper.js b/webui/static/helper.js index 4a6e9189..c01620a5 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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' }, diff --git a/webui/static/script.js b/webui/static/script.js index 021a11d6..6d4805eb 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = ` +
+ + Metadata Cache + ${stats.total_entities.toLocaleString()} entities · ${healthLabel} + View Details › +
+ `; + 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 = ` +
+
+
+
📊
+
+

Cache Health

+

Metadata cache status across all sources

+
+
+ +
+
+
+
+
Loading cache stats...
+
+
+ +
+ `; + 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 = ` +
+
${healthEmoji}
+
${healthLabel}
+
+ +
+
+
${s.total_entities.toLocaleString()}
+
Total Entities
+
+
+
${s.total_searches.toLocaleString()}
+
Search Results
+
+
+
${s.junk_entities}
+
Junk Entries
+
+
+
${s.stale_mb_nulls}
+
Failed Lookups
+
+
+ +
+
By Source
+
+ ${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 `
+ ${src} +
+ ${count.toLocaleString()} +
`; + }).join('')} +
+
+ +
+
By Type
+
+ ${Object.entries(s.by_type || {}).map(([type, count]) => `${type}s ${count.toLocaleString()}`).join('')} +
+
+ +
+
Metrics
+
+
Average Age${s.avg_age_days} days
+
Total Cache Hits${s.total_access_hits.toLocaleString()}
+
Expiring in 24h${s.expiring_24h}
+
Expiring in 7 days${s.expiring_7d}
+
MusicBrainz Entries${s.total_musicbrainz}
+
+
+ `; + } catch (error) { + const body = overlay.querySelector('.cache-health-body'); + body.innerHTML = '
Failed to load cache stats
'; + } +} + function filterFindingsByJob(jobId) { const jobFilter = document.getElementById('repair-findings-job-filter'); if (!jobFilter) return; diff --git a/webui/static/style.css b/webui/static/style.css index f1df20d3..02c5b5dc 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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;