feat: Add progress bar for batch SEO audit

- Progress section with bar and percentage
- Real-time log showing each company being processed
- Score display with color indicators (🟢🟡🔴)
- Cancel button to stop audit mid-process
- Summary at the end with success/failed/skipped counts
- 500ms delay between requests to avoid API overload

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-13 17:55:17 +01:00
parent 0d2b26031d
commit a916b297c7

View File

@ -462,6 +462,80 @@
justify-content: flex-end;
gap: var(--spacing-sm);
}
/* Progress Section */
.progress-section {
background: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--spacing-xl);
display: none;
}
.progress-section.active {
display: block;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.progress-title {
font-size: var(--font-size-lg);
font-weight: 600;
}
.progress-bar-container {
height: 24px;
background: var(--border);
border-radius: 12px;
overflow: hidden;
margin-bottom: var(--spacing-sm);
}
.progress-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 12px;
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: var(--font-size-sm);
}
.progress-message {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.progress-log {
max-height: 250px;
overflow-y: auto;
font-family: monospace;
font-size: var(--font-size-sm);
background: var(--background);
padding: var(--spacing-md);
border-radius: var(--radius);
margin-top: var(--spacing-md);
}
.progress-log-entry {
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--border);
}
.progress-log-entry.success { color: var(--success); font-weight: 600; }
.progress-log-entry.error { color: var(--error); font-weight: 600; }
.progress-log-entry.skipped { color: var(--warning, #f59e0b); }
.progress-log-entry.detail { color: var(--text-secondary); font-size: 0.9em; padding-left: 1em; border-bottom: none; }
.progress-log-entry.info { color: var(--text-secondary); font-style: italic; }
</style>
{% endblock %}
@ -519,6 +593,19 @@
</div>
</div>
<!-- Progress Section (hidden by default) -->
<div class="progress-section" id="progressSection">
<div class="progress-header">
<span class="progress-title">Audyt SEO w toku...</span>
<button class="btn btn-sm btn-outline" onclick="cancelAudit()">Anuluj</button>
</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="progressBar" style="width: 0%">0%</div>
</div>
<div class="progress-message" id="progressMessage">Przygotowywanie...</div>
<div class="progress-log" id="progressLog"></div>
</div>
<!-- Filters -->
<div class="filters-bar">
<div class="filter-group">
@ -597,7 +684,11 @@
</thead>
<tbody id="seoTableBody">
{% for company in companies %}
<tr data-category="{{ company.category }}"
<tr data-company-id="{{ company.id }}"
data-slug="{{ company.slug }}"
data-company-name="{{ company.name }}"
data-website="{{ company.website or '' }}"
data-category="{{ company.category }}"
data-name="{{ company.name|lower }}"
data-overall="{{ company.seo_score if company.seo_score is not none else -1 }}"
data-performance="{{ company.performance_score if company.performance_score is not none else -1 }}"
@ -877,11 +968,44 @@ function resetFilters() {
applyFilters();
}
// Helper functions
let auditInProgress = false;
function addLogEntry(logElement, message, type) {
const entry = document.createElement('div');
entry.className = 'progress-log-entry ' + type;
entry.textContent = message;
logElement.appendChild(entry);
logElement.scrollTop = logElement.scrollHeight;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getScoreClass(score) {
if (score >= 90) return '🟢';
if (score >= 50) return '🟡';
return '🔴';
}
function cancelAudit() {
auditInProgress = false;
document.getElementById('progressSection').classList.remove('active');
document.getElementById('batchAuditBtn').disabled = false;
document.getElementById('batchAuditBtn').innerHTML = `
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Uruchom audyt
`;
}
// Audit functions
function runSingleAudit(slug) {
showModal(
'Uruchom audyt SEO',
'Czy na pewno chcesz uruchomić audyt SEO dla tej firmy? Analiza może potrwać kilka minut.',
'Czy na pewno chcesz uruchomić audyt SEO dla tej firmy? Analiza może potrwać kilka sekund.',
async () => {
try {
const response = await fetch('/api/seo/audit', {
@ -908,22 +1032,138 @@ function runSingleAudit(slug) {
);
}
function runBatchAudit() {
async function runBatchAudit() {
showModal(
'Uruchom audyt wsadowy',
'Czy na pewno chcesz uruchomić audyt SEO dla wszystkich firm? To może potrwać dłuższy czas.',
() => {
'Uruchom audyt wszystkich firm',
'Czy chcesz uruchomić audyt SEO dla wszystkich firm z adresem WWW? Każda firma będzie analizowana osobno.',
async () => {
auditInProgress = true;
const btn = document.getElementById('batchAuditBtn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span>Audyt w toku...</span>';
showInfoModal('Audyt uruchomiony', 'Audyt wsadowy został uruchomiony w tle. Może to potrwać kilkadziesiąt minut. Sprawdź wyniki później.');
const progressSection = document.getElementById('progressSection');
progressSection.classList.add('active');
setTimeout(() => {
const progressBar = document.getElementById('progressBar');
const progressMessage = document.getElementById('progressMessage');
const progressLog = document.getElementById('progressLog');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressMessage.textContent = 'Pobieranie listy firm...';
progressLog.innerHTML = '';
// Get all companies with website from the table
const rows = document.querySelectorAll('#seoTableBody tr[data-slug]');
const companies = [];
rows.forEach(row => {
const website = row.dataset.website;
if (website && website.trim() !== '') {
companies.push({
slug: row.dataset.slug,
name: row.dataset.companyName || row.dataset.name,
website: website
});
}
});
if (companies.length === 0) {
progressSection.classList.remove('active');
showInfoModal('Brak firm', 'Nie znaleziono firm z adresem WWW do audytu.');
auditInProgress = false;
btn.disabled = false;
btn.innerHTML = originalText;
}, 2000);
btn.innerHTML = `
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Uruchom audyt
`;
return;
}
addLogEntry(progressLog, `Znaleziono ${companies.length} firm z adresem WWW`, 'info');
let success = 0;
let failed = 0;
let skipped = 0;
for (let i = 0; i < companies.length; i++) {
if (!auditInProgress) {
addLogEntry(progressLog, 'Audyt anulowany przez użytkownika', 'error');
break;
}
const company = companies[i];
const percent = Math.round(((i + 1) / companies.length) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
progressMessage.textContent = `[${i + 1}/${companies.length}] Analizuję: ${company.name}...`;
try {
const response = await fetch('/api/seo/audit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ slug: company.slug })
});
const data = await response.json();
if (response.ok && data.success) {
success++;
const scores = data.data || {};
const seo = scores.seo_score ?? '-';
const perf = scores.performance_score ?? '-';
const acc = scores.accessibility_score ?? '-';
const bp = scores.best_practices_score ?? '-';
addLogEntry(progressLog, `✓ ${company.name}`, 'success');
addLogEntry(progressLog, ` SEO: ${seo !== '-' ? getScoreClass(seo) + ' ' + seo : '-'} | Perf: ${perf !== '-' ? getScoreClass(perf) + ' ' + perf : '-'} | Dostępność: ${acc !== '-' ? getScoreClass(acc) + ' ' + acc : '-'} | BP: ${bp !== '-' ? getScoreClass(bp) + ' ' + bp : '-'}`, 'detail');
} else {
if (data.error && data.error.includes('Brak strony WWW')) {
skipped++;
addLogEntry(progressLog, `⊘ ${company.name} - Brak WWW`, 'skipped');
} else {
failed++;
addLogEntry(progressLog, `✗ ${company.name} - ${data.error || 'Błąd'}`, 'error');
}
}
} catch (error) {
failed++;
addLogEntry(progressLog, `✗ ${company.name} - Błąd połączenia`, 'error');
}
// Delay between requests to avoid overwhelming the API
await sleep(500);
}
progressBar.style.width = '100%';
progressBar.textContent = '100%';
progressMessage.textContent = `Audyt zakończony! Sukces: ${success}, Błędy: ${failed}, Pominięte: ${skipped}`;
addLogEntry(progressLog, `─────────────────────────────`, 'info');
addLogEntry(progressLog, `PODSUMOWANIE: Sukces: ${success}, Błędy: ${failed}, Pominięte: ${skipped}`, 'info');
showInfoModal(
'Audyt zakończony',
`Przetworzono ${companies.length} firm.\n\nSukces: ${success}\nBłędy: ${failed}\nPominięte: ${skipped}`
);
auditInProgress = false;
btn.disabled = false;
btn.innerHTML = `
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Uruchom audyt
`;
// Refresh page after a delay
setTimeout(() => location.reload(), 3000);
}
);
}