improve: comprehensive portal SEO audit dashboard with all metrics
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

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>
This commit is contained in:
Maciej Pienczyn 2026-02-21 15:49:31 +01:00
parent 5484138bfb
commit 0b7f9fe098

View File

@ -14,31 +14,33 @@
}
.score-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
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-lg);
padding: var(--spacing-md);
text-align: center;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.score-card-value {
font-size: 2.5rem;
font-size: 2rem;
font-weight: 700;
line-height: 1;
margin-bottom: var(--spacing-xs);
margin-bottom: 4px;
}
.score-card-label {
font-size: var(--font-size-sm);
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: var(--spacing-xs);
margin-top: 2px;
}
.score-good { color: var(--success); }
.score-ok { color: var(--warning); }
@ -47,64 +49,118 @@
.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(200px, 1fr));
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-xs);
}
.check-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: white;
gap: 6px;
padding: 6px 10px;
border-radius: var(--radius);
border: 1px solid var(--border);
font-size: var(--font-size-sm);
font-size: 13px;
border-left: 3px solid var(--border);
}
.check-pass { border-left: 3px solid var(--success); }
.check-fail { border-left: 3px solid var(--error); }
.check-na { border-left: 3px solid var(--border); }
.check-pass { border-left-color: var(--success); background: #f0fdf4; }
.check-fail { border-left-color: var(--error); background: #fef2f2; }
.audit-history {
background: white;
.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);
overflow: hidden;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.audit-history table {
width: 100%;
.history-table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
font-size: 12px;
white-space: nowrap;
}
.audit-history th {
background: #f8fafc;
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
font-size: var(--font-size-sm);
.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;
}
.audit-history td {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border);
font-size: var(--font-size-sm);
}
.audit-history tr:hover td {
.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: 2px 8px;
border-radius: 4px;
padding: 1px 6px;
border-radius: 3px;
font-weight: 600;
font-size: 12px;
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;
@ -137,41 +193,25 @@
width: 0%;
transition: width 0.4s ease;
}
.progress-steps {
list-style: none;
padding: 0;
margin: 0;
}
.progress-steps { list-style: none; padding: 0; margin: 0; }
.progress-steps li {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: 6px 0;
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.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;
}
.step-icon.pending { color: var(--border); }
.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;
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 %}
@ -180,7 +220,7 @@
<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 }} - historia zmian w czasie</p>
<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;">
@ -197,159 +237,267 @@
<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 pending">&#9675;</span> Inicjalizacja audytora SEO</li>
<li data-step="2"><span class="step-icon pending">&#9675;</span> Pobieranie strony</li>
<li data-step="3"><span class="step-icon pending">&#9675;</span> Analiza on-page SEO</li>
<li data-step="4"><span class="step-icon pending">&#9675;</span> Sprawdzanie techniczne</li>
<li data-step="5"><span class="step-icon pending">&#9675;</span> PageSpeed Insights API</li>
<li data-step="6"><span class="step-icon pending">&#9675;</span> Analiza Local SEO</li>
<li data-step="7"><span class="step-icon pending">&#9675;</span> Sprawdzanie cytowań</li>
<li data-step="8"><span class="step-icon pending">&#9675;</span> Sprawdzanie aktualności treści</li>
<li data-step="9"><span class="step-icon pending">&#9675;</span> Zapisywanie wyników</li>
<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 {} %}
<!-- Latest scores -->
{# 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>
<div class="score-cards">
{% macro score_card(label, value, prev_value) %}
<div class="score-card">
<div class="score-card-value {{ 'score-good' if value and value >= 90 else ('score-ok' if value and value >= 50 else 'score-bad') }}">
{{ value if value is not none else '—' }}
</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 delta > 0 else ('delta-down' if delta < 0 else 'delta-same') }}">
{{ '+' if delta > 0 else '' }}{{ delta }}
</div>
{% endif %}
<!-- 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>
{% endmacro %}
{{ 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>
<!-- Key checks -->
<h3 style="margin-bottom: var(--spacing-md);">Kluczowe elementy SEO</h3>
<div class="check-grid">
{% macro check_item(label, value) %}
<div class="check-item {{ 'check-pass' if value == true else ('check-fail' if value == false else 'check-na') }}">
{% if value == true %}
<span style="color: var(--success);">&#10003;</span>
{% elif value == false %}
<span style="color: var(--error);">&#10007;</span>
{% else %}
<span style="color: var(--text-secondary);">?</span>
{% endif %}
{{ label }}
</div>
{% endmacro %}
{{ check_item('Meta Title', latest.has_meta_title) }}
{{ check_item('Meta Description', latest.has_meta_description) }}
{{ check_item('Canonical URL', latest.has_canonical) }}
{{ check_item('robots.txt', latest.has_robots_txt) }}
{{ check_item('sitemap.xml', latest.has_sitemap) }}
{{ check_item('Structured Data', latest.has_structured_data) }}
{{ check_item('Open Graph', latest.has_og_tags) }}
{{ check_item('SSL', latest.has_ssl) }}
{{ check_item('Mobile Friendly', latest.is_mobile_friendly) }}
{{ check_item('HSTS', latest.has_hsts) }}
{{ check_item('CSP', latest.has_csp) }}
{{ check_item('X-Frame-Options', latest.has_x_frame) }}
{{ check_item('X-Content-Type', latest.has_x_content_type) }}
</div>
<!-- Core Web Vitals -->
{% if latest.lcp_ms or latest.fcp_ms or latest.cls is not none %}
<h3 style="margin-bottom: var(--spacing-md);">Core Web Vitals</h3>
<div class="score-cards" style="margin-bottom: var(--spacing-xl);">
{% if latest.lcp_ms %}
<div class="score-card">
<div class="score-card-value {{ 'score-good' if latest.lcp_ms <= 2500 else ('score-ok' if latest.lcp_ms <= 4000 else 'score-bad') }}" style="font-size: 1.8rem;">
{{ '%.1f'|format(latest.lcp_ms / 1000) }}s
</div>
<div class="score-card-label">LCP</div>
<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>
{% endif %}
{% if latest.fcp_ms %}
<div class="score-card">
<div class="score-card-value {{ 'score-good' if latest.fcp_ms <= 1800 else ('score-ok' if latest.fcp_ms <= 3000 else 'score-bad') }}" style="font-size: 1.8rem;">
{{ '%.1f'|format(latest.fcp_ms / 1000) }}s
</div>
<div class="score-card-label">FCP</div>
</div>
{% endif %}
{% if latest.cls is not none %}
<div class="score-card">
<div class="score-card-value {{ 'score-good' if latest.cls <= 0.1 else ('score-ok' if latest.cls <= 0.25 else 'score-bad') }}" style="font-size: 1.8rem;">
{{ '%.3f'|format(latest.cls) }}
</div>
<div class="score-card-label">CLS</div>
</div>
{% endif %}
{% if latest.tbt_ms %}
<div class="score-card">
<div class="score-card-value {{ 'score-good' if latest.tbt_ms <= 200 else ('score-ok' if latest.tbt_ms <= 600 else 'score-bad') }}" style="font-size: 1.8rem;">
{{ latest.tbt_ms|int }}ms
</div>
<div class="score-card-label">TBT</div>
</div>
{% endif %}
</div>
{% endif %}
<!-- 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 %}
<!-- History table -->
<!-- ============================================================ -->
<!-- FULL HISTORY TABLE -->
<!-- ============================================================ -->
<h3 style="margin-bottom: var(--spacing-md);">Historia audytów</h3>
{% if audits %}
<div class="audit-history">
<table>
<div class="history-wrapper">
<table class="history-table">
<thead>
<tr>
<th>Data</th>
<th>Perf</th>
<th>SEO</th>
<th>A11y</th>
<th>BP</th>
<th>Notatka</th>
<th></th>
<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.%Y %H:%M') }}</td>
{% macro score_td(val) %}
<td>
{% if val is not none %}
<span class="score-badge {{ 'good' if val >= 90 else ('ok' if val >= 50 else 'bad') }}">{{ val }}</span>
{% else %}—{% endif %}
</td>
{% endmacro %}
{{ score_td(a.pagespeed_performance) }}
{{ score_td(a.pagespeed_seo) }}
{{ score_td(a.pagespeed_accessibility) }}
{{ score_td(a.pagespeed_best_practices) }}
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ a.notes or '' }}
</td>
<td>
<a href="{{ url_for('admin.admin_portal_seo_detail', audit_id=a.id) }}" style="color: var(--primary); font-size: var(--font-size-sm);">
Szczegóły
</a>
</td>
<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>
@ -375,11 +523,9 @@ function startAudit() {
btn.textContent = 'Trwa audyt...';
panel.classList.add('active');
// Reset all steps
var items = stepsList.querySelectorAll('li');
items.forEach(function(li) {
li.className = '';
li.querySelector('.step-icon').className = 'step-icon pending';
li.querySelector('.step-icon').innerHTML = '&#9675;';
});
bar.style.width = '0%';
@ -390,16 +536,12 @@ function startAudit() {
source.onmessage = function(e) {
var data = JSON.parse(e.data);
// Final events
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);
setTimeout(function() { window.location.reload(); }, 1500);
return;
}
@ -407,46 +549,36 @@ function startAudit() {
source.close();
btn.disabled = false;
btn.textContent = 'Uruchom audyt';
panel.querySelector('h3').textContent = 'Błąd audytu: ' + (data.message || 'Nieznany');
panel.querySelector('h3').textContent = 'Błąd: ' + (data.message || 'Nieznany');
return;
}
// Step progress
if (data.step) {
var pct = Math.round((data.step / data.total) * 100);
bar.style.width = pct + '%';
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');
// Update step text with detail message
var stepLabel = li.childNodes[li.childNodes.length - 1];
var label = li.childNodes[li.childNodes.length - 1];
if (data.status === 'running') {
li.className = 'active';
icon.className = 'step-icon';
icon.innerHTML = '<span class="spinner"></span>';
} else if (data.status === 'done') {
li.className = 'done';
icon.className = 'step-icon';
icon.innerHTML = '&#10003;';
stepLabel.textContent = ' ' + data.message;
label.textContent = ' ' + data.message;
} else if (data.status === 'error') {
li.className = 'error';
icon.className = 'step-icon';
icon.innerHTML = '&#10007;';
stepLabel.textContent = ' ' + data.message;
label.textContent = ' ' + data.message;
} else if (data.status === 'warning') {
li.className = 'warning';
icon.className = 'step-icon';
icon.innerHTML = '&#9888;';
stepLabel.textContent = ' ' + data.message;
label.textContent = ' ' + data.message;
} else if (data.status === 'skipped') {
li.className = '';
icon.className = 'step-icon';
icon.innerHTML = '&#8212;';
stepLabel.textContent = ' ' + data.message;
label.textContent = ' ' + data.message;
}
}
};