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:
Broque Thomas 2026-03-30 07:40:20 -07:00
parent 4e6b424bc7
commit 59587162cd
8 changed files with 540 additions and 16 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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"""

View file

@ -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>

View file

@ -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' },

View file

@ -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">&#128202;</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()">&times;</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' ? '&#10003;' : healthScore === 'fair' ? '&#9888;' : '&#10060;';
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;

View file

@ -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;