From a051f9350f013e9d5881a5418d3aba814bfe3c82 Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 19:33:18 +0100 Subject: [PATCH] feat(pseo): create pSEO Engine admin templates + sidebar nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - base_admin.html: add pSEO section with "pSEO Engine" link - pseo_dashboard.html: template stats, freshness badges, HTMX gaps panels, recent jobs table, health issues HTMX-loaded section - pseo_health.html: HTMX partial — hreflang orphans, missing build files, broken scenario refs (collapsible details with drill-down tables) - pseo_gaps.html: HTMX partial — missing rows per template with generate button - pseo_jobs.html: full jobs list with live progress bars (HTMX polling) - pseo_job_status.html: HTMX partial — polls every 2s while job is pending - app.py: add `fromjson` Jinja2 filter for displaying task payloads Co-Authored-By: Claude Sonnet 4.6 --- .../admin/templates/admin/base_admin.html | 6 + .../admin/templates/admin/pseo_dashboard.html | 195 ++++++++++++++++++ .../admin/templates/admin/pseo_gaps.html | 43 ++++ .../admin/templates/admin/pseo_health.html | 99 +++++++++ .../templates/admin/pseo_job_status.html | 45 ++++ .../admin/templates/admin/pseo_jobs.html | 95 +++++++++ web/src/padelnomics/app.py | 2 + 7 files changed, 485 insertions(+) create mode 100644 web/src/padelnomics/admin/templates/admin/pseo_dashboard.html create mode 100644 web/src/padelnomics/admin/templates/admin/pseo_gaps.html create mode 100644 web/src/padelnomics/admin/templates/admin/pseo_health.html create mode 100644 web/src/padelnomics/admin/templates/admin/pseo_job_status.html create mode 100644 web/src/padelnomics/admin/templates/admin/pseo_jobs.html diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index 3ca1820..687335e 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -95,6 +95,12 @@ Templates +
pSEO
+ + + pSEO Engine + +
Email
diff --git a/web/src/padelnomics/admin/templates/admin/pseo_dashboard.html b/web/src/padelnomics/admin/templates/admin/pseo_dashboard.html new file mode 100644 index 0000000..212883b --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/pseo_dashboard.html @@ -0,0 +1,195 @@ +{% extends "admin/base_admin.html" %} +{% set admin_page = "pseo" %} + +{% block title %}pSEO Engine - {{ config.APP_NAME }}{% endblock %} + +{% block admin_head %} + +{% endblock %} + +{% block admin_content %} +
+
+

pSEO Engine

+

Operational dashboard for programmatic SEO

+
+
All Jobs +
+ + +
+
+

Total Articles

+

{{ total_articles }}

+

{{ total_published }} published

+
+
+

Templates

+

{{ total_templates }}

+
+
+

Stale Templates

+

+ {{ stale_count }} +

+

data newer than articles

+
+
+

Health Checks

+

+

see Health section below

+
+
+ + +
+
+ Templates + Click "Gaps" to load missing articles per template +
+
+ + + + + + + + + + + + + {% for r in template_rows %} + {% set t = r.template %} + {% set stats = r.stats %} + {% set fr = r.freshness %} + + + + + + + + + + + + {% endfor %} + +
TemplateData rowsArticles ENArticles DEFreshnessActions
+ {{ t.name }}
+ {{ t.slug }} +
{{ fr.row_count if fr.row_count is not none else '—' }}{{ stats.by_language.get('en', {}).get('total', 0) }}{{ stats.by_language.get('de', {}).get('total', 0) }} + {% set status = fr.status | default('no_data') %} + + {% if status == 'fresh' %}🟢 Fresh + {% elif status == 'stale' %}🟡 Stale + {% elif status == 'no_articles' %}🟣 No articles + {% else %}⚪ No data + {% endif %} + + + +
+ + +
+
+
+ +
+
+
+
+ + +{% if jobs %} +
+
+ Recent Generation Jobs + View all → +
+
+ + + + + + + + + + + {% for job in jobs %} + + + + + + + {% endfor %} + +
JobStatusProgressStarted
+ #{{ job.id }} + {% if job.payload %} + — {{ (job.payload | fromjson).get('template_slug', '') }} + {% endif %} + + {% if job.status == 'complete' %} + Complete + {% elif job.status == 'failed' %} + Failed + {% elif job.status == 'pending' %} + Running + {% else %} + {{ job.status }} + {% endif %} + + {% if job.progress_total and job.progress_total > 0 %} +
+
+
+
+ {{ job.progress_current }}/{{ job.progress_total }} +
+ {% else %} + — + {% endif %} +
{{ job.created_at | default('') | truncate(16, True, '') }}
+
+
+{% endif %} + + +
+
+

Loading health checks…

+
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/pseo_gaps.html b/web/src/padelnomics/admin/templates/admin/pseo_gaps.html new file mode 100644 index 0000000..779ff87 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/pseo_gaps.html @@ -0,0 +1,43 @@ +{# HTMX partial — rendered inside the gaps panel for one template. + Loaded via GET /admin/pseo/gaps/. #} + +{% if not gaps %} +

✓ No gaps — all {{ template.name }} rows have articles.

+{% else %} +
+ {{ gaps | length }} missing row{{ 's' if gaps | length != 1 else '' }} +
+ + +
+
+
+ + + + + + {% for key in (gaps[0].keys() | list | reject('equalto', '_natural_key') | reject('equalto', '_missing_languages') | list)[:4] %} + + {% endfor %} + + + + {% for gap in gaps[:100] %} + + + + {% for key in (gap.keys() | list | reject('equalto', '_natural_key') | reject('equalto', '_missing_languages') | list)[:4] %} + + {% endfor %} + + {% endfor %} + {% if gaps | length > 100 %} + + + + {% endif %} + +
{{ template.natural_key }}Missing languages{{ key }}
{{ gap._natural_key }}{{ gap._missing_languages | join(', ') }}{{ gap[key] | truncate(30) if gap[key] is string else gap[key] }}
… and {{ gaps | length - 100 }} more rows
+
+{% endif %} diff --git a/web/src/padelnomics/admin/templates/admin/pseo_health.html b/web/src/padelnomics/admin/templates/admin/pseo_health.html new file mode 100644 index 0000000..2d2335b --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/pseo_health.html @@ -0,0 +1,99 @@ +{# HTMX partial — loaded by pseo_dashboard.html and /admin/pseo/health directly. + When loaded via HTMX (hx-swap="outerHTML"), renders a full card. + When loaded standalone (full page), also works since it just outputs HTML. #} + +
+
+ Health Checks + {{ health.counts.total }} issue{{ 's' if health.counts.total != 1 else '' }} +
+ + {% if health.counts.total == 0 %} +

✓ No issues found — all articles are healthy.

+ {% else %} + + + {% if health.hreflang_orphans %} +
+ + ⚠ Hreflang orphans ({{ health.counts.hreflang_orphans }}) + — articles missing a sibling language + +
+ + + + {% for o in health.hreflang_orphans[:50] %} + + + + + + + {% endfor %} + {% if health.hreflang_orphans | length > 50 %} + + {% endif %} + +
TemplateURL pathPresentMissing
{{ o.template_slug }}{{ o.url_path }}{{ o.present_languages | join(', ') }}{{ o.missing_languages | join(', ') }}
… and {{ health.hreflang_orphans | length - 50 }} more
+
+
+ {% endif %} + + + {% if health.missing_build_files %} +
+ + ❌ Missing build files ({{ health.counts.missing_build_files }}) + — published articles with no HTML on disk + +
+ + + + {% for m in health.missing_build_files[:50] %} + + + + + + + {% endfor %} + {% if health.missing_build_files | length > 50 %} + + {% endif %} + +
SlugLanguageURL pathExpected path
{{ m.slug }}{{ m.language }}{{ m.url_path }}{{ m.expected_path }}
… and {{ health.missing_build_files | length - 50 }} more
+
+
+ {% endif %} + + + {% if health.broken_scenario_refs %} +
+ + ❌ Broken scenario refs ({{ health.counts.broken_scenario_refs }}) + — [scenario:slug] markers referencing deleted scenarios + +
+ + + + {% for b in health.broken_scenario_refs[:50] %} + + + + + + {% endfor %} + {% if health.broken_scenario_refs | length > 50 %} + + {% endif %} + +
SlugLanguageBroken refs
{{ b.slug }}{{ b.language }}{{ b.broken_scenario_refs | join(', ') }}
… and {{ health.broken_scenario_refs | length - 50 }} more
+
+
+ {% endif %} + + {% endif %} +
diff --git a/web/src/padelnomics/admin/templates/admin/pseo_job_status.html b/web/src/padelnomics/admin/templates/admin/pseo_job_status.html new file mode 100644 index 0000000..e039860 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/pseo_job_status.html @@ -0,0 +1,45 @@ +{# HTMX partial — replaces the entire for a job row while it's running. + Stops polling once the job is complete or failed (hx-trigger="every 2s" only applies + while this partial keeps returning a polling trigger). #} + +{% set pct = [((job.progress_current / job.progress_total) * 100) | int, 100] | min if job.progress_total else 0 %} + + + #{{ job.id }} + —{# payload not re-fetched in status endpoint — static display #} + + {% if job.status == 'complete' %} + Complete + {% elif job.status == 'failed' %} + Failed + {% else %} + Running… + {% endif %} + + + {% if job.progress_total and job.progress_total > 0 %} +
+
+
+
+ {{ job.progress_current }}/{{ job.progress_total }} +
+ {% else %}—{% endif %} + + {{ job.created_at | default('') | truncate(19, True, '') }} + {{ job.completed_at | default('') | truncate(19, True, '') }} + + {% if job.error %} +
+ Error +
{{ job.error[:500] }}
+
+ {% else %}—{% endif %} + + diff --git a/web/src/padelnomics/admin/templates/admin/pseo_jobs.html b/web/src/padelnomics/admin/templates/admin/pseo_jobs.html new file mode 100644 index 0000000..2cb12d3 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/pseo_jobs.html @@ -0,0 +1,95 @@ +{% extends "admin/base_admin.html" %} +{% set admin_page = "pseo" %} + +{% block title %}pSEO Jobs - {{ config.APP_NAME }}{% endblock %} + +{% block admin_head %} + +{% endblock %} + +{% block admin_content %} +
+
+

Generation Jobs

+

Recent article generation runs

+
+ ← pSEO Engine +
+ +{% if not jobs %} +
+

No generation jobs found. Use the pSEO Engine dashboard to generate articles.

+
+{% else %} +
+
+ + + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + + {% endfor %} + +
#TemplateStatusProgressStartedCompletedError
#{{ job.id }} + {% if job.payload %} + {% set payload = job.payload | fromjson %} + {{ payload.get('template_slug', '—') }} + {% else %}—{% endif %} + + {% if job.status == 'complete' %} + Complete + {% elif job.status == 'failed' %} + Failed + {% elif job.status == 'pending' %} + {# Poll live status for running jobs #} +
+ Running… +
+ {% else %} + {{ job.status }} + {% endif %} +
+ {% if job.progress_total and job.progress_total > 0 %} +
+
+
+
+ {{ job.progress_current }}/{{ job.progress_total }} +
+ {% else %}—{% endif %} +
{{ job.created_at | default('') | truncate(19, True, '') }}{{ job.completed_at | default('') | truncate(19, True, '') }} + {% if job.error %} +
+ Error +
{{ job.error[:500] }}
+
+ {% else %}—{% endif %} +
+
+
+{% endif %} +{% endblock %} diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index 3413d81..5b29b64 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -1,6 +1,7 @@ """ Padelnomics - Application factory and entry point. """ +import json import time from pathlib import Path @@ -105,6 +106,7 @@ def create_app() -> Quart: app.jinja_env.filters["fmt_n"] = _fmt_n app.jinja_env.filters["tformat"] = _tformat # translate with placeholders: {{ t.key | tformat(count=n) }} app.jinja_env.filters["country_name"] = get_country_name # {{ article.country | country_name(lang) }} + app.jinja_env.filters["fromjson"] = json.loads # {{ job.payload | fromjson }} # Session config app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG