feat: add SSE progress bar for portal SEO audit
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
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
Audit now runs step-by-step with real-time progress via Server-Sent Events. Each of 9 steps (fetch, on-page, technical, PageSpeed, local SEO, citations, freshness, save) shows status with spinner, checkmark, or error icon. Removed old POST form in favor of SSE-based streaming approach. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0c878e38b9
commit
4d6150fbde
@ -8,10 +8,17 @@ Tracks results over time for before/after comparison.
|
||||
|
||||
import logging
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import time as time_module
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
|
||||
from flask import abort, render_template, request, redirect, url_for, flash, jsonify
|
||||
import requests
|
||||
from flask import (
|
||||
abort, render_template, request, redirect, url_for,
|
||||
flash, Response, stream_with_context
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from . import bp
|
||||
@ -22,6 +29,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
PORTAL_URL = 'https://nordabiznes.pl'
|
||||
|
||||
# Path to scripts/ for SEOAuditor components
|
||||
_SCRIPTS_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'scripts'
|
||||
)
|
||||
|
||||
|
||||
def _make_json_safe(obj):
|
||||
"""Recursively convert datetime/Decimal objects to JSON-serializable types."""
|
||||
@ -36,6 +48,19 @@ def _make_json_safe(obj):
|
||||
return obj
|
||||
|
||||
|
||||
def _sse_event(data):
|
||||
"""Format SSE event."""
|
||||
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def _get_auditor():
|
||||
"""Import and return SEOAuditor instance."""
|
||||
if _SCRIPTS_DIR not in sys.path:
|
||||
sys.path.insert(0, _SCRIPTS_DIR)
|
||||
from seo_audit import SEOAuditor
|
||||
return SEOAuditor()
|
||||
|
||||
|
||||
@bp.route('/portal-seo')
|
||||
@login_required
|
||||
def admin_portal_seo():
|
||||
@ -57,91 +82,379 @@ def admin_portal_seo():
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/portal-seo/run', methods=['POST'])
|
||||
@bp.route('/portal-seo/run/stream')
|
||||
@login_required
|
||||
def admin_portal_seo_run():
|
||||
"""Run a new SEO audit on nordabiznes.pl using the existing SEOAuditor"""
|
||||
def admin_portal_seo_run_stream():
|
||||
"""SSE endpoint for streaming portal SEO audit progress step by step."""
|
||||
if not is_audit_owner():
|
||||
abort(404)
|
||||
import sys
|
||||
import os
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Use the same SEOAuditor that audits company websites
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'scripts'))
|
||||
from seo_audit import SEOAuditor
|
||||
notes = request.args.get('notes', '')
|
||||
user_email = current_user.email
|
||||
|
||||
auditor = SEOAuditor()
|
||||
result = auditor.audit_company({
|
||||
'id': 0,
|
||||
'name': 'NordaBiznes.pl',
|
||||
'slug': 'nordabiznes-pl',
|
||||
'website': PORTAL_URL,
|
||||
'address_city': 'Wejherowo'
|
||||
})
|
||||
STEPS = [
|
||||
(1, 'Inicjalizacja audytora SEO...'),
|
||||
(2, 'Pobieranie strony nordabiznes.pl...'),
|
||||
(3, 'Analiza on-page SEO...'),
|
||||
(4, 'Sprawdzanie techniczne (robots.txt, sitemap, SSL)...'),
|
||||
(5, 'PageSpeed Insights API...'),
|
||||
(6, 'Analiza Local SEO...'),
|
||||
(7, 'Sprawdzanie cytowań (citations)...'),
|
||||
(8, 'Sprawdzanie aktualności treści...'),
|
||||
(9, 'Zapisywanie wyników...'),
|
||||
]
|
||||
TOTAL = len(STEPS)
|
||||
|
||||
# Extract scores from audit result
|
||||
ps = result.get('pagespeed', {})
|
||||
ps_scores = ps.get('scores', {})
|
||||
cwv = ps.get('core_web_vitals', {})
|
||||
tech = result.get('technical', {})
|
||||
on_page = result.get('on_page', {})
|
||||
def generate():
|
||||
try:
|
||||
# Step 1: Init auditor
|
||||
yield _sse_event({
|
||||
'step': 1, 'total': TOTAL,
|
||||
'message': STEPS[0][1], 'status': 'running'
|
||||
})
|
||||
|
||||
sec = result.get('security_headers', {})
|
||||
auditor = _get_auditor()
|
||||
|
||||
audit = PortalSEOAudit(
|
||||
audited_at=datetime.now(),
|
||||
url=PORTAL_URL,
|
||||
# PageSpeed scores
|
||||
pagespeed_performance=ps_scores.get('performance'),
|
||||
pagespeed_seo=ps_scores.get('seo'),
|
||||
pagespeed_accessibility=ps_scores.get('accessibility'),
|
||||
pagespeed_best_practices=ps_scores.get('best_practices'),
|
||||
# Core Web Vitals
|
||||
lcp_ms=cwv.get('lcp_ms'),
|
||||
fcp_ms=cwv.get('fcp_ms'),
|
||||
cls=cwv.get('cls'),
|
||||
tbt_ms=cwv.get('tbt_ms'),
|
||||
speed_index_ms=cwv.get('speed_index_ms'),
|
||||
# On-page checks
|
||||
has_meta_title=bool(on_page.get('meta_title')),
|
||||
has_meta_description=bool(on_page.get('meta_description')),
|
||||
has_canonical=tech.get('has_canonical'),
|
||||
has_robots_txt=tech.get('has_robots_txt'),
|
||||
has_sitemap=tech.get('has_sitemap'),
|
||||
has_structured_data=on_page.get('has_structured_data'),
|
||||
has_og_tags=on_page.get('has_og_tags'),
|
||||
has_ssl=tech.get('has_ssl'),
|
||||
is_mobile_friendly=tech.get('is_mobile_friendly'),
|
||||
# Security headers
|
||||
has_hsts=sec.get('has_hsts'),
|
||||
has_csp=sec.get('has_csp'),
|
||||
has_x_frame=sec.get('has_x_frame_options'),
|
||||
has_x_content_type=sec.get('has_x_content_type'),
|
||||
# Content metrics
|
||||
page_size_bytes=on_page.get('page_size_bytes'),
|
||||
image_count=on_page.get('total_images'),
|
||||
images_without_alt=on_page.get('images_without_alt'),
|
||||
# Full data (sanitize for JSONB - datetime/Decimal not serializable)
|
||||
full_results=_make_json_safe(result),
|
||||
notes=request.form.get('notes', ''),
|
||||
created_by=current_user.email
|
||||
)
|
||||
yield _sse_event({
|
||||
'step': 1, 'total': TOTAL,
|
||||
'message': 'Audytor SEO zainicjalizowany', 'status': 'done'
|
||||
})
|
||||
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
company = {
|
||||
'id': 0,
|
||||
'name': 'NordaBiznes.pl',
|
||||
'slug': 'nordabiznes-pl',
|
||||
'website': PORTAL_URL,
|
||||
'address_city': 'Wejherowo'
|
||||
}
|
||||
result = {
|
||||
'company_id': 0,
|
||||
'company_name': 'NordaBiznes.pl',
|
||||
'audit_date': datetime.now(),
|
||||
'website_url': PORTAL_URL,
|
||||
'errors': [],
|
||||
}
|
||||
html_content = None
|
||||
final_url = PORTAL_URL
|
||||
|
||||
flash(f'Audyt SEO portalu zakończony. Performance: {audit.pagespeed_performance or "N/A"}', 'success')
|
||||
return redirect(url_for('admin.admin_portal_seo'))
|
||||
# Step 2: Fetch page
|
||||
yield _sse_event({
|
||||
'step': 2, 'total': TOTAL,
|
||||
'message': STEPS[1][1], 'status': 'running'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f'Portal SEO audit failed: {e}', exc_info=True)
|
||||
flash(f'Błąd audytu: {str(e)}', 'error')
|
||||
return redirect(url_for('admin.admin_portal_seo'))
|
||||
finally:
|
||||
db.close()
|
||||
try:
|
||||
start = time_module.time()
|
||||
resp = auditor.session.get(
|
||||
PORTAL_URL, timeout=15, allow_redirects=True
|
||||
)
|
||||
load_ms = int((time_module.time() - start) * 1000)
|
||||
final_url = resp.url
|
||||
|
||||
if resp.status_code == 200:
|
||||
if resp.encoding and resp.encoding.lower() == 'iso-8859-1':
|
||||
resp.encoding = resp.apparent_encoding
|
||||
html_content = resp.text
|
||||
result['http_status'] = 200
|
||||
result['load_time_ms'] = load_ms
|
||||
result['final_url'] = final_url
|
||||
yield _sse_event({
|
||||
'step': 2, 'total': TOTAL,
|
||||
'message': f'Strona pobrana ({load_ms}ms)',
|
||||
'status': 'done'
|
||||
})
|
||||
else:
|
||||
result['errors'].append(f'HTTP {resp.status_code}')
|
||||
yield _sse_event({
|
||||
'step': 2, 'total': TOTAL,
|
||||
'message': f'HTTP {resp.status_code}',
|
||||
'status': 'warning'
|
||||
})
|
||||
except Exception as e:
|
||||
result['errors'].append(str(e)[:100])
|
||||
yield _sse_event({
|
||||
'step': 2, 'total': TOTAL,
|
||||
'message': f'Błąd pobierania: {str(e)[:80]}',
|
||||
'status': 'error'
|
||||
})
|
||||
|
||||
# Step 3: On-page analysis
|
||||
yield _sse_event({
|
||||
'step': 3, 'total': TOTAL,
|
||||
'message': STEPS[2][1], 'status': 'running'
|
||||
})
|
||||
|
||||
if html_content:
|
||||
try:
|
||||
onpage = auditor.onpage_analyzer.analyze_html(
|
||||
html_content, base_url=final_url
|
||||
)
|
||||
result['onpage'] = onpage.to_dict()
|
||||
yield _sse_event({
|
||||
'step': 3, 'total': TOTAL,
|
||||
'message': f'On-page: title="{onpage.meta_title[:40]}..."' if onpage.meta_title else 'On-page: brak meta title',
|
||||
'status': 'done'
|
||||
})
|
||||
except Exception as e:
|
||||
result['errors'].append(f'On-page: {str(e)[:100]}')
|
||||
yield _sse_event({
|
||||
'step': 3, 'total': TOTAL,
|
||||
'message': f'On-page błąd: {str(e)[:80]}',
|
||||
'status': 'error'
|
||||
})
|
||||
else:
|
||||
yield _sse_event({
|
||||
'step': 3, 'total': TOTAL,
|
||||
'message': 'Pominięto (brak HTML)',
|
||||
'status': 'skipped'
|
||||
})
|
||||
|
||||
# Step 4: Technical SEO
|
||||
yield _sse_event({
|
||||
'step': 4, 'total': TOTAL,
|
||||
'message': STEPS[3][1], 'status': 'running'
|
||||
})
|
||||
|
||||
try:
|
||||
tech_result = auditor.technical_checker.check_url(final_url)
|
||||
result['technical'] = tech_result.to_dict()
|
||||
checks = []
|
||||
td = tech_result.to_dict()
|
||||
if td.get('has_robots_txt'):
|
||||
checks.append('robots.txt')
|
||||
if td.get('has_sitemap'):
|
||||
checks.append('sitemap')
|
||||
if td.get('has_ssl'):
|
||||
checks.append('SSL')
|
||||
yield _sse_event({
|
||||
'step': 4, 'total': TOTAL,
|
||||
'message': f'Technical: {", ".join(checks) if checks else "brak kluczowych elementów"}',
|
||||
'status': 'done'
|
||||
})
|
||||
except Exception as e:
|
||||
result['errors'].append(f'Technical: {str(e)[:100]}')
|
||||
yield _sse_event({
|
||||
'step': 4, 'total': TOTAL,
|
||||
'message': f'Technical błąd: {str(e)[:80]}',
|
||||
'status': 'error'
|
||||
})
|
||||
|
||||
# Step 5: PageSpeed Insights
|
||||
yield _sse_event({
|
||||
'step': 5, 'total': TOTAL,
|
||||
'message': STEPS[4][1], 'status': 'running'
|
||||
})
|
||||
|
||||
try:
|
||||
remaining = auditor.pagespeed_client.get_remaining_quota()
|
||||
if remaining > 0:
|
||||
from pagespeed_client import Strategy
|
||||
ps_result = auditor.pagespeed_client.analyze_url(
|
||||
final_url, strategy=Strategy.MOBILE
|
||||
)
|
||||
result['pagespeed'] = ps_result.to_dict()
|
||||
result['scores'] = {
|
||||
'pagespeed_seo': ps_result.scores.seo,
|
||||
'pagespeed_performance': ps_result.scores.performance,
|
||||
'pagespeed_accessibility': ps_result.scores.accessibility,
|
||||
'pagespeed_best_practices': ps_result.scores.best_practices,
|
||||
}
|
||||
yield _sse_event({
|
||||
'step': 5, 'total': TOTAL,
|
||||
'message': f'PageSpeed: Perf={ps_result.scores.performance}, SEO={ps_result.scores.seo}',
|
||||
'status': 'done'
|
||||
})
|
||||
else:
|
||||
result['errors'].append('PageSpeed API quota exceeded')
|
||||
yield _sse_event({
|
||||
'step': 5, 'total': TOTAL,
|
||||
'message': 'Limit API wyczerpany — pominięto',
|
||||
'status': 'warning'
|
||||
})
|
||||
except Exception as e:
|
||||
result['errors'].append(f'PageSpeed: {str(e)[:100]}')
|
||||
yield _sse_event({
|
||||
'step': 5, 'total': TOTAL,
|
||||
'message': f'PageSpeed błąd: {str(e)[:80]}',
|
||||
'status': 'error'
|
||||
})
|
||||
|
||||
# Step 6: Local SEO
|
||||
yield _sse_event({
|
||||
'step': 6, 'total': TOTAL,
|
||||
'message': STEPS[5][1], 'status': 'running'
|
||||
})
|
||||
|
||||
if html_content:
|
||||
try:
|
||||
local_seo = auditor.local_seo_analyzer.analyze(
|
||||
html_content, final_url, company
|
||||
)
|
||||
result['local_seo'] = local_seo
|
||||
score = local_seo.get('local_seo_score', 0)
|
||||
yield _sse_event({
|
||||
'step': 6, 'total': TOTAL,
|
||||
'message': f'Local SEO score: {score}',
|
||||
'status': 'done'
|
||||
})
|
||||
except Exception as e:
|
||||
result['errors'].append(f'Local SEO: {str(e)[:100]}')
|
||||
yield _sse_event({
|
||||
'step': 6, 'total': TOTAL,
|
||||
'message': f'Local SEO błąd: {str(e)[:80]}',
|
||||
'status': 'error'
|
||||
})
|
||||
else:
|
||||
yield _sse_event({
|
||||
'step': 6, 'total': TOTAL,
|
||||
'message': 'Pominięto (brak HTML)',
|
||||
'status': 'skipped'
|
||||
})
|
||||
|
||||
# Step 7: Citations
|
||||
yield _sse_event({
|
||||
'step': 7, 'total': TOTAL,
|
||||
'message': STEPS[6][1], 'status': 'running'
|
||||
})
|
||||
|
||||
try:
|
||||
citations = auditor.citation_checker.check_citations(
|
||||
'NordaBiznes.pl', 'Wejherowo'
|
||||
)
|
||||
result['citations'] = citations
|
||||
found = sum(1 for c in citations if c.get('status') == 'found')
|
||||
yield _sse_event({
|
||||
'step': 7, 'total': TOTAL,
|
||||
'message': f'Cytowania: {found}/{len(citations)} znalezione',
|
||||
'status': 'done'
|
||||
})
|
||||
except Exception as e:
|
||||
result['errors'].append(f'Citations: {str(e)[:100]}')
|
||||
yield _sse_event({
|
||||
'step': 7, 'total': TOTAL,
|
||||
'message': f'Citations błąd: {str(e)[:80]}',
|
||||
'status': 'error'
|
||||
})
|
||||
|
||||
# Step 8: Content freshness
|
||||
yield _sse_event({
|
||||
'step': 8, 'total': TOTAL,
|
||||
'message': STEPS[7][1], 'status': 'running'
|
||||
})
|
||||
|
||||
try:
|
||||
freshness = auditor.freshness_checker.check_freshness(
|
||||
final_url, html_content
|
||||
)
|
||||
result['freshness'] = freshness
|
||||
fscore = freshness.get('content_freshness_score', 0)
|
||||
yield _sse_event({
|
||||
'step': 8, 'total': TOTAL,
|
||||
'message': f'Aktualność treści: {fscore}',
|
||||
'status': 'done'
|
||||
})
|
||||
except Exception as e:
|
||||
result['errors'].append(f'Freshness: {str(e)[:100]}')
|
||||
yield _sse_event({
|
||||
'step': 8, 'total': TOTAL,
|
||||
'message': f'Freshness błąd: {str(e)[:80]}',
|
||||
'status': 'error'
|
||||
})
|
||||
|
||||
# Step 9: Save to DB
|
||||
yield _sse_event({
|
||||
'step': 9, 'total': TOTAL,
|
||||
'message': STEPS[8][1], 'status': 'running'
|
||||
})
|
||||
|
||||
# Extract data for DB columns
|
||||
ps = result.get('pagespeed', {})
|
||||
ps_scores = result.get('scores', {})
|
||||
cwv = ps.get('core_web_vitals', {})
|
||||
tech = result.get('technical', {})
|
||||
onpage = result.get('onpage', {})
|
||||
sec = tech.get('security_headers', {})
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
audit = PortalSEOAudit(
|
||||
audited_at=datetime.now(),
|
||||
url=PORTAL_URL,
|
||||
pagespeed_performance=ps_scores.get('pagespeed_performance'),
|
||||
pagespeed_seo=ps_scores.get('pagespeed_seo'),
|
||||
pagespeed_accessibility=ps_scores.get('pagespeed_accessibility'),
|
||||
pagespeed_best_practices=ps_scores.get('pagespeed_best_practices'),
|
||||
lcp_ms=cwv.get('lcp_ms'),
|
||||
fcp_ms=cwv.get('fcp_ms'),
|
||||
cls=cwv.get('cls'),
|
||||
tbt_ms=cwv.get('tbt_ms'),
|
||||
speed_index_ms=cwv.get('speed_index_ms'),
|
||||
has_meta_title=bool(onpage.get('meta_title')),
|
||||
has_meta_description=bool(onpage.get('meta_description')),
|
||||
has_canonical=tech.get('has_canonical'),
|
||||
has_robots_txt=tech.get('has_robots_txt'),
|
||||
has_sitemap=tech.get('has_sitemap'),
|
||||
has_structured_data=onpage.get('has_structured_data'),
|
||||
has_og_tags=onpage.get('has_og_tags'),
|
||||
has_ssl=tech.get('has_ssl'),
|
||||
is_mobile_friendly=tech.get('is_mobile_friendly'),
|
||||
has_hsts=sec.get('has_hsts'),
|
||||
has_csp=sec.get('has_csp'),
|
||||
has_x_frame=sec.get('has_x_frame_options'),
|
||||
has_x_content_type=sec.get('has_x_content_type'),
|
||||
page_size_bytes=onpage.get('page_size_bytes'),
|
||||
image_count=onpage.get('total_images'),
|
||||
images_without_alt=onpage.get('images_without_alt'),
|
||||
full_results=_make_json_safe(result),
|
||||
notes=notes,
|
||||
created_by=user_email
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
audit_id = audit.id
|
||||
|
||||
yield _sse_event({
|
||||
'step': 9, 'total': TOTAL,
|
||||
'message': f'Zapisano audyt #{audit_id}',
|
||||
'status': 'done'
|
||||
})
|
||||
|
||||
# Send complete event
|
||||
yield _sse_event({
|
||||
'status': 'complete',
|
||||
'audit_id': audit_id,
|
||||
'performance': ps_scores.get('pagespeed_performance'),
|
||||
'seo': ps_scores.get('pagespeed_seo'),
|
||||
'errors': result.get('errors', []),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f'Portal SEO save failed: {e}', exc_info=True)
|
||||
yield _sse_event({
|
||||
'step': 9, 'total': TOTAL,
|
||||
'message': f'Błąd zapisu: {str(e)[:80]}',
|
||||
'status': 'error'
|
||||
})
|
||||
yield _sse_event({'status': 'error', 'message': str(e)[:200]})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Portal SEO audit stream failed: {e}', exc_info=True)
|
||||
yield _sse_event({
|
||||
'status': 'error',
|
||||
'message': f'Krytyczny błąd: {str(e)[:200]}'
|
||||
})
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/portal-seo/<int:audit_id>')
|
||||
|
||||
@ -111,6 +111,68 @@
|
||||
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: 6px 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;
|
||||
}
|
||||
.step-icon.pending { color: var(--border); }
|
||||
@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 %}
|
||||
|
||||
@ -120,13 +182,31 @@
|
||||
<h1 style="margin-bottom: var(--spacing-xs);">SEO Portalu</h1>
|
||||
<p style="color: var(--text-secondary);">Audyt {{ portal_url }} - historia zmian w czasie</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('admin.admin_portal_seo_run') }}" class="run-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="text" name="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="submit" class="btn btn-primary" style="white-space: nowrap;">
|
||||
<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>
|
||||
</form>
|
||||
</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 pending">○</span> Inicjalizacja audytora SEO</li>
|
||||
<li data-step="2"><span class="step-icon pending">○</span> Pobieranie strony</li>
|
||||
<li data-step="3"><span class="step-icon pending">○</span> Analiza on-page SEO</li>
|
||||
<li data-step="4"><span class="step-icon pending">○</span> Sprawdzanie techniczne</li>
|
||||
<li data-step="5"><span class="step-icon pending">○</span> PageSpeed Insights API</li>
|
||||
<li data-step="6"><span class="step-icon pending">○</span> Analiza Local SEO</li>
|
||||
<li data-step="7"><span class="step-icon pending">○</span> Sprawdzanie cytowań</li>
|
||||
<li data-step="8"><span class="step-icon pending">○</span> Sprawdzanie aktualności treści</li>
|
||||
<li data-step="9"><span class="step-icon pending">○</span> Zapisywanie wyników</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if audits %}
|
||||
@ -282,3 +362,101 @@
|
||||
{% 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');
|
||||
|
||||
// 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 = '○';
|
||||
});
|
||||
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);
|
||||
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'error' && !data.step) {
|
||||
source.close();
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Uruchom audyt';
|
||||
panel.querySelector('h3').textContent = 'Błąd audytu: ' + (data.message || 'Nieznany');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step progress
|
||||
if (data.step) {
|
||||
var pct = Math.round((data.step / data.total) * 100);
|
||||
bar.style.width = pct + '%';
|
||||
|
||||
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];
|
||||
|
||||
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 = '✓';
|
||||
stepLabel.textContent = ' ' + data.message;
|
||||
} else if (data.status === 'error') {
|
||||
li.className = 'error';
|
||||
icon.className = 'step-icon';
|
||||
icon.innerHTML = '✗';
|
||||
stepLabel.textContent = ' ' + data.message;
|
||||
} else if (data.status === 'warning') {
|
||||
li.className = 'warning';
|
||||
icon.className = 'step-icon';
|
||||
icon.innerHTML = '⚠';
|
||||
stepLabel.textContent = ' ' + data.message;
|
||||
} else if (data.status === 'skipped') {
|
||||
li.className = '';
|
||||
icon.className = 'step-icon';
|
||||
icon.innerHTML = '—';
|
||||
stepLabel.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 %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user