feat(pseo): create pSEO Engine admin templates + sidebar nav
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,12 @@
|
|||||||
Templates
|
Templates
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<div class="admin-sidebar__section">pSEO</div>
|
||||||
|
<a href="{{ url_for('pseo.pseo_dashboard') }}" class="{% if admin_page == 'pseo' %}active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"/></svg>
|
||||||
|
pSEO Engine
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="admin-sidebar__section">Email</div>
|
<div class="admin-sidebar__section">Email</div>
|
||||||
<a href="{{ url_for('admin.emails') }}" class="{% if admin_page == 'emails' %}active{% endif %}">
|
<a href="{{ url_for('admin.emails') }}" class="{% if admin_page == 'emails' %}active{% endif %}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||||
|
|||||||
195
web/src/padelnomics/admin/templates/admin/pseo_dashboard.html
Normal file
195
web/src/padelnomics/admin/templates/admin/pseo_dashboard.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends "admin/base_admin.html" %}
|
||||||
|
{% set admin_page = "pseo" %}
|
||||||
|
|
||||||
|
{% block title %}pSEO Engine - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.pseo-status-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-size: 0.6875rem; font-weight: 600; padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
.pseo-status-fresh { background: #D1FAE5; color: #065F46; }
|
||||||
|
.pseo-status-stale { background: #FEF3C7; color: #92400E; }
|
||||||
|
.pseo-status-no_data { background: #F1F5F9; color: #64748B; }
|
||||||
|
.pseo-status-no_articles { background: #EDE9FE; color: #5B21B6; }
|
||||||
|
|
||||||
|
.pseo-gaps-panel { border-top: 1px solid #E2E8F0; margin-top: 8px; padding-top: 8px; }
|
||||||
|
|
||||||
|
.progress-bar-wrap { height: 6px; background: #E2E8F0; border-radius: 9999px; overflow: hidden; min-width: 80px; }
|
||||||
|
.progress-bar-fill { height: 100%; background: #1D4ED8; border-radius: 9999px; transition: width 0.3s; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<header class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl">pSEO Engine</h1>
|
||||||
|
<p class="text-slate text-sm mt-1">Operational dashboard for programmatic SEO</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('pseo.pseo_jobs') }}" class="btn-outline btn-sm">All Jobs</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="grid-4 mb-8">
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">Total Articles</p>
|
||||||
|
<p class="text-3xl font-bold text-navy">{{ total_articles }}</p>
|
||||||
|
<p class="text-xs text-slate mt-1">{{ total_published }} published</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">Templates</p>
|
||||||
|
<p class="text-3xl font-bold text-navy">{{ total_templates }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">Stale Templates</p>
|
||||||
|
<p class="text-3xl font-bold {% if stale_count > 0 %}text-amber-600{% else %}text-navy{% endif %}">
|
||||||
|
{{ stale_count }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate mt-1">data newer than articles</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">Health Checks</p>
|
||||||
|
<p class="text-3xl font-bold text-navy">—</p>
|
||||||
|
<p class="text-xs text-slate mt-1">see Health section below</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-Template Table -->
|
||||||
|
<div class="card mb-8">
|
||||||
|
<div class="card-header mb-4 flex justify-between items-center">
|
||||||
|
<span>Templates</span>
|
||||||
|
<span class="text-xs text-slate">Click "Gaps" to load missing articles per template</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Template</th>
|
||||||
|
<th>Data rows</th>
|
||||||
|
<th>Articles EN</th>
|
||||||
|
<th>Articles DE</th>
|
||||||
|
<th>Freshness</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in template_rows %}
|
||||||
|
{% set t = r.template %}
|
||||||
|
{% set stats = r.stats %}
|
||||||
|
{% set fr = r.freshness %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ t.name }}</strong><br>
|
||||||
|
<span class="text-xs text-slate">{{ t.slug }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ fr.row_count if fr.row_count is not none else '—' }}</td>
|
||||||
|
<td>{{ stats.by_language.get('en', {}).get('total', 0) }}</td>
|
||||||
|
<td>{{ stats.by_language.get('de', {}).get('total', 0) }}</td>
|
||||||
|
<td>
|
||||||
|
{% set status = fr.status | default('no_data') %}
|
||||||
|
<span class="pseo-status-badge pseo-status-{{ status }}">
|
||||||
|
{% if status == 'fresh' %}🟢 Fresh
|
||||||
|
{% elif status == 'stale' %}🟡 Stale
|
||||||
|
{% elif status == 'no_articles' %}🟣 No articles
|
||||||
|
{% else %}⚪ No data
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="flex gap-2 items-center">
|
||||||
|
<button class="btn-outline btn-sm"
|
||||||
|
hx-get="{{ url_for('pseo.pseo_gaps_template', slug=t.slug) }}"
|
||||||
|
hx-target="#gaps-panel-{{ t.slug }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#gaps-panel-{{ t.slug }}">
|
||||||
|
Gaps
|
||||||
|
</button>
|
||||||
|
<form method="post" action="{{ url_for('pseo.pseo_generate_gaps', slug=t.slug) }}" class="m-0">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm">Generate gaps</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="p-0">
|
||||||
|
<div id="gaps-panel-{{ t.slug }}" class="pseo-gaps-panel" style="padding: 0 1rem 0.5rem;">
|
||||||
|
<!-- Loaded via HTMX on "Gaps" click -->
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Jobs -->
|
||||||
|
{% if jobs %}
|
||||||
|
<div class="card mb-8">
|
||||||
|
<div class="card-header mb-4 flex justify-between items-center">
|
||||||
|
<span>Recent Generation Jobs</span>
|
||||||
|
<a href="{{ url_for('pseo.pseo_jobs') }}" class="text-xs text-blue">View all →</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Job</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Started</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for job in jobs %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('pseo.pseo_jobs') }}#job-{{ job.id }}" class="text-blue">#{{ job.id }}</a>
|
||||||
|
{% if job.payload %}
|
||||||
|
— {{ (job.payload | fromjson).get('template_slug', '') }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if job.status == 'complete' %}
|
||||||
|
<span class="badge-success">Complete</span>
|
||||||
|
{% elif job.status == 'failed' %}
|
||||||
|
<span class="badge-danger">Failed</span>
|
||||||
|
{% elif job.status == 'pending' %}
|
||||||
|
<span class="badge-warning">Running</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge">{{ job.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if job.progress_total and job.progress_total > 0 %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="progress-bar-wrap">
|
||||||
|
<div class="progress-bar-fill" style="width: {{ [((job.progress_current / job.progress_total) * 100) | int, 100] | min }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-slate">{{ job.progress_current }}/{{ job.progress_total }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs text-slate">{{ job.created_at | default('') | truncate(16, True, '') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Health Issues (HTMX-loaded) -->
|
||||||
|
<div id="health-panel"
|
||||||
|
hx-get="{{ url_for('pseo.pseo_health') }}"
|
||||||
|
hx-trigger="load delay:500ms"
|
||||||
|
hx-target="#health-panel"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<div class="card">
|
||||||
|
<p class="text-slate text-sm">Loading health checks…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
43
web/src/padelnomics/admin/templates/admin/pseo_gaps.html
Normal file
43
web/src/padelnomics/admin/templates/admin/pseo_gaps.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{# HTMX partial — rendered inside the gaps panel for one template.
|
||||||
|
Loaded via GET /admin/pseo/gaps/<slug>. #}
|
||||||
|
|
||||||
|
{% if not gaps %}
|
||||||
|
<p class="text-success text-sm p-2">✓ No gaps — all {{ template.name }} rows have articles.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<span class="text-sm font-semibold">{{ gaps | length }} missing row{{ 's' if gaps | length != 1 else '' }}</span>
|
||||||
|
<form method="post" action="{{ url_for('pseo.pseo_generate_gaps', slug=template.slug) }}" class="m-0">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm">Generate {{ gaps | length }} missing</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap" style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<table class="table text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ template.natural_key }}</th>
|
||||||
|
<th>Missing languages</th>
|
||||||
|
{% for key in (gaps[0].keys() | list | reject('equalto', '_natural_key') | reject('equalto', '_missing_languages') | list)[:4] %}
|
||||||
|
<th>{{ key }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for gap in gaps[:100] %}
|
||||||
|
<tr>
|
||||||
|
<td class="font-mono text-xs">{{ gap._natural_key }}</td>
|
||||||
|
<td class="text-xs text-amber-700">{{ gap._missing_languages | join(', ') }}</td>
|
||||||
|
{% for key in (gap.keys() | list | reject('equalto', '_natural_key') | reject('equalto', '_missing_languages') | list)[:4] %}
|
||||||
|
<td class="text-xs text-slate">{{ gap[key] | truncate(30) if gap[key] is string else gap[key] }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if gaps | length > 100 %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="text-xs text-slate text-center">… and {{ gaps | length - 100 }} more rows</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
99
web/src/padelnomics/admin/templates/admin/pseo_health.html
Normal file
99
web/src/padelnomics/admin/templates/admin/pseo_health.html
Normal file
@@ -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. #}
|
||||||
|
|
||||||
|
<div class="card" id="health-panel">
|
||||||
|
<div class="card-header mb-4 flex justify-between items-center">
|
||||||
|
<span>Health Checks</span>
|
||||||
|
<span class="text-xs text-slate">{{ health.counts.total }} issue{{ 's' if health.counts.total != 1 else '' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if health.counts.total == 0 %}
|
||||||
|
<p class="text-success text-sm">✓ No issues found — all articles are healthy.</p>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<!-- Hreflang Orphans -->
|
||||||
|
{% if health.hreflang_orphans %}
|
||||||
|
<details class="mb-4">
|
||||||
|
<summary class="cursor-pointer font-semibold text-sm text-amber-700">
|
||||||
|
⚠ Hreflang orphans ({{ health.counts.hreflang_orphans }})
|
||||||
|
<span class="text-xs font-normal text-slate ml-2">— articles missing a sibling language</span>
|
||||||
|
</summary>
|
||||||
|
<div class="table-wrap mt-2">
|
||||||
|
<table class="table text-sm">
|
||||||
|
<thead><tr><th>Template</th><th>URL path</th><th>Present</th><th>Missing</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for o in health.hreflang_orphans[:50] %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-xs text-slate">{{ o.template_slug }}</td>
|
||||||
|
<td><a href="{{ o.url_path }}" class="text-blue text-xs" target="_blank">{{ o.url_path }}</a></td>
|
||||||
|
<td class="text-xs">{{ o.present_languages | join(', ') }}</td>
|
||||||
|
<td class="text-xs text-red-600">{{ o.missing_languages | join(', ') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if health.hreflang_orphans | length > 50 %}
|
||||||
|
<tr><td colspan="4" class="text-xs text-slate text-center">… and {{ health.hreflang_orphans | length - 50 }} more</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Missing Build Files -->
|
||||||
|
{% if health.missing_build_files %}
|
||||||
|
<details class="mb-4">
|
||||||
|
<summary class="cursor-pointer font-semibold text-sm text-red-700">
|
||||||
|
❌ Missing build files ({{ health.counts.missing_build_files }})
|
||||||
|
<span class="text-xs font-normal text-slate ml-2">— published articles with no HTML on disk</span>
|
||||||
|
</summary>
|
||||||
|
<div class="table-wrap mt-2">
|
||||||
|
<table class="table text-sm">
|
||||||
|
<thead><tr><th>Slug</th><th>Language</th><th>URL path</th><th>Expected path</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in health.missing_build_files[:50] %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-xs font-mono">{{ m.slug }}</td>
|
||||||
|
<td class="text-xs">{{ m.language }}</td>
|
||||||
|
<td class="text-xs"><a href="{{ m.url_path }}" class="text-blue" target="_blank">{{ m.url_path }}</a></td>
|
||||||
|
<td class="text-xs text-slate font-mono">{{ m.expected_path }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if health.missing_build_files | length > 50 %}
|
||||||
|
<tr><td colspan="4" class="text-xs text-slate text-center">… and {{ health.missing_build_files | length - 50 }} more</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Broken Scenario Refs -->
|
||||||
|
{% if health.broken_scenario_refs %}
|
||||||
|
<details class="mb-4">
|
||||||
|
<summary class="cursor-pointer font-semibold text-sm text-red-700">
|
||||||
|
❌ Broken scenario refs ({{ health.counts.broken_scenario_refs }})
|
||||||
|
<span class="text-xs font-normal text-slate ml-2">— [scenario:slug] markers referencing deleted scenarios</span>
|
||||||
|
</summary>
|
||||||
|
<div class="table-wrap mt-2">
|
||||||
|
<table class="table text-sm">
|
||||||
|
<thead><tr><th>Slug</th><th>Language</th><th>Broken refs</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in health.broken_scenario_refs[:50] %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-xs font-mono">{{ b.slug }}</td>
|
||||||
|
<td class="text-xs">{{ b.language }}</td>
|
||||||
|
<td class="text-xs text-red-600 font-mono">{{ b.broken_scenario_refs | join(', ') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if health.broken_scenario_refs | length > 50 %}
|
||||||
|
<tr><td colspan="3" class="text-xs text-slate text-center">… and {{ health.broken_scenario_refs | length - 50 }} more</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{# HTMX partial — replaces the entire <tr> 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 %}
|
||||||
|
|
||||||
|
<tr id="job-{{ job.id }}"
|
||||||
|
{% if job.status == 'pending' %}
|
||||||
|
hx-get="{{ url_for('pseo.pseo_job_status', job_id=job.id) }}"
|
||||||
|
hx-trigger="every 2s"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
{% endif %}>
|
||||||
|
<td class="text-xs text-slate">#{{ job.id }}</td>
|
||||||
|
<td>—</td>{# payload not re-fetched in status endpoint — static display #}
|
||||||
|
<td>
|
||||||
|
{% if job.status == 'complete' %}
|
||||||
|
<span class="badge-success">Complete</span>
|
||||||
|
{% elif job.status == 'failed' %}
|
||||||
|
<span class="badge-danger">Failed</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge-warning">Running…</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if job.progress_total and job.progress_total > 0 %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="progress-bar-wrap" style="min-width:120px;">
|
||||||
|
<div class="progress-bar-fill" style="width: {{ pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-slate">{{ job.progress_current }}/{{ job.progress_total }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs text-slate">{{ job.created_at | default('') | truncate(19, True, '') }}</td>
|
||||||
|
<td class="text-xs text-slate">{{ job.completed_at | default('') | truncate(19, True, '') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if job.error %}
|
||||||
|
<details>
|
||||||
|
<summary class="text-xs text-red-600 cursor-pointer">Error</summary>
|
||||||
|
<pre class="text-xs mt-1 p-2 bg-gray-50 rounded overflow-auto max-w-xs">{{ job.error[:500] }}</pre>
|
||||||
|
</details>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
95
web/src/padelnomics/admin/templates/admin/pseo_jobs.html
Normal file
95
web/src/padelnomics/admin/templates/admin/pseo_jobs.html
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{% extends "admin/base_admin.html" %}
|
||||||
|
{% set admin_page = "pseo" %}
|
||||||
|
|
||||||
|
{% block title %}pSEO Jobs - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.progress-bar-wrap { height: 6px; background: #E2E8F0; border-radius: 9999px; overflow: hidden; min-width: 120px; }
|
||||||
|
.progress-bar-fill { height: 100%; background: #1D4ED8; border-radius: 9999px; transition: width 0.3s; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<header class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl">Generation Jobs</h1>
|
||||||
|
<p class="text-slate text-sm mt-1">Recent article generation runs</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('pseo.pseo_dashboard') }}" class="btn-outline btn-sm">← pSEO Engine</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if not jobs %}
|
||||||
|
<div class="card">
|
||||||
|
<p class="text-slate text-sm">No generation jobs found. Use the pSEO Engine dashboard to generate articles.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Template</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Completed</th>
|
||||||
|
<th>Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for job in jobs %}
|
||||||
|
<tr id="job-{{ job.id }}">
|
||||||
|
<td class="text-xs text-slate">#{{ job.id }}</td>
|
||||||
|
<td>
|
||||||
|
{% if job.payload %}
|
||||||
|
{% set payload = job.payload | fromjson %}
|
||||||
|
<span class="font-mono text-xs">{{ payload.get('template_slug', '—') }}</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if job.status == 'complete' %}
|
||||||
|
<span class="badge-success">Complete</span>
|
||||||
|
{% elif job.status == 'failed' %}
|
||||||
|
<span class="badge-danger">Failed</span>
|
||||||
|
{% elif job.status == 'pending' %}
|
||||||
|
{# Poll live status for running jobs #}
|
||||||
|
<div hx-get="{{ url_for('pseo.pseo_job_status', job_id=job.id) }}"
|
||||||
|
hx-trigger="load, every 2s"
|
||||||
|
hx-target="closest tr"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<span class="badge-warning">Running…</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge">{{ job.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if job.progress_total and job.progress_total > 0 %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="progress-bar-wrap">
|
||||||
|
<div class="progress-bar-fill" style="width: {{ [((job.progress_current / job.progress_total) * 100) | int, 100] | min }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-slate">{{ job.progress_current }}/{{ job.progress_total }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs text-slate">{{ job.created_at | default('') | truncate(19, True, '') }}</td>
|
||||||
|
<td class="text-xs text-slate">{{ job.completed_at | default('') | truncate(19, True, '') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if job.error %}
|
||||||
|
<details>
|
||||||
|
<summary class="text-xs text-red-600 cursor-pointer">Error</summary>
|
||||||
|
<pre class="text-xs mt-1 p-2 bg-gray-50 rounded overflow-auto max-w-xs">{{ job.error[:500] }}</pre>
|
||||||
|
</details>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Padelnomics - Application factory and entry point.
|
Padelnomics - Application factory and entry point.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -105,6 +106,7 @@ def create_app() -> Quart:
|
|||||||
app.jinja_env.filters["fmt_n"] = _fmt_n
|
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["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["country_name"] = get_country_name # {{ article.country | country_name(lang) }}
|
||||||
|
app.jinja_env.filters["fromjson"] = json.loads # {{ job.payload | fromjson }}
|
||||||
|
|
||||||
# Session config
|
# Session config
|
||||||
app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG
|
app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG
|
||||||
|
|||||||
Reference in New Issue
Block a user