Redesign maintenance findings/history tabs with dashboard & fix path resolution bugs
- Add findings dashboard with summary stats and per-job clickable filter chips - Redesign findings cards with expandable detail panels and per-type renderers - Redesign history tab with status dots, stat pills, and full timestamps - Fix dead file cleaner false positives by using suffix-based path resolution - Fix orphan file detector false positives by matching via path suffixes - Add help text modal for each repair job card - Enlarge maintenance modal (1100px wide, 90vh tall)
This commit is contained in:
parent
4635ea895b
commit
61ed4086e0
6 changed files with 808 additions and 102 deletions
|
|
@ -9,6 +9,31 @@ from utils.logging_config import get_logger
|
|||
logger = get_logger("repair_job.dead_files")
|
||||
|
||||
|
||||
def _resolve_file_path(file_path, transfer_folder, download_folder=None):
|
||||
"""Resolve a stored DB path to an actual file on disk.
|
||||
|
||||
Mirrors _resolve_library_file_path from web_server.py — tries the raw
|
||||
path first, then progressively shorter suffixes against configured dirs.
|
||||
"""
|
||||
if not file_path:
|
||||
return None
|
||||
if os.path.exists(file_path):
|
||||
return file_path
|
||||
|
||||
path_parts = file_path.replace('\\', '/').split('/')
|
||||
|
||||
for base_dir in [transfer_folder, download_folder]:
|
||||
if not base_dir or not os.path.isdir(base_dir):
|
||||
continue
|
||||
# Skip index 0 to avoid drive letter issues (e.g. E:)
|
||||
for i in range(1, len(path_parts)):
|
||||
candidate = os.path.join(base_dir, *path_parts[i:])
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@register_job
|
||||
class DeadFileCleanerJob(RepairJob):
|
||||
job_id = 'dead_file_cleaner'
|
||||
|
|
@ -57,6 +82,11 @@ class DeadFileCleanerJob(RepairJob):
|
|||
if context.update_progress:
|
||||
context.update_progress(0, total)
|
||||
|
||||
# Get download folder for path resolution fallback
|
||||
download_folder = None
|
||||
if context.config_manager:
|
||||
download_folder = context.config_manager.get('soulseek.download_path', '')
|
||||
|
||||
for i, row in enumerate(tracks):
|
||||
if context.check_stop():
|
||||
return result
|
||||
|
|
@ -66,8 +96,11 @@ class DeadFileCleanerJob(RepairJob):
|
|||
track_id, title, artist_name, album_title, file_path = row
|
||||
result.scanned += 1
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
# File is missing — create finding
|
||||
# Use the same path resolution logic as library playback
|
||||
resolved = _resolve_file_path(file_path, context.transfer_folder, download_folder)
|
||||
|
||||
if resolved is None:
|
||||
# File is truly missing — create finding
|
||||
if context.create_finding:
|
||||
try:
|
||||
context.create_finding(
|
||||
|
|
|
|||
|
|
@ -39,16 +39,24 @@ class OrphanFileDetectorJob(RepairJob):
|
|||
logger.warning("Transfer folder does not exist: %s", transfer)
|
||||
return result
|
||||
|
||||
# Build set of all known file paths from DB
|
||||
known_paths = set()
|
||||
# Build set of known file-path suffixes from DB.
|
||||
# DB may store paths with a different base prefix than the local filesystem
|
||||
# (e.g. DB has /mnt/musicBackup/Artist/Album/track.mp3, local disk is
|
||||
# H:\Music\Artist\Album\track.mp3). We compare using suffix fragments
|
||||
# of depth 1-3 (filename, album/filename, artist/album/filename) which
|
||||
# covers all realistic path-prefix mismatches.
|
||||
known_suffixes = set()
|
||||
conn = None
|
||||
try:
|
||||
conn = context.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT file_path FROM tracks WHERE file_path IS NOT NULL AND file_path != ''")
|
||||
for row in cursor.fetchall():
|
||||
# Normalize path for comparison
|
||||
known_paths.add(os.path.normpath(row[0]))
|
||||
parts = row[0].replace('\\', '/').split('/')
|
||||
# Store last 1, 2, and 3 path components as lowercase suffixes
|
||||
for depth in range(1, min(4, len(parts) + 1)):
|
||||
suffix = '/'.join(parts[-depth:]).lower()
|
||||
known_suffixes.add(suffix)
|
||||
except Exception as e:
|
||||
logger.error("Error reading known file paths from DB: %s", e, exc_info=True)
|
||||
result.errors += 1
|
||||
|
|
@ -78,9 +86,17 @@ class OrphanFileDetectorJob(RepairJob):
|
|||
return result
|
||||
|
||||
result.scanned += 1
|
||||
norm_path = os.path.normpath(fpath)
|
||||
|
||||
if norm_path not in known_paths:
|
||||
# Check if this file matches any known DB path via suffix matching
|
||||
fpath_parts = fpath.replace('\\', '/').split('/')
|
||||
is_known = False
|
||||
for depth in range(1, min(4, len(fpath_parts) + 1)):
|
||||
suffix = '/'.join(fpath_parts[-depth:]).lower()
|
||||
if suffix in known_suffixes:
|
||||
is_known = True
|
||||
break
|
||||
|
||||
if not is_known:
|
||||
# This file is an orphan — create finding
|
||||
try:
|
||||
stat = os.stat(fpath)
|
||||
|
|
|
|||
|
|
@ -770,25 +770,50 @@ class RepairWorker:
|
|||
conn.close()
|
||||
|
||||
def get_findings_counts(self) -> dict:
|
||||
"""Get counts by status."""
|
||||
"""Get counts by status and by job."""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Overall counts by status
|
||||
cursor.execute("""
|
||||
SELECT status, COUNT(*) FROM repair_findings
|
||||
GROUP BY status
|
||||
""")
|
||||
counts = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
status_counts = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
|
||||
# Pending counts per job
|
||||
cursor.execute("""
|
||||
SELECT job_id, finding_type, severity, COUNT(*) FROM repair_findings
|
||||
WHERE status = 'pending'
|
||||
GROUP BY job_id, finding_type, severity
|
||||
""")
|
||||
by_job = {}
|
||||
for job_id, finding_type, severity, cnt in cursor.fetchall():
|
||||
if job_id not in by_job:
|
||||
by_job[job_id] = {'total': 0, 'types': {}, 'warning': 0, 'info': 0}
|
||||
by_job[job_id]['total'] += cnt
|
||||
by_job[job_id]['types'][finding_type] = by_job[job_id]['types'].get(finding_type, 0) + cnt
|
||||
if severity in ('warning', 'info'):
|
||||
by_job[job_id][severity] += cnt
|
||||
|
||||
# Resolve display names
|
||||
self._ensure_jobs_loaded()
|
||||
for job_id in by_job:
|
||||
job = self._jobs.get(job_id)
|
||||
by_job[job_id]['display_name'] = job.display_name if job else job_id
|
||||
|
||||
return {
|
||||
'pending': counts.get('pending', 0),
|
||||
'resolved': counts.get('resolved', 0),
|
||||
'dismissed': counts.get('dismissed', 0),
|
||||
'auto_fixed': counts.get('auto_fixed', 0),
|
||||
'total': sum(counts.values()),
|
||||
'pending': status_counts.get('pending', 0),
|
||||
'resolved': status_counts.get('resolved', 0),
|
||||
'dismissed': status_counts.get('dismissed', 0),
|
||||
'auto_fixed': status_counts.get('auto_fixed', 0),
|
||||
'total': sum(status_counts.values()),
|
||||
'by_job': by_job,
|
||||
}
|
||||
except Exception:
|
||||
return {'pending': 0, 'resolved': 0, 'dismissed': 0, 'auto_fixed': 0, 'total': 0}
|
||||
return {'pending': 0, 'resolved': 0, 'dismissed': 0, 'auto_fixed': 0, 'total': 0, 'by_job': {}}
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
|
|
|||
|
|
@ -5638,18 +5638,21 @@
|
|||
</div>
|
||||
|
||||
<div class="repair-tab-content" id="repair-tab-findings" style="display:none;">
|
||||
<div class="repair-findings-header">
|
||||
<!-- Summary dashboard -->
|
||||
<div class="repair-findings-dashboard" id="repair-findings-dashboard"></div>
|
||||
|
||||
<!-- Toolbar: filters + bulk actions -->
|
||||
<div class="repair-findings-toolbar">
|
||||
<div class="repair-findings-filters">
|
||||
<select id="repair-findings-job-filter" onchange="loadRepairFindings()">
|
||||
<select id="repair-findings-job-filter" onchange="_repairFindingsPage=0;loadRepairFindings()">
|
||||
<option value="">All Jobs</option>
|
||||
</select>
|
||||
<select id="repair-findings-severity-filter" onchange="loadRepairFindings()">
|
||||
<select id="repair-findings-severity-filter" onchange="_repairFindingsPage=0;loadRepairFindings()">
|
||||
<option value="">All Severity</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
<select id="repair-findings-status-filter" onchange="loadRepairFindings()">
|
||||
<select id="repair-findings-status-filter" onchange="_repairFindingsPage=0;loadRepairFindings()">
|
||||
<option value="pending">Pending</option>
|
||||
<option value="">All Status</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
|
|
|
|||
|
|
@ -49919,7 +49919,7 @@ function switchRepairTab(tab) {
|
|||
if (content) content.style.display = '';
|
||||
|
||||
if (tab === 'jobs') loadRepairJobs();
|
||||
else if (tab === 'findings') loadRepairFindings();
|
||||
else if (tab === 'findings') { loadRepairFindingsDashboard(); loadRepairFindings(); }
|
||||
else if (tab === 'history') loadRepairHistory();
|
||||
}
|
||||
|
||||
|
|
@ -50303,6 +50303,84 @@ function updateRepairJobProgressFromData(data) {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadRepairFindingsDashboard() {
|
||||
const dashboard = document.getElementById('repair-findings-dashboard');
|
||||
if (!dashboard) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/repair/findings/counts');
|
||||
if (!response.ok) throw new Error('Failed to fetch counts');
|
||||
const data = await response.json();
|
||||
|
||||
const pending = data.pending || 0;
|
||||
const resolved = data.resolved || 0;
|
||||
const dismissed = data.dismissed || 0;
|
||||
const autoFixed = data.auto_fixed || 0;
|
||||
const byJob = data.by_job || {};
|
||||
|
||||
// Summary stats row
|
||||
let html = '<div class="repair-dashboard-summary">';
|
||||
html += `<div class="repair-dashboard-stat pending">
|
||||
<span class="stat-count">${pending.toLocaleString()}</span> pending
|
||||
</div>`;
|
||||
html += `<div class="repair-dashboard-stat resolved">
|
||||
<span class="stat-count">${resolved.toLocaleString()}</span> resolved
|
||||
</div>`;
|
||||
html += `<div class="repair-dashboard-stat dismissed">
|
||||
<span class="stat-count">${dismissed.toLocaleString()}</span> dismissed
|
||||
</div>`;
|
||||
if (autoFixed > 0) {
|
||||
html += `<div class="repair-dashboard-stat auto-fixed">
|
||||
<span class="stat-count">${autoFixed.toLocaleString()}</span> auto-fixed
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Per-job chips (only if there are pending findings)
|
||||
const jobIds = Object.keys(byJob).sort((a, b) => byJob[b].total - byJob[a].total);
|
||||
if (jobIds.length > 0) {
|
||||
html += '<div class="repair-dashboard-jobs">';
|
||||
const jobFilter = document.getElementById('repair-findings-job-filter');
|
||||
const activeJob = jobFilter ? jobFilter.value : '';
|
||||
|
||||
for (const jid of jobIds) {
|
||||
const job = byJob[jid];
|
||||
const isActive = activeJob === jid;
|
||||
const severityDots = [];
|
||||
if (job.warning > 0) severityDots.push(`<span class="repair-dashboard-chip-severity warning" title="${job.warning} warnings"></span>`);
|
||||
if (job.info > 0) severityDots.push(`<span class="repair-dashboard-chip-severity info" title="${job.info} info"></span>`);
|
||||
|
||||
html += `<div class="repair-dashboard-chip ${isActive ? 'active' : ''}" onclick="filterFindingsByJob('${jid}')">
|
||||
<span class="repair-dashboard-chip-count">${job.total.toLocaleString()}</span>
|
||||
<span class="repair-dashboard-chip-name">${_escFinding(job.display_name || jid.replace(/_/g, ' '))}</span>
|
||||
${severityDots.length ? `<span class="repair-dashboard-chip-bar">${severityDots.join('')}</span>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
dashboard.innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error('Error loading findings dashboard:', error);
|
||||
dashboard.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function filterFindingsByJob(jobId) {
|
||||
const jobFilter = document.getElementById('repair-findings-job-filter');
|
||||
if (!jobFilter) return;
|
||||
|
||||
// Toggle: click same chip again to clear filter
|
||||
if (jobFilter.value === jobId) {
|
||||
jobFilter.value = '';
|
||||
} else {
|
||||
jobFilter.value = jobId;
|
||||
}
|
||||
_repairFindingsPage = 0;
|
||||
loadRepairFindingsDashboard();
|
||||
loadRepairFindings();
|
||||
}
|
||||
|
||||
async function loadRepairFindings() {
|
||||
const container = document.getElementById('repair-findings-list');
|
||||
if (!container) return;
|
||||
|
|
@ -50339,33 +50417,58 @@ async function loadRepairFindings() {
|
|||
}
|
||||
|
||||
const severityIcons = { info: 'ℹ️', warning: '⚠️', critical: '🔴' };
|
||||
const typeLabels = {
|
||||
dead_file: 'Dead File', orphan_file: 'Orphan', acoustid_mismatch: 'Wrong Song',
|
||||
acoustid_no_match: 'No Match', fake_lossless: 'Fake Lossless',
|
||||
duplicate_tracks: 'Duplicate', incomplete_album: 'Incomplete',
|
||||
path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata',
|
||||
missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number'
|
||||
};
|
||||
|
||||
container.innerHTML = items.map(f => {
|
||||
const icon = severityIcons[f.severity] || 'ℹ️';
|
||||
const age = formatCacheAge(f.created_at);
|
||||
const statusBadge = f.status !== 'pending' ?
|
||||
`<span class="repair-finding-status-badge ${f.status}">${f.status}</span>` : '';
|
||||
const typeLabel = typeLabels[f.finding_type] || f.finding_type.replace(/_/g, ' ');
|
||||
const d = f.details || {};
|
||||
const filePath = f.file_path || d.original_path || d.file_path || '';
|
||||
|
||||
return `<div class="repair-finding-card ${f.severity}" data-id="${f.id}">
|
||||
<div class="repair-finding-select">
|
||||
<input type="checkbox" onchange="toggleFindingSelect(${f.id}, this.checked)">
|
||||
</div>
|
||||
<div class="repair-finding-content">
|
||||
<div class="repair-finding-title">
|
||||
<span class="repair-finding-icon">${icon}</span>
|
||||
${f.title}
|
||||
${statusBadge}
|
||||
<div class="repair-finding-main" onclick="toggleFindingDetail(${f.id})">
|
||||
<div class="repair-finding-select" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" onchange="toggleFindingSelect(${f.id}, this.checked)">
|
||||
</div>
|
||||
<div class="repair-finding-desc">${f.description || ''}</div>
|
||||
<div class="repair-finding-meta">
|
||||
${f.job_id.replace(/_/g, ' ')} · ${f.entity_type || 'file'} · ${age}
|
||||
<div class="repair-finding-content">
|
||||
<div class="repair-finding-title">
|
||||
<span class="repair-finding-icon">${icon}</span>
|
||||
${_escFinding(f.title)}
|
||||
<span class="repair-finding-type-badge">${typeLabel}</span>
|
||||
${statusBadge}
|
||||
</div>
|
||||
<div class="repair-finding-desc">${_escFinding(f.description || '')}</div>
|
||||
${filePath ? `<div class="repair-finding-path">${_escFinding(filePath)}</div>` : ''}
|
||||
<div class="repair-finding-meta">
|
||||
<span>${f.job_id.replace(/_/g, ' ')}</span>
|
||||
<span>·</span>
|
||||
<span>${f.entity_type || 'file'}</span>
|
||||
${f.entity_id ? `<span>·</span><span>ID: ${f.entity_id}</span>` : ''}
|
||||
<span>·</span>
|
||||
<span>${age}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repair-finding-actions" onclick="event.stopPropagation()">
|
||||
${f.status === 'pending' ? `
|
||||
<button class="repair-finding-btn resolve" onclick="resolveRepairFinding(${f.id})" title="Resolve">✓</button>
|
||||
<button class="repair-finding-btn dismiss" onclick="dismissRepairFinding(${f.id})" title="Dismiss">×</button>
|
||||
` : ''}
|
||||
<button class="repair-finding-expand-btn" data-finding="${f.id}" title="Details">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repair-finding-actions">
|
||||
${f.status === 'pending' ? `
|
||||
<button class="repair-finding-btn resolve" onclick="resolveRepairFinding(${f.id})" title="Resolve">✓</button>
|
||||
<button class="repair-finding-btn dismiss" onclick="dismissRepairFinding(${f.id})" title="Dismiss">×</button>
|
||||
` : ''}
|
||||
<div class="repair-finding-detail" id="repair-detail-${f.id}">
|
||||
<div class="repair-finding-detail-inner">
|
||||
${_renderFindingDetail(f)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
|
@ -50379,6 +50482,168 @@ async function loadRepairFindings() {
|
|||
}
|
||||
}
|
||||
|
||||
function _escFinding(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _renderScoreBar(value, label) {
|
||||
const pct = Math.round((value || 0) * 100);
|
||||
const cls = pct >= 80 ? 'good' : pct >= 50 ? 'warn' : 'bad';
|
||||
return `<div class="repair-score-bar">
|
||||
<span class="repair-detail-key">${label}</span>
|
||||
<div class="repair-score-bar-track"><div class="repair-score-bar-fill ${cls}" style="width:${pct}%"></div></div>
|
||||
<span class="repair-detail-val">${pct}%</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _formatFileSize(bytes) {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function _renderFindingDetail(f) {
|
||||
const d = f.details || {};
|
||||
const rows = [];
|
||||
|
||||
switch (f.finding_type) {
|
||||
case 'dead_file':
|
||||
if (d.artist) rows.push(['Artist', d.artist]);
|
||||
if (d.album) rows.push(['Album', d.album]);
|
||||
if (d.title) rows.push(['Title', d.title]);
|
||||
if (d.track_id) rows.push(['Track ID', d.track_id]);
|
||||
if (d.original_path) rows.push(['Original Path', d.original_path, 'path']);
|
||||
return _gridRows(rows);
|
||||
|
||||
case 'orphan_file':
|
||||
if (d.folder) rows.push(['Folder', d.folder, 'path']);
|
||||
if (d.format) rows.push(['Format', d.format.toUpperCase()]);
|
||||
if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]);
|
||||
if (d.modified) rows.push(['Last Modified', d.modified]);
|
||||
if (f.file_path) rows.push(['Full Path', f.file_path, 'path']);
|
||||
return _gridRows(rows);
|
||||
|
||||
case 'acoustid_mismatch':
|
||||
let html = '<div style="margin-bottom:8px">';
|
||||
html += _renderScoreBar(d.fingerprint_score, 'Fingerprint');
|
||||
html += _renderScoreBar(d.title_similarity, 'Title Match');
|
||||
html += _renderScoreBar(d.artist_similarity, 'Artist Match');
|
||||
html += '</div>';
|
||||
rows.push(['Expected Title', d.expected_title || '-']);
|
||||
rows.push(['Expected Artist', d.expected_artist || '-']);
|
||||
rows.push(['AcoustID Title', d.acoustid_title || '-', 'highlight']);
|
||||
rows.push(['AcoustID Artist', d.acoustid_artist || '-', 'highlight']);
|
||||
if (f.file_path) rows.push(['File', f.file_path, 'path']);
|
||||
return html + _gridRows(rows);
|
||||
|
||||
case 'acoustid_no_match':
|
||||
if (d.expected_title) rows.push(['Expected Title', d.expected_title]);
|
||||
if (d.expected_artist) rows.push(['Expected Artist', d.expected_artist]);
|
||||
if (f.file_path) rows.push(['File', f.file_path, 'path']);
|
||||
return _gridRows(rows);
|
||||
|
||||
case 'fake_lossless':
|
||||
if (d.format) rows.push(['Format', d.format.toUpperCase()]);
|
||||
rows.push(['Spectral Cutoff', `${d.detected_cutoff_khz || '?'} kHz`]);
|
||||
rows.push(['Expected Min', `${d.expected_min_khz || '?'} kHz`]);
|
||||
if (d.sample_rate) rows.push(['Sample Rate', `${d.sample_rate} Hz`]);
|
||||
if (d.nyquist_khz) rows.push(['Nyquist', `${d.nyquist_khz} kHz`]);
|
||||
if (d.bit_depth) rows.push(['Bit Depth', `${d.bit_depth}-bit`]);
|
||||
if (d.bitrate) rows.push(['Bitrate', `${d.bitrate} kbps`]);
|
||||
if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]);
|
||||
if (f.file_path) rows.push(['File', f.file_path, 'path']);
|
||||
return _gridRows(rows);
|
||||
|
||||
case 'duplicate_tracks':
|
||||
if (!d.tracks || !d.tracks.length) return _gridRows([['Count', d.count || '?']]);
|
||||
return `<div class="repair-detail-sublist">${d.tracks.map((t, i) => `
|
||||
<div class="repair-detail-subitem">
|
||||
<strong>Copy ${i + 1}: ${_escFinding(t.title)} by ${_escFinding(t.artist)}</strong>
|
||||
<span>Album: ${_escFinding(t.album || 'Unknown')}</span>
|
||||
${t.bitrate ? `<span>Bitrate: ${t.bitrate} kbps${t.duration ? ` · Duration: ${Math.round(t.duration)}s` : ''}</span>` : ''}
|
||||
${t.file_path ? `<span class="mono">${_escFinding(t.file_path)}</span>` : ''}
|
||||
</div>`).join('')}</div>`;
|
||||
|
||||
case 'incomplete_album':
|
||||
if (d.artist) rows.push(['Artist', d.artist]);
|
||||
if (d.album_title) rows.push(['Album', d.album_title]);
|
||||
rows.push(['Tracks Found', `${d.actual_tracks || '?'} of ${d.expected_tracks || '?'}`]);
|
||||
if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]);
|
||||
if (d.missing_tracks && d.missing_tracks.length) {
|
||||
return _gridRows(rows) + `<div class="repair-detail-sublist">${d.missing_tracks.map(t => `
|
||||
<div class="repair-detail-subitem">
|
||||
<strong>#${t.track_number || '?'} ${_escFinding(t.name || t.title || 'Unknown')}</strong>
|
||||
${t.duration_ms ? `<span>Duration: ${Math.round(t.duration_ms / 1000)}s</span>` : ''}
|
||||
</div>`).join('')}</div>`;
|
||||
}
|
||||
return _gridRows(rows);
|
||||
|
||||
case 'path_mismatch':
|
||||
if (d.from) rows.push(['Current Path', d.from, 'path']);
|
||||
if (d.to) rows.push(['Expected Path', d.to, 'success']);
|
||||
return _gridRows(rows);
|
||||
|
||||
case 'metadata_gap':
|
||||
if (d.artist) rows.push(['Artist', d.artist]);
|
||||
if (d.album) rows.push(['Album', d.album]);
|
||||
if (d.title) rows.push(['Title', d.title]);
|
||||
if (d.spotify_track_id) rows.push(['Spotify ID', d.spotify_track_id]);
|
||||
if (d.found_fields && typeof d.found_fields === 'object') {
|
||||
Object.entries(d.found_fields).forEach(([k, v]) => {
|
||||
rows.push([`Found: ${k}`, String(v), 'success']);
|
||||
});
|
||||
}
|
||||
return _gridRows(rows);
|
||||
|
||||
case 'missing_cover_art':
|
||||
if (d.artist) rows.push(['Artist', d.artist]);
|
||||
if (d.album_title) rows.push(['Album', d.album_title]);
|
||||
if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]);
|
||||
if (d.found_artwork_url) rows.push(['Artwork URL', d.found_artwork_url, 'success']);
|
||||
return _gridRows(rows);
|
||||
|
||||
case 'track_number_mismatch':
|
||||
if (d.matched_title) rows.push(['Matched To', d.matched_title]);
|
||||
if (d.file_title) rows.push(['File Title', d.file_title]);
|
||||
if (d.current_track_num !== undefined) rows.push(['Current Track #', String(d.current_track_num)]);
|
||||
if (d.correct_track_num !== undefined) rows.push(['Correct Track #', String(d.correct_track_num), 'success']);
|
||||
if (f.file_path) rows.push(['File', f.file_path, 'path']);
|
||||
let tnHtml = _gridRows(rows);
|
||||
if (d.changes && d.changes.length) {
|
||||
tnHtml += `<div class="repair-detail-sublist">${d.changes.map(c => `
|
||||
<div class="repair-detail-subitem"><strong>${_escFinding(c)}</strong></div>`).join('')}</div>`;
|
||||
}
|
||||
return tnHtml;
|
||||
|
||||
default:
|
||||
// Generic: render all detail keys
|
||||
Object.entries(d).forEach(([k, v]) => {
|
||||
if (typeof v !== 'object') {
|
||||
rows.push([k.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), String(v)]);
|
||||
}
|
||||
});
|
||||
if (f.file_path) rows.push(['File', f.file_path, 'path']);
|
||||
return rows.length ? _gridRows(rows) : '<span style="color:rgba(255,255,255,0.3);font-size:12px;">No additional details available</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function _gridRows(rows) {
|
||||
if (!rows.length) return '';
|
||||
return `<div class="repair-detail-grid">${rows.map(([k, v, cls]) =>
|
||||
`<span class="repair-detail-key">${_escFinding(k)}</span><span class="repair-detail-val ${cls || ''}">${_escFinding(v)}</span>`
|
||||
).join('')}</div>`;
|
||||
}
|
||||
|
||||
function toggleFindingDetail(id) {
|
||||
const panel = document.getElementById(`repair-detail-${id}`);
|
||||
const btn = document.querySelector(`.repair-finding-expand-btn[data-finding="${id}"]`);
|
||||
if (!panel) return;
|
||||
const isOpen = panel.classList.toggle('open');
|
||||
if (btn) btn.classList.toggle('open', isOpen);
|
||||
}
|
||||
|
||||
function toggleFindingSelect(id, checked) {
|
||||
if (checked) _repairSelectedFindings.add(id);
|
||||
else _repairSelectedFindings.delete(id);
|
||||
|
|
@ -50398,19 +50663,36 @@ function renderRepairFindingsPagination(total, currentPage) {
|
|||
if (currentPage > 0) {
|
||||
html += `<button class="repair-page-btn" onclick="_repairFindingsPage=${currentPage - 1};loadRepairFindings()">←</button>`;
|
||||
}
|
||||
for (let i = 0; i < totalPages && i < 10; i++) {
|
||||
|
||||
// Smart page range
|
||||
let startPage = Math.max(0, currentPage - 3);
|
||||
let endPage = Math.min(totalPages, startPage + 7);
|
||||
if (endPage - startPage < 7) startPage = Math.max(0, endPage - 7);
|
||||
|
||||
if (startPage > 0) {
|
||||
html += `<button class="repair-page-btn" onclick="_repairFindingsPage=0;loadRepairFindings()">1</button>`;
|
||||
if (startPage > 1) html += '<span class="repair-page-info">...</span>';
|
||||
}
|
||||
for (let i = startPage; i < endPage; i++) {
|
||||
html += `<button class="repair-page-btn ${i === currentPage ? 'active' : ''}"
|
||||
onclick="_repairFindingsPage=${i};loadRepairFindings()">${i + 1}</button>`;
|
||||
}
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) html += '<span class="repair-page-info">...</span>';
|
||||
html += `<button class="repair-page-btn" onclick="_repairFindingsPage=${totalPages - 1};loadRepairFindings()">${totalPages}</button>`;
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 1) {
|
||||
html += `<button class="repair-page-btn" onclick="_repairFindingsPage=${currentPage + 1};loadRepairFindings()">→</button>`;
|
||||
}
|
||||
html += `<span class="repair-page-info">${total.toLocaleString()} total</span>`;
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function resolveRepairFinding(id) {
|
||||
try {
|
||||
await fetch(`/api/repair/findings/${id}/resolve`, { method: 'POST' });
|
||||
loadRepairFindingsDashboard();
|
||||
loadRepairFindings();
|
||||
updateRepairStatus();
|
||||
} catch (error) {
|
||||
|
|
@ -50421,6 +50703,7 @@ async function resolveRepairFinding(id) {
|
|||
async function dismissRepairFinding(id) {
|
||||
try {
|
||||
await fetch(`/api/repair/findings/${id}/dismiss`, { method: 'POST' });
|
||||
loadRepairFindingsDashboard();
|
||||
loadRepairFindings();
|
||||
updateRepairStatus();
|
||||
} catch (error) {
|
||||
|
|
@ -50438,6 +50721,7 @@ async function bulkRepairAction(action) {
|
|||
});
|
||||
showToast(`${_repairSelectedFindings.size} findings ${action === 'dismiss' ? 'dismissed' : 'resolved'}`, 'success');
|
||||
_repairSelectedFindings.clear();
|
||||
loadRepairFindingsDashboard();
|
||||
loadRepairFindings();
|
||||
updateRepairStatus();
|
||||
} catch (error) {
|
||||
|
|
@ -50471,18 +50755,26 @@ async function loadRepairHistory() {
|
|||
const statusClass = run.status === 'completed' ? 'success' :
|
||||
run.status === 'failed' ? 'error' : 'running';
|
||||
|
||||
// Build stat pills
|
||||
const stats = [];
|
||||
stats.push(`<span class="repair-history-stat"><strong>${(run.items_scanned || 0).toLocaleString()}</strong> scanned</span>`);
|
||||
if (run.findings_created) stats.push(`<span class="repair-history-stat findings"><strong>${run.findings_created}</strong> findings</span>`);
|
||||
if (run.auto_fixed) stats.push(`<span class="repair-history-stat fixed"><strong>${run.auto_fixed}</strong> fixed</span>`);
|
||||
if (run.errors) stats.push(`<span class="repair-history-stat errors"><strong>${run.errors}</strong> errors</span>`);
|
||||
|
||||
// Format timestamps
|
||||
const startTime = run.started_at ? new Date(run.started_at).toLocaleString() : '-';
|
||||
const endTime = run.finished_at ? new Date(run.finished_at).toLocaleString() : 'In progress';
|
||||
|
||||
return `<div class="repair-history-entry">
|
||||
<div class="repair-history-job">
|
||||
<div class="repair-history-header">
|
||||
<div class="repair-history-dot ${statusClass}"></div>
|
||||
<span class="repair-history-name">${run.display_name || run.job_id}</span>
|
||||
<span class="repair-history-status ${statusClass}">${run.status}</span>
|
||||
<span class="repair-history-duration">${duration}</span>
|
||||
</div>
|
||||
<div class="repair-history-meta">
|
||||
${age} · ${duration} ·
|
||||
Scanned: ${(run.items_scanned || 0).toLocaleString()} ·
|
||||
Fixed: ${run.auto_fixed || 0} ·
|
||||
Findings: ${run.findings_created || 0}
|
||||
${run.errors ? ` · Errors: ${run.errors}` : ''}
|
||||
</div>
|
||||
<div class="repair-history-stats">${stats.join('')}</div>
|
||||
<div class="repair-history-meta">${age} · ${startTime} → ${endTime}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
|
|
|
|||
|
|
@ -39720,9 +39720,9 @@ tr.tag-diff-same {
|
|||
background: rgba(14, 14, 14, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
max-height: 85vh;
|
||||
width: 94vw;
|
||||
max-width: 1100px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
|
@ -40132,19 +40132,134 @@ tr.tag-diff-same {
|
|||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── Findings Dashboard ── */
|
||||
.repair-findings-dashboard {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.repair-dashboard-summary {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.repair-dashboard-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.repair-dashboard-stat .stat-count {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.repair-dashboard-stat.pending .stat-count {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.repair-dashboard-stat.resolved .stat-count {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.repair-dashboard-stat.dismissed .stat-count {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.repair-dashboard-stat.auto-fixed .stat-count {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.repair-dashboard-jobs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.repair-dashboard-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.repair-dashboard-chip:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.repair-dashboard-chip.active {
|
||||
background: rgba(var(--accent-rgb, 99, 102, 241), 0.12);
|
||||
border-color: rgba(var(--accent-rgb, 99, 102, 241), 0.3);
|
||||
}
|
||||
|
||||
.repair-dashboard-chip-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.repair-dashboard-chip-count {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.repair-dashboard-chip-bar {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.repair-dashboard-chip-severity {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.repair-dashboard-chip-severity.warning {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 4px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.repair-dashboard-chip-severity.info {
|
||||
background: #3b82f6;
|
||||
box-shadow: 0 0 4px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* ── Findings Tab ── */
|
||||
.repair-findings-header {
|
||||
.repair-findings-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.repair-findings-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.repair-findings-filters select {
|
||||
|
|
@ -40161,9 +40276,20 @@ tr.tag-diff-same {
|
|||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.repair-findings-summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.repair-findings-summary strong {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.repair-findings-bulk {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.repair-bulk-btn {
|
||||
|
|
@ -40188,38 +40314,39 @@ tr.tag-diff-same {
|
|||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Finding card — expandable two-row layout */
|
||||
.repair-finding-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(22, 22, 22, 0.8);
|
||||
background: rgba(22, 22, 22, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.2s ease;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.repair-finding-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(28, 28, 28, 0.98);
|
||||
}
|
||||
|
||||
.repair-finding-card.warning {
|
||||
border-left: 3px solid rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.repair-finding-card.critical {
|
||||
border-left: 3px solid rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.repair-finding-card.info {
|
||||
border-left: 3px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.repair-finding-select {
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
.repair-finding-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.repair-finding-select {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.repair-finding-select input[type="checkbox"] {
|
||||
accent-color: var(--accent-color, #6366f1);
|
||||
}
|
||||
|
|
@ -40227,23 +40354,24 @@ tr.tag-diff-same {
|
|||
.repair-finding-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.repair-finding-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
margin-bottom: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.repair-finding-icon {
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.repair-finding-status-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
|
|
@ -40251,78 +40379,211 @@ tr.tag-diff-same {
|
|||
border-radius: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.repair-finding-status-badge.resolved {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.repair-finding-status-badge.dismissed {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.repair-finding-type-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--accent-rgb, 99, 102, 241), 0.1);
|
||||
color: rgb(var(--accent-light-rgb, 129, 140, 248));
|
||||
}
|
||||
|
||||
.repair-finding-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin-bottom: 3px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.repair-finding-path {
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.repair-finding-meta {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.repair-finding-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.repair-finding-btn {
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 5px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.repair-finding-btn.resolve {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.repair-finding-btn.resolve:hover {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.repair-finding-btn.dismiss {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.repair-finding-btn.dismiss:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
.repair-finding-expand-btn {
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 5px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.repair-finding-expand-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.repair-finding-expand-btn.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Finding detail panel — expandable */
|
||||
.repair-finding-detail {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease;
|
||||
}
|
||||
.repair-finding-detail.open {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.repair-finding-detail-inner {
|
||||
padding: 12px 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Detail key-value rows */
|
||||
.repair-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.repair-detail-key {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
.repair-detail-val {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
word-break: break-all;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.repair-detail-val.path {
|
||||
color: rgba(var(--accent-light-rgb, 129, 140, 248), 0.8);
|
||||
}
|
||||
.repair-detail-val.highlight {
|
||||
color: #fbbf24;
|
||||
}
|
||||
.repair-detail-val.success {
|
||||
color: #4ade80;
|
||||
}
|
||||
.repair-detail-val.error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Detail sub-items (for duplicates, missing tracks, etc.) */
|
||||
.repair-detail-sublist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.repair-detail-subitem {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.repair-detail-subitem strong {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 500;
|
||||
}
|
||||
.repair-detail-subitem .mono {
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Score bars (for AcoustID) */
|
||||
.repair-score-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.repair-score-bar-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
max-width: 120px;
|
||||
}
|
||||
.repair-score-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.repair-score-bar-fill.good { background: #4ade80; }
|
||||
.repair-score-bar-fill.warn { background: #fbbf24; }
|
||||
.repair-score-bar-fill.bad { background: #f87171; }
|
||||
|
||||
/* Pagination */
|
||||
.repair-findings-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 0 4px;
|
||||
padding: 14px 0 4px;
|
||||
}
|
||||
|
||||
.repair-page-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
|
@ -40333,17 +40594,20 @@ tr.tag-diff-same {
|
|||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.repair-page-btn.active {
|
||||
background: var(--accent-color, #6366f1);
|
||||
color: #fff;
|
||||
border-color: var(--accent-color, #6366f1);
|
||||
}
|
||||
|
||||
.repair-page-btn:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
.repair-page-info {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* ── History Tab ── */
|
||||
.repair-history-list {
|
||||
|
|
@ -40353,23 +40617,47 @@ tr.tag-diff-same {
|
|||
}
|
||||
|
||||
.repair-history-entry {
|
||||
padding: 10px 14px;
|
||||
background: rgba(22, 22, 22, 0.8);
|
||||
padding: 12px 14px;
|
||||
background: rgba(22, 22, 22, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.repair-history-entry:hover {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(28, 28, 28, 0.98);
|
||||
}
|
||||
|
||||
.repair-history-job {
|
||||
.repair-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.repair-history-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.repair-history-dot.success {
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 6px rgba(74, 222, 128, 0.4);
|
||||
}
|
||||
.repair-history-dot.error {
|
||||
background: #f87171;
|
||||
box-shadow: 0 0 6px rgba(248, 113, 113, 0.4);
|
||||
}
|
||||
.repair-history-dot.running {
|
||||
background: rgb(var(--accent-rgb, 99, 102, 241));
|
||||
box-shadow: 0 0 6px rgba(var(--accent-rgb, 99, 102, 241), 0.4);
|
||||
}
|
||||
|
||||
.repair-history-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.repair-history-status {
|
||||
|
|
@ -40379,32 +40667,72 @@ tr.tag-diff-same {
|
|||
border-radius: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.repair-history-status.success {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.repair-history-status.error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.repair-history-status.running {
|
||||
background: rgba(var(--accent-rgb, 99, 102, 241), 0.12);
|
||||
color: var(--accent-color, #6366f1);
|
||||
}
|
||||
|
||||
.repair-history-meta {
|
||||
.repair-history-duration {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.repair-history-stats {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.repair-history-stat {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.repair-history-stat strong {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 600;
|
||||
}
|
||||
.repair-history-stat.findings {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
color: rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
.repair-history-stat.findings strong {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.repair-history-stat.errors {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
.repair-history-stat.errors strong {
|
||||
color: #ef4444;
|
||||
}
|
||||
.repair-history-stat.fixed {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
color: rgba(34, 197, 94, 0.7);
|
||||
}
|
||||
.repair-history-stat.fixed strong {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.repair-history-meta {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 768px) {
|
||||
.repair-modal {
|
||||
width: 98vw;
|
||||
max-height: 92vh;
|
||||
max-height: 95vh;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
|
|
@ -40428,6 +40756,15 @@ tr.tag-diff-same {
|
|||
.repair-job-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.repair-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.repair-help-modal {
|
||||
max-width: 95vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Repair Job Live Progress Panel ── */
|
||||
|
|
|
|||
Loading…
Reference in a new issue