nordabiz/templates/admin/portal_seo.html
Maciej Pienczyn befbd31931
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: full-width layout for portal SEO audit page
Use container-full class to expand this page to full monitor width
so the history table with ~35 columns is visible without scrolling.
Only affects this specific page, not the rest of the portal.

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

601 lines
26 KiB
HTML

{% extends "base.html" %}
{% block title %}SEO Portalu - Norda Biznes Partner{% endblock %}
{% block container_class %}container-full{% endblock %}
{% block extra_css %}
<style>
.container.container-full {
max-width: 100%;
padding: 0 var(--spacing-lg);
}
.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 %}