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:
Broque Thomas 2026-03-15 12:20:02 -07:00
parent 4635ea895b
commit 61ed4086e0
6 changed files with 808 additions and 102 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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, ' ')} &middot; ${f.entity_type || 'file'} &middot; ${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>&middot;</span>
<span>${f.entity_type || 'file'}</span>
${f.entity_id ? `<span>&middot;</span><span>ID: ${f.entity_id}</span>` : ''}
<span>&middot;</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">&#10003;</button>
<button class="repair-finding-btn dismiss" onclick="dismissRepairFinding(${f.id})" title="Dismiss">&times;</button>
` : ''}
<button class="repair-finding-expand-btn" data-finding="${f.id}" title="Details">&#9660;</button>
</div>
</div>
<div class="repair-finding-actions">
${f.status === 'pending' ? `
<button class="repair-finding-btn resolve" onclick="resolveRepairFinding(${f.id})" title="Resolve">&#10003;</button>
<button class="repair-finding-btn dismiss" onclick="dismissRepairFinding(${f.id})" title="Dismiss">&times;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 ? ` &middot; 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()">&larr;</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()">&rarr;</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} &middot; ${duration} &middot;
Scanned: ${(run.items_scanned || 0).toLocaleString()} &middot;
Fixed: ${run.auto_fixed || 0} &middot;
Findings: ${run.findings_created || 0}
${run.errors ? ` &middot; Errors: ${run.errors}` : ''}
</div>
<div class="repair-history-stats">${stats.join('')}</div>
<div class="repair-history-meta">${age} &middot; ${startTime} &rarr; ${endTime}</div>
</div>`;
}).join('');

View file

@ -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 ── */