merge: unify confirm dialog — pure hx-confirm + form[method=dialog]

Eliminates confirmAction() entirely. One code path: all confirmations
go through showConfirm() called by the htmx:confirm interceptor.
14 template files converted to hx-boost + hx-confirm pattern.
Pipeline endpoints updated to exclude HX-Boosted requests from the
HTMX partial path.

# Conflicts:
#	web/src/padelnomics/admin/templates/admin/affiliate_form.html
#	web/src/padelnomics/admin/templates/admin/affiliate_program_form.html
#	web/src/padelnomics/admin/templates/admin/base_admin.html
#	web/src/padelnomics/admin/templates/admin/partials/affiliate_program_results.html
#	web/src/padelnomics/admin/templates/admin/partials/affiliate_row.html
This commit is contained in:
Deeman
2026-03-02 07:48:49 +01:00
14 changed files with 54 additions and 51 deletions

View File

@@ -19,6 +19,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Proxy URL scheme validation in `load_proxy_tiers()`** — URLs in `PROXY_URLS_DATACENTER` / `PROXY_URLS_RESIDENTIAL` that are missing an `http://` or `https://` scheme are now logged as a warning and skipped, rather than being passed through and causing SSL handshake failures or connection errors at request time. Also fixed a missing `http://` prefix in the dev `.env` `PROXY_URLS_DATACENTER` entry. - **Proxy URL scheme validation in `load_proxy_tiers()`** — URLs in `PROXY_URLS_DATACENTER` / `PROXY_URLS_RESIDENTIAL` that are missing an `http://` or `https://` scheme are now logged as a warning and skipped, rather than being passed through and causing SSL handshake failures or connection errors at request time. Also fixed a missing `http://` prefix in the dev `.env` `PROXY_URLS_DATACENTER` entry.
### Changed ### Changed
- **Unified confirm dialog — pure HTMX `hx-confirm` + `<form method="dialog">`** — eliminated the `confirmAction()` JS function and the duplicate `cloneNode` hack. All confirmation prompts now go through a single `showConfirm()` Promise-based function called by the `htmx:confirm` interceptor. The dialog HTML uses `<form method="dialog">` for native close semantics (`returnValue` is `"ok"` or `"cancel"`), removing the need to clone and replace buttons on every invocation. All 12 Padelnomics call sites converted from `onclick=confirmAction(...)` to `hx-boost="true"` + `hx-confirm="..."` on the submit button. Pipeline trigger endpoints updated to treat `HX-Boosted: true` requests as non-HTMX (returning a redirect rather than an inline partial) so boosted form submissions flow through the normal redirect cycle. Same changes applied to BeanFlows and the quart-saas-boilerplate template.
- `web/src/padelnomics/admin/templates/admin/base_admin.html`: replaced dialog `<div>` with `<form method="dialog">`, replaced `confirmAction()` + inline `htmx:confirm` handler with unified `showConfirm()` + single `htmx:confirm` listener
- `web/src/padelnomics/admin/pipeline_routes.py`: `pipeline_trigger_extract` and `pipeline_trigger_transform` now exclude `HX-Boosted: true` from the HTMX partial path
- 12 templates updated: `pipeline.html`, `partials/pipeline_extractions.html`, `affiliate_form.html`, `affiliate_program_form.html`, `partials/affiliate_program_results.html`, `partials/affiliate_row.html`, `generate_form.html`, `articles.html`, `audience_contacts.html`, `template_detail.html`, `partials/scenario_results.html`
- Same changes mirrored to BeanFlows and quart-saas-boilerplate template
- **Per-proxy dead tracking in tiered cycler** — `make_tiered_cycler` now accepts a `proxy_failure_limit` parameter (default 3). Individual proxies that hit the limit are marked dead and permanently skipped by `next_proxy()`. If all proxies in the active tier are dead, `next_proxy()` auto-escalates to the next tier without needing the tier-level threshold. `record_failure(proxy_url)` and `record_success(proxy_url)` accept an optional `proxy_url` argument for per-proxy tracking; callers without `proxy_url` are fully backward-compatible. New `dead_proxy_count()` callable exposed for monitoring. - **Per-proxy dead tracking in tiered cycler** — `make_tiered_cycler` now accepts a `proxy_failure_limit` parameter (default 3). Individual proxies that hit the limit are marked dead and permanently skipped by `next_proxy()`. If all proxies in the active tier are dead, `next_proxy()` auto-escalates to the next tier without needing the tier-level threshold. `record_failure(proxy_url)` and `record_success(proxy_url)` accept an optional `proxy_url` argument for per-proxy tracking; callers without `proxy_url` are fully backward-compatible. New `dead_proxy_count()` callable exposed for monitoring.
- `extract/padelnomics_extract/src/padelnomics_extract/proxy.py`: added per-proxy state (`proxy_failure_counts`, `dead_proxies`), updated `next_proxy`/`record_failure`/`record_success`, added `dead_proxy_count` - `extract/padelnomics_extract/src/padelnomics_extract/proxy.py`: added per-proxy state (`proxy_failure_counts`, `dead_proxies`), updated `next_proxy`/`record_failure`/`record_success`, added `dead_proxy_count`
- `extract/padelnomics_extract/src/padelnomics_extract/playtomic_tenants.py`: `_fetch_page_via_cycler` passes `proxy_url` to `record_success`/`record_failure` - `extract/padelnomics_extract/src/padelnomics_extract/playtomic_tenants.py`: `_fetch_page_via_cycler` passes `proxy_url` to `record_success`/`record_failure`

View File

@@ -780,7 +780,8 @@ async def pipeline_trigger_extract():
else: else:
await enqueue("run_extraction") await enqueue("run_extraction")
is_htmx = request.headers.get("HX-Request") == "true" is_htmx = (request.headers.get("HX-Request") == "true"
and request.headers.get("HX-Boosted") != "true")
if is_htmx: if is_htmx:
return await _render_overview_partial() return await _render_overview_partial()
@@ -1005,7 +1006,8 @@ async def pipeline_trigger_transform():
(task_name,), (task_name,),
) )
if existing: if existing:
is_htmx = request.headers.get("HX-Request") == "true" is_htmx = (request.headers.get("HX-Request") == "true"
and request.headers.get("HX-Boosted") != "true")
if is_htmx: if is_htmx:
return await _render_transform_partial() return await _render_transform_partial()
await flash(f"A '{step}' task is already queued (task #{existing['id']}).", "warning") await flash(f"A '{step}' task is already queued (task #{existing['id']}).", "warning")
@@ -1013,7 +1015,8 @@ async def pipeline_trigger_transform():
await enqueue(task_name) await enqueue(task_name)
is_htmx = request.headers.get("HX-Request") == "true" is_htmx = (request.headers.get("HX-Request") == "true"
and request.headers.get("HX-Boosted") != "true")
if is_htmx: if is_htmx:
return await _render_transform_partial() return await _render_transform_partial()

View File

@@ -226,10 +226,9 @@ document.addEventListener('DOMContentLoaded', function() {
<a href="{{ url_for('admin.affiliate_products') }}" class="btn-outline">Cancel</a> <a href="{{ url_for('admin.affiliate_products') }}" class="btn-outline">Cancel</a>
</div> </div>
{% if editing %} {% if editing %}
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0"> <form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline" <button type="submit" class="btn-outline"
onclick="event.preventDefault(); confirmAction('Delete this product? This cannot be undone.', this.closest('form'))">Delete</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>

View File

@@ -120,10 +120,9 @@ document.addEventListener('DOMContentLoaded', function() {
<a href="{{ url_for('admin.affiliate_programs') }}" class="btn-outline">Cancel</a> <a href="{{ url_for('admin.affiliate_programs') }}" class="btn-outline">Cancel</a>
</div> </div>
{% if editing %} {% if editing %}
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=program_id) }}" style="margin:0"> <form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=program_id) }}" style="margin:0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline" <button type="submit" class="btn-outline"
onclick="event.preventDefault(); confirmAction('Delete this program? Blocked if products reference it.', this.closest('form'))">Delete</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>

View File

@@ -11,9 +11,10 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a> <a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline"> <form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-outline btn-sm" onclick="confirmAction('Rebuild all articles? This will re-render every article from its template.', this.closest('form'))">Rebuild All</button> <button type="submit" class="btn-outline btn-sm"
hx-confirm="Rebuild all articles? This will re-render every article from its template.">Rebuild All</button>
</form> </form>
</div> </div>
</header> </header>

View File

@@ -27,10 +27,11 @@
<td class="text-sm">{{ c.email if c.email is defined else (c.get('email', '-') if c is mapping else '-') }}</td> <td class="text-sm">{{ c.email if c.email is defined else (c.get('email', '-') if c is mapping else '-') }}</td>
<td class="mono text-sm">{{ (c.created_at if c.created_at is defined else (c.get('created_at', '-') if c is mapping else '-'))[:16] if c else '-' }}</td> <td class="mono text-sm">{{ (c.created_at if c.created_at is defined else (c.get('created_at', '-') if c is mapping else '-'))[:16] if c else '-' }}</td>
<td style="text-align:right"> <td style="text-align:right">
<form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline"> <form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="contact_id" value="{{ c.id if c.id is defined else (c.get('id', '') if c is mapping else '') }}"> <input type="hidden" name="contact_id" value="{{ c.id if c.id is defined else (c.get('id', '') if c is mapping else '') }}">
<button type="button" class="btn-outline btn-sm" style="color:#DC2626" onclick="confirmAction('Remove this contact from the audience?', this.closest('form'))">Remove</button> <button type="submit" class="btn-outline btn-sm" style="color:#DC2626"
hx-confirm="Remove this contact from the audience?">Remove</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -228,36 +228,29 @@
<dialog id="confirm-dialog"> <dialog id="confirm-dialog">
<p id="confirm-msg"></p> <p id="confirm-msg"></p>
<div class="dialog-actions"> <form method="dialog" class="dialog-actions">
<button id="confirm-cancel" class="btn-outline btn-sm">Cancel</button> <button value="cancel" class="btn-outline btn-sm">Cancel</button>
<button id="confirm-ok" class="btn btn-sm">Confirm</button> <button value="ok" class="btn btn-sm">Confirm</button>
</div> </form>
</dialog> </dialog>
<script> <script>
function confirmAction(message, form) { function showConfirm(message) {
var dialog = document.getElementById('confirm-dialog'); var dialog = document.getElementById('confirm-dialog');
document.getElementById('confirm-msg').textContent = message; document.getElementById('confirm-msg').textContent = message;
var ok = document.getElementById('confirm-ok');
var newOk = ok.cloneNode(true);
ok.replaceWith(newOk);
newOk.addEventListener('click', function() { dialog.close(); form.submit(); });
document.getElementById('confirm-cancel').addEventListener('click', function() { dialog.close(); }, { once: true });
dialog.showModal(); dialog.showModal();
return new Promise(function(resolve) {
dialog.addEventListener('close', function() {
resolve(dialog.returnValue === 'ok');
}, { once: true });
});
} }
// Intercept hx-confirm to use the styled dialog instead of window.confirm()
document.body.addEventListener('htmx:confirm', function(evt) { document.body.addEventListener('htmx:confirm', function(evt) {
if (!evt.detail.question) return; // no hx-confirm on this element, let HTMX proceed if (!evt.detail.question) return;
var dialog = document.getElementById('confirm-dialog');
if (!dialog) return; // fallback: let HTMX use native confirm
evt.preventDefault(); evt.preventDefault();
document.getElementById('confirm-msg').textContent = evt.detail.question; showConfirm(evt.detail.question).then(function(ok) {
var ok = document.getElementById('confirm-ok'); if (ok) evt.detail.issueRequest(true);
var newOk = ok.cloneNode(true); });
ok.replaceWith(newOk);
newOk.addEventListener('click', function() { dialog.close(); evt.detail.issueRequest(true); }, { once: true });
document.getElementById('confirm-cancel').addEventListener('click', function() { dialog.close(); }, { once: true });
dialog.showModal();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -19,7 +19,7 @@
<p class="text-slate text-sm">No data rows found. Run the data pipeline to populate <code>{{ config_data.data_table }}</code>.</p> <p class="text-slate text-sm">No data rows found. Run the data pipeline to populate <code>{{ config_data.data_table }}</code>.</p>
</div> </div>
{% else %} {% else %}
<form method="post" class="card"> <form method="post" class="card" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4"> <div class="mb-4">
@@ -45,7 +45,8 @@
</p> </p>
</div> </div>
<button type="button" class="btn" style="width: 100%;" onclick="confirmAction('Generate articles? Existing articles will be updated in-place.', this.closest('form'))"> <button type="submit" class="btn" style="width: 100%;"
hx-confirm="Generate articles? Existing articles will be updated in-place.">
Generate Articles Generate Articles
</button> </button>
</form> </form>

View File

@@ -21,10 +21,9 @@
</td> </td>
<td class="text-right" style="white-space:nowrap"> <td class="text-right" style="white-space:nowrap">
<a href="{{ url_for('admin.affiliate_program_edit', program_id=prog.id) }}" class="btn-outline btn-sm">Edit</a> <a href="{{ url_for('admin.affiliate_program_edit', program_id=prog.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline"> <form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" <button type="submit" class="btn-outline btn-sm"
onclick="event.preventDefault(); confirmAction('Delete {{ prog.name }}? This is blocked if products reference it.', this.closest('form'))">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -20,10 +20,9 @@
<td class="mono text-right">{{ product.click_count or 0 }}</td> <td class="mono text-right">{{ product.click_count or 0 }}</td>
<td class="text-right" style="white-space:nowrap"> <td class="text-right" style="white-space:nowrap">
<a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" class="btn-outline btn-sm">Edit</a> <a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline"> <form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" <button type="submit" class="btn-outline btn-sm"
onclick="event.preventDefault(); confirmAction('Delete {{ product.name }}?', this.closest('form'))">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -29,10 +29,10 @@
</div> </div>
</form> </form>
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_extract') }}" class="m-0"> <form method="post" action="{{ url_for('pipeline.pipeline_trigger_extract') }}" class="m-0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-outline btn-sm" <button type="submit" class="btn-outline btn-sm"
onclick="confirmAction('Enqueue a full extraction run? This will run all extractors in the background.', this.closest('form'))"> hx-confirm="Enqueue a full extraction run? This will run all extractors in the background.">
Run All Extractors Run All Extractors
</button> </button>
</form> </form>
@@ -112,11 +112,11 @@
{% if run.status == 'running' %} {% if run.status == 'running' %}
<form method="post" <form method="post"
action="{{ url_for('pipeline.pipeline_mark_stale', run_id=run.run_id) }}" action="{{ url_for('pipeline.pipeline_mark_stale', run_id=run.run_id) }}"
class="m-0"> class="m-0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-danger btn-sm" <button type="submit" class="btn-danger btn-sm"
style="padding:2px 8px;font-size:11px" style="padding:2px 8px;font-size:11px"
onclick="confirmAction('Mark run #{{ run.run_id }} as failed? Only do this if the process is definitely dead.', this.closest('form'))"> hx-confirm="Mark run #{{ run.run_id }} as failed? Only do this if the process is definitely dead.">
Mark Failed Mark Failed
</button> </button>
</form> </form>

View File

@@ -36,9 +36,10 @@
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a> <a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a> <a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a>
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a> <a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;"> <form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-outline btn-sm" onclick="confirmAction('Delete this scenario? This cannot be undone.', this.closest('form'))">Delete</button> <button type="submit" class="btn-outline btn-sm"
hx-confirm="Delete this scenario? This cannot be undone.">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -57,11 +57,11 @@
<p class="text-sm text-slate mt-1">Extraction status, data catalog, and ad-hoc query editor</p> <p class="text-sm text-slate mt-1">Extraction status, data catalog, and ad-hoc query editor</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_transform') }}" class="m-0"> <form method="post" action="{{ url_for('pipeline.pipeline_trigger_transform') }}" class="m-0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="step" value="pipeline"> <input type="hidden" name="step" value="pipeline">
<button type="button" class="btn btn-sm" <button type="submit" class="btn btn-sm"
onclick="confirmAction('Run full ELT pipeline (extract → transform → export)? This runs in the background.', this.closest('form'))"> hx-confirm="Run full ELT pipeline (extract → transform → export)? This runs in the background.">
Run Pipeline Run Pipeline
</button> </button>
</form> </form>

View File

@@ -13,9 +13,10 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{{ url_for('admin.template_generate', slug=config_data.slug) }}" class="btn">Generate Articles</a> <a href="{{ url_for('admin.template_generate', slug=config_data.slug) }}" class="btn">Generate Articles</a>
<form method="post" action="{{ url_for('admin.template_regenerate', slug=config_data.slug) }}" style="display:inline"> <form method="post" action="{{ url_for('admin.template_regenerate', slug=config_data.slug) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-outline" onclick="confirmAction('Regenerate all articles for this template with fresh data? Existing articles will be overwritten.', this.closest('form'))"> <button type="submit" class="btn-outline"
hx-confirm="Regenerate all articles for this template with fresh data? Existing articles will be overwritten.">
Regenerate Regenerate
</button> </button>
</form> </form>