nordabiz/templates/admin/portal_seo.html
Maciej Pienczyn 0b7f9fe098
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
improve: comprehensive portal SEO audit dashboard with all metrics
Show all collected SEO data in history table organized by color-coded groups:
PageSpeed scores, Core Web Vitals, On-Page SEO checks, Security headers,
Content metrics, and composite scores. Dashboard includes score cards with
deltas, check grid for 17 boolean checks, content metrics grid, and
horizontally scrollable history table with ~35 columns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:49:31 +01:00

595 lines
25 KiB
HTML

{% extends "base.html" %}
{% block title %}SEO Portalu - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.portal-seo-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.score-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.score-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-md);
text-align: center;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.score-card-value {
font-size: 2rem;
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
}
.score-card-label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.score-card-delta {
font-size: var(--font-size-sm);
margin-top: 2px;
}
.score-good { color: var(--success); }
.score-ok { color: var(--warning); }
.score-bad { color: var(--error); }
.delta-up { color: var(--success); }
.delta-down { color: var(--error); }
.delta-same { color: var(--text-secondary); }
.section-box {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.section-box h3 {
margin-bottom: var(--spacing-md);
font-size: var(--font-size-md);
}
.check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-xs);
}
.check-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: var(--radius);
font-size: 13px;
border-left: 3px solid var(--border);
}
.check-pass { border-left-color: var(--success); background: #f0fdf4; }
.check-fail { border-left-color: var(--error); background: #fef2f2; }
.metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-sm);
}
.metric-item {
display: flex;
flex-direction: column;
padding: 8px 12px;
background: #f8fafc;
border-radius: var(--radius);
}
.metric-value { font-size: 1.3rem; font-weight: 700; }
.metric-label { font-size: 11px; color: var(--text-secondary); text-transform: uppercase; }
/* History table - scrollable */
.history-wrapper {
overflow-x: auto;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.history-table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
font-size: 12px;
white-space: nowrap;
}
.history-table th, .history-table td {
padding: 6px 10px;
border-bottom: 1px solid var(--border);
text-align: center;
}
.history-table th {
background: #f1f5f9;
font-weight: 600;
color: var(--text-secondary);
position: relative;
}
.history-table thead tr:first-child th {
border-bottom: 2px solid var(--border);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.history-table thead tr:nth-child(2) th {
background: #f8fafc;
font-size: 11px;
}
.history-table tbody tr:hover td { background: #f8fafc; }
.history-table td:first-child,
.history-table th:first-child {
position: sticky;
left: 0;
z-index: 1;
background: white;
text-align: left;
border-right: 2px solid var(--border);
}
.history-table thead th:first-child { background: #f1f5f9; }
.history-table tbody tr:hover td:first-child { background: #f8fafc; }
.score-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-weight: 600;
font-size: 11px;
}
.score-badge.good { background: #d1fae5; color: #065f46; }
.score-badge.ok { background: #fef3c7; color: #92400e; }
.score-badge.bad { background: #fee2e2; color: #991b1b; }
.bool-yes { color: var(--success); font-weight: 700; }
.bool-no { color: var(--error); font-weight: 700; }
.grp-pagespeed { border-left: 3px solid #3b82f6; }
.grp-cwv { border-left: 3px solid #8b5cf6; }
.grp-onpage { border-left: 3px solid #10b981; }
.grp-security { border-left: 3px solid #f59e0b; }
.grp-content { border-left: 3px solid #ec4899; }
.grp-scores { border-left: 3px solid #06b6d4; }
.run-form {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
}
/* Progress panel */
.audit-progress {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
display: none;
}
.audit-progress.active { display: block; }
.progress-bar-container {
background: #e2e8f0;
border-radius: 999px;
height: 8px;
margin-bottom: var(--spacing-md);
overflow: hidden;
}
.progress-bar-fill {
background: var(--primary);
height: 100%;
border-radius: 999px;
width: 0%;
transition: width 0.4s ease;
}
.progress-steps { list-style: none; padding: 0; margin: 0; }
.progress-steps li {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: 5px 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.progress-steps li.active { color: var(--text-primary); font-weight: 600; }
.progress-steps li.done { color: var(--success); }
.progress-steps li.error { color: var(--error); }
.progress-steps li.warning { color: var(--warning); }
.step-icon { width: 20px; text-align: center; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--primary);
border-radius: 50%; animation: spin 0.6s linear infinite;
}
</style>
{% endblock %}
{% block content %}
<div class="portal-seo-header">
<div>
<h1 style="margin-bottom: var(--spacing-xs);">SEO Portalu</h1>
<p style="color: var(--text-secondary);">Audyt {{ portal_url }} &mdash; pełna historia zmian</p>
</div>
<div class="run-form">
<input type="text" id="audit-notes" placeholder="Notatka (opcjonalnie)" style="padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius); font-size: var(--font-size-sm); width: 200px;">
<button type="button" id="btn-run-audit" class="btn btn-primary" style="white-space: nowrap;" onclick="startAudit()">
Uruchom audyt
</button>
</div>
</div>
<!-- Progress panel -->
<div class="audit-progress" id="audit-progress">
<h3 style="margin-bottom: var(--spacing-md);">Audyt w toku...</h3>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="progress-bar"></div>
</div>
<ul class="progress-steps" id="progress-steps">
<li data-step="1"><span class="step-icon">&#9675;</span> Inicjalizacja audytora SEO</li>
<li data-step="2"><span class="step-icon">&#9675;</span> Pobieranie strony</li>
<li data-step="3"><span class="step-icon">&#9675;</span> Analiza on-page SEO</li>
<li data-step="4"><span class="step-icon">&#9675;</span> Sprawdzanie techniczne</li>
<li data-step="5"><span class="step-icon">&#9675;</span> PageSpeed Insights API</li>
<li data-step="6"><span class="step-icon">&#9675;</span> Analiza Local SEO</li>
<li data-step="7"><span class="step-icon">&#9675;</span> Sprawdzanie cytowań</li>
<li data-step="8"><span class="step-icon">&#9675;</span> Sprawdzanie aktualności treści</li>
<li data-step="9"><span class="step-icon">&#9675;</span> Zapisywanie wyników</li>
</ul>
</div>
{% if audits %}
{% set latest = audits[0] %}
{% set prev = audits[1] if audits|length > 1 else None %}
{% set fr = latest.full_results or {} %}
{% set fr_prev = prev.full_results or {} if prev else {} %}
{# Helper macros #}
{% macro score_card(label, value, prev_value, unit='', threshold_good=90, threshold_ok=50, lower_better=false) %}
<div class="score-card">
<div class="score-card-value {% if value is not none %}{{ 'score-good' if (not lower_better and value >= threshold_good) or (lower_better and value <= threshold_good) else ('score-ok' if (not lower_better and value >= threshold_ok) or (lower_better and value <= threshold_ok) else 'score-bad') }}{% endif %}">
{% if value is not none %}{{ value }}{{ unit }}{% else %}&mdash;{% endif %}
</div>
<div class="score-card-label">{{ label }}</div>
{% if prev_value is not none and value is not none %}
{% set delta = value - prev_value %}
<div class="score-card-delta {{ 'delta-up' if (not lower_better and delta > 0) or (lower_better and delta < 0) else ('delta-down' if (not lower_better and delta < 0) or (lower_better and delta > 0) else 'delta-same') }}">
{{ '+' if delta > 0 else '' }}{{ delta }}{{ unit }}
</div>
{% endif %}
</div>
{% endmacro %}
{% macro check(label, value) %}
<div class="check-item {{ 'check-pass' if value else 'check-fail' }}">
{% if value %}<span style="color:var(--success)">&#10003;</span>{% else %}<span style="color:var(--error)">&#10007;</span>{% endif %}
{{ label }}
</div>
{% endmacro %}
<!-- Latest audit header -->
<h2 style="font-size: var(--font-size-lg); margin-bottom: var(--spacing-md); color: var(--text-secondary);">
Ostatni audyt: {{ latest.audited_at.strftime('%d.%m.%Y %H:%M') }}
{% if latest.notes %}<span style="font-size: var(--font-size-sm); font-weight: 400;"> &mdash; {{ latest.notes }}</span>{% endif %}
</h2>
<!-- PageSpeed Scores -->
<div class="section-box">
<h3>PageSpeed Insights</h3>
<div class="score-cards">
{{ score_card('Performance', latest.pagespeed_performance, prev.pagespeed_performance if prev else None) }}
{{ score_card('SEO', latest.pagespeed_seo, prev.pagespeed_seo if prev else None) }}
{{ score_card('Accessibility', latest.pagespeed_accessibility, prev.pagespeed_accessibility if prev else None) }}
{{ score_card('Best Practices', latest.pagespeed_best_practices, prev.pagespeed_best_practices if prev else None) }}
</div>
</div>
<!-- Core Web Vitals -->
<div class="section-box">
<h3>Core Web Vitals</h3>
<div class="score-cards">
{% set ps = fr.get('pagespeed', {}) %}
{% set cwv = ps.get('core_web_vitals', {}) %}
{% set ps_prev = fr_prev.get('pagespeed', {}) %}
{% set cwv_prev = ps_prev.get('core_web_vitals', {}) %}
{{ score_card('LCP', cwv.get('lcp_ms'), cwv_prev.get('lcp_ms'), 'ms', 2500, 4000, true) }}
{{ score_card('FCP', cwv.get('fcp_ms'), cwv_prev.get('fcp_ms'), 'ms', 1800, 3000, true) }}
{{ score_card('CLS', cwv.get('cls'), cwv_prev.get('cls'), '', 0.1, 0.25, true) }}
{{ score_card('TBT', cwv.get('tbt_ms'), cwv_prev.get('tbt_ms'), 'ms', 200, 600, true) }}
{{ score_card('TTFB', cwv.get('ttfb_ms'), cwv_prev.get('ttfb_ms'), 'ms', 800, 1800, true) }}
{{ score_card('INP', cwv.get('inp_ms'), cwv_prev.get('inp_ms'), 'ms', 200, 500, true) }}
{{ score_card('SI', cwv.get('speed_index_ms') or cwv.get('speed_index'), cwv_prev.get('speed_index_ms') or cwv_prev.get('speed_index'), 'ms', 3400, 5800, true) }}
{{ score_card('Load', fr.get('load_time_ms'), fr_prev.get('load_time_ms'), 'ms', 1000, 3000, true) }}
</div>
</div>
<!-- On-Page + Technical checks -->
<div class="section-box">
<h3>Elementy SEO i bezpieczeństwo</h3>
<div class="check-grid">
{{ check('Meta Title', latest.has_meta_title) }}
{{ check('Meta Description', latest.has_meta_description) }}
{{ check('Canonical URL', latest.has_canonical) }}
{{ check('robots.txt', latest.has_robots_txt) }}
{{ check('sitemap.xml', latest.has_sitemap) }}
{{ check('Structured Data', latest.has_structured_data) }}
{{ check('Open Graph', latest.has_og_tags) }}
{{ check('SSL/HTTPS', latest.has_ssl) }}
{{ check('Indexable', fr.get('technical', {}).get('indexability', {}).get('is_indexable', None)) }}
{{ check('Viewport', fr.get('onpage', {}).get('meta_tags', {}).get('viewport') is not none) }}
{{ check('Lang Attribute', fr.get('onpage', {}).get('has_lang_attribute', false)) }}
{{ check('DOCTYPE', fr.get('onpage', {}).get('has_doctype', false)) }}
{{ check('Single H1', fr.get('onpage', {}).get('headings', {}).get('has_single_h1', false)) }}
{{ check('HSTS', latest.has_hsts) }}
{{ check('CSP', latest.has_csp) }}
{{ check('X-Frame-Options', latest.has_x_frame) }}
{{ check('X-Content-Type', latest.has_x_content_type) }}
</div>
</div>
<!-- Content & Link metrics -->
<div class="section-box">
<h3>Metryki treści i linków</h3>
{% set onpage = fr.get('onpage', {}) %}
{% set imgs = onpage.get('images', {}) %}
{% set links = onpage.get('links', {}) %}
{% set headings = onpage.get('headings', {}) %}
<div class="metric-grid">
<div class="metric-item">
<span class="metric-value">{{ onpage.get('word_count', '—') }}</span>
<span class="metric-label">Słowa</span>
</div>
<div class="metric-item">
<span class="metric-value">{{ headings.get('h1_count', '—') }}</span>
<span class="metric-label">H1</span>
</div>
<div class="metric-item">
<span class="metric-value">{{ headings.get('h2_count', '—') }}</span>
<span class="metric-label">H2</span>
</div>
<div class="metric-item">
<span class="metric-value">{{ imgs.get('total_images', '—') }}</span>
<span class="metric-label">Obrazy</span>
</div>
<div class="metric-item">
<span class="metric-value {{ 'score-bad' if imgs.get('images_without_alt', 0) > 0 else 'score-good' }}">{{ imgs.get('images_without_alt', '—') }}</span>
<span class="metric-label">Bez alt</span>
</div>
<div class="metric-item">
<span class="metric-value">{{ links.get('total_links', '—') }}</span>
<span class="metric-label">Linki</span>
</div>
<div class="metric-item">
<span class="metric-value">{{ links.get('internal_links', '—') }}</span>
<span class="metric-label">Wewnętrzne</span>
</div>
<div class="metric-item">
<span class="metric-value">{{ links.get('external_links', '—') }}</span>
<span class="metric-label">Zewnętrzne</span>
</div>
</div>
</div>
<!-- Local SEO, Freshness, Citations -->
<div class="section-box">
<h3>Local SEO, aktualność i cytowania</h3>
{% set local = fr.get('local_seo', {}) %}
{% set fresh = fr.get('freshness', {}) %}
{% set cit = fr.get('citations', []) %}
{% set cit_found = cit|selectattr('status', 'equalto', 'found')|list|length if cit else 0 %}
<div class="score-cards">
{{ score_card('Local SEO', local.get('local_seo_score'), None, '/100', 70, 40) }}
{{ score_card('Aktualność', fresh.get('content_freshness_score'), None, '/100', 80, 40) }}
{{ score_card('Cytowania', cit_found, None, '/' ~ (cit|length), cit|length, (cit|length)//2) }}
{{ score_card('Overall', fr.get('scores', {}).get('overall_seo'), None, '', 80, 50) }}
</div>
</div>
{% endif %}
<!-- ============================================================ -->
<!-- FULL HISTORY TABLE -->
<!-- ============================================================ -->
<h3 style="margin-bottom: var(--spacing-md);">Historia audytów</h3>
{% if audits %}
<div class="history-wrapper">
<table class="history-table">
<thead>
<tr>
<th rowspan="2" style="min-width:120px">Data</th>
<th colspan="4" class="grp-pagespeed">PageSpeed</th>
<th colspan="7" class="grp-cwv">Core Web Vitals</th>
<th colspan="7" class="grp-onpage">On-Page SEO</th>
<th colspan="4" class="grp-security">Security</th>
<th colspan="5" class="grp-content">Content</th>
<th colspan="4" class="grp-scores">Scores</th>
<th rowspan="2">Notatka</th>
<th rowspan="2"></th>
</tr>
<tr>
<!-- PageSpeed -->
<th class="grp-pagespeed">Perf</th><th class="grp-pagespeed">SEO</th><th class="grp-pagespeed">A11y</th><th class="grp-pagespeed">BP</th>
<!-- CWV -->
<th class="grp-cwv">LCP</th><th class="grp-cwv">FCP</th><th class="grp-cwv">CLS</th><th class="grp-cwv">TBT</th><th class="grp-cwv">TTFB</th><th class="grp-cwv">INP</th><th class="grp-cwv">Load</th>
<!-- On-Page -->
<th class="grp-onpage">Title</th><th class="grp-onpage">Desc</th><th class="grp-onpage">Canon</th><th class="grp-onpage">Robots</th><th class="grp-onpage">Sitemap</th><th class="grp-onpage">Schema</th><th class="grp-onpage">OG</th>
<!-- Security -->
<th class="grp-security">HSTS</th><th class="grp-security">CSP</th><th class="grp-security">X-Frame</th><th class="grp-security">X-CT</th>
<!-- Content -->
<th class="grp-content">Słowa</th><th class="grp-content">Obrazy</th><th class="grp-content">No-Alt</th><th class="grp-content">Linki</th><th class="grp-content">H1</th>
<!-- Scores -->
<th class="grp-scores">Local</th><th class="grp-scores">Fresh</th><th class="grp-scores">Cit.</th><th class="grp-scores">Overall</th>
</tr>
</thead>
<tbody>
{% for a in audits %}
{% set f = a.full_results or {} %}
{% set f_ps = f.get('pagespeed', {}) %}
{% set f_cwv = f_ps.get('core_web_vitals', {}) %}
{% set f_op = f.get('onpage', {}) %}
{% set f_imgs = f_op.get('images', {}) %}
{% set f_links = f_op.get('links', {}) %}
{% set f_heads = f_op.get('headings', {}) %}
{% set f_local = f.get('local_seo', {}) %}
{% set f_fresh = f.get('freshness', {}) %}
{% set f_cit = f.get('citations', []) %}
{% set f_cit_found = f_cit|selectattr('status', 'equalto', 'found')|list|length if f_cit else 0 %}
<tr>
<td>{{ a.audited_at.strftime('%d.%m %H:%M') }}</td>
{# PageSpeed scores #}
{% macro std(val, good=90, ok=50) %}<td>{% if val is not none %}<span class="score-badge {{ 'good' if val >= good else ('ok' if val >= ok else 'bad') }}">{{ val }}</span>{% else %}&mdash;{% endif %}</td>{% endmacro %}
{{ std(a.pagespeed_performance) }}
{{ std(a.pagespeed_seo) }}
{{ std(a.pagespeed_accessibility) }}
{{ std(a.pagespeed_best_practices) }}
{# CWV - lower is better #}
{% macro cwv_td(val, unit='ms', good=2500, ok=4000) %}<td>{% if val is not none %}<span class="score-badge {{ 'good' if val <= good else ('ok' if val <= ok else 'bad') }}">{{ val }}{{ unit }}</span>{% else %}&mdash;{% endif %}</td>{% endmacro %}
{{ cwv_td(f_cwv.get('lcp_ms'), 'ms', 2500, 4000) }}
{{ cwv_td(f_cwv.get('fcp_ms'), 'ms', 1800, 3000) }}
{{ cwv_td(f_cwv.get('cls'), '', 0.1, 0.25) }}
{{ cwv_td(f_cwv.get('tbt_ms'), 'ms', 200, 600) }}
{{ cwv_td(f_cwv.get('ttfb_ms'), 'ms', 800, 1800) }}
{{ cwv_td(f_cwv.get('inp_ms'), 'ms', 200, 500) }}
{{ cwv_td(f.get('load_time_ms'), 'ms', 1000, 3000) }}
{# On-Page booleans #}
{% macro bool_td(val) %}<td>{% if val %}<span class="bool-yes">&#10003;</span>{% elif val is sameas false %}<span class="bool-no">&#10007;</span>{% else %}&mdash;{% endif %}</td>{% endmacro %}
{{ bool_td(a.has_meta_title) }}
{{ bool_td(a.has_meta_description) }}
{{ bool_td(a.has_canonical) }}
{{ bool_td(a.has_robots_txt) }}
{{ bool_td(a.has_sitemap) }}
{{ bool_td(a.has_structured_data) }}
{{ bool_td(a.has_og_tags) }}
{# Security booleans #}
{{ bool_td(a.has_hsts) }}
{{ bool_td(a.has_csp) }}
{{ bool_td(a.has_x_frame) }}
{{ bool_td(a.has_x_content_type) }}
{# Content metrics #}
<td>{{ f_op.get('word_count', '—') }}</td>
<td>{{ f_imgs.get('total_images', '—') }}</td>
<td>{% if f_imgs.get('images_without_alt') is not none %}<span class="{{ 'bool-no' if f_imgs.get('images_without_alt', 0) > 0 else 'bool-yes' }}">{{ f_imgs.get('images_without_alt') }}</span>{% else %}&mdash;{% endif %}</td>
<td>{{ f_links.get('total_links', '—') }}</td>
<td>{{ f_heads.get('h1_count', '—') }}</td>
{# Scores #}
{{ std(f_local.get('local_seo_score'), 70, 40) }}
{{ std(f_fresh.get('content_freshness_score'), 80, 40) }}
<td>{% if f_cit %}{{ f_cit_found }}/{{ f_cit|length }}{% else %}&mdash;{% endif %}</td>
{{ std(f.get('scores', {}).get('overall_seo'), 80, 50) }}
<td style="max-width:120px; overflow:hidden; text-overflow:ellipsis;" title="{{ a.notes or '' }}">{{ a.notes or '' }}</td>
<td><a href="{{ url_for('admin.admin_portal_seo_detail', audit_id=a.id) }}" style="color:var(--primary);">Szczegóły</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: var(--spacing-2xl); color: var(--text-secondary);">
<p>Brak audytów. Kliknij "Uruchom audyt" aby wykonać pierwszy audyt SEO portalu.</p>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
function startAudit() {
var btn = document.getElementById('btn-run-audit');
var panel = document.getElementById('audit-progress');
var bar = document.getElementById('progress-bar');
var stepsList = document.getElementById('progress-steps');
var notes = document.getElementById('audit-notes').value;
btn.disabled = true;
btn.textContent = 'Trwa audyt...';
panel.classList.add('active');
var items = stepsList.querySelectorAll('li');
items.forEach(function(li) {
li.className = '';
li.querySelector('.step-icon').innerHTML = '&#9675;';
});
bar.style.width = '0%';
var url = '{{ url_for("admin.admin_portal_seo_run_stream") }}' + '?notes=' + encodeURIComponent(notes);
var source = new EventSource(url);
source.onmessage = function(e) {
var data = JSON.parse(e.data);
if (data.status === 'complete') {
source.close();
bar.style.width = '100%';
btn.textContent = 'Ukończono!';
panel.querySelector('h3').textContent = 'Audyt zakończony';
setTimeout(function() { window.location.reload(); }, 1500);
return;
}
if (data.status === 'error' && !data.step) {
source.close();
btn.disabled = false;
btn.textContent = 'Uruchom audyt';
panel.querySelector('h3').textContent = 'Błąd: ' + (data.message || 'Nieznany');
return;
}
if (data.step) {
bar.style.width = Math.round((data.step / data.total) * 100) + '%';
var li = stepsList.querySelector('li[data-step="' + data.step + '"]');
if (!li) return;
var icon = li.querySelector('.step-icon');
var label = li.childNodes[li.childNodes.length - 1];
if (data.status === 'running') {
li.className = 'active';
icon.innerHTML = '<span class="spinner"></span>';
} else if (data.status === 'done') {
li.className = 'done';
icon.innerHTML = '&#10003;';
label.textContent = ' ' + data.message;
} else if (data.status === 'error') {
li.className = 'error';
icon.innerHTML = '&#10007;';
label.textContent = ' ' + data.message;
} else if (data.status === 'warning') {
li.className = 'warning';
icon.innerHTML = '&#9888;';
label.textContent = ' ' + data.message;
} else if (data.status === 'skipped') {
li.className = '';
icon.innerHTML = '&#8212;';
label.textContent = ' ' + data.message;
}
}
};
source.onerror = function() {
source.close();
btn.disabled = false;
btn.textContent = 'Uruchom audyt';
if (bar.style.width === '100%') return;
panel.querySelector('h3').textContent = 'Połączenie przerwane';
};
}
{% endblock %}