From 4f6e900c05dfafb1ad4d6a9603dc6c6e69536878 Mon Sep 17 00:00:00 2001 From: Michael Pilosov Date: Wed, 22 Apr 2026 18:21:51 -0600 Subject: [PATCH] runs filter: persist chip state in URL + server-render initial slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runs-filter.js mirrors its chip selections into the query string (dataset/algorithm/N/T/J). Empty selections are omitted entirely. Reads URL on init; triggers an immediate /runs refresh if any filter was present so the polled slice catches up instantly. - dataset-picker.js's updateUrlState now merges into the existing query instead of rebuilding, so the two scripts don't stomp each other's keys. - Index route applies the same chip filter to its initial server-side run listing, so a filter-bearing deep-link renders the right slice on first paint — no flash of unfiltered runs. --- app/web/main.py | 6 ++++-- app/web/static/dataset-picker.js | 5 ++++- app/web/static/runs-filter.js | 27 +++++++++++++++++++++++++++ app/web/templates/index.html | 2 +- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/app/web/main.py b/app/web/main.py index 705a9ff..e514939 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -742,14 +742,16 @@ async def index(request: Request) -> HTMLResponse: reducers = _reducer_choices() default_reducer = reducers[0]["key"] if reducers else None default_spec = REDUCERS.get(default_reducer) if default_reducer else None + q = request.query_params + required = _chip_filter_tags(q) + initial_limit = 50 if required else 10 async with httpx.AsyncClient(timeout=5.0) as client: - runs = await PREFECT.recent_runs(client, limit=10) + runs = await PREFECT.recent_runs(client, limit=initial_limit, required_tags=required) dep_id = await PREFECT.deployment_id(client) views = [_run_view(r) for r in runs] _mark_stale_views(views) # Pre-resolve the two
sections' open state from the URL so # first paint matches (no flash). Intro defaults closed, picker open. - q = request.query_params intro_open = q.get("intro") == "1" picker_open = q.get("picker") != "0" # Also pre-resolve the radio-group selections so n/f/j render with the diff --git a/app/web/static/dataset-picker.js b/app/web/static/dataset-picker.js index 6c6b00f..6e43ef5 100644 --- a/app/web/static/dataset-picker.js +++ b/app/web/static/dataset-picker.js @@ -298,7 +298,10 @@ async function main() { // the query string so a refresh restores the page. Intro defaults to // closed (no param); picker defaults to open (no param). function updateUrlState() { - const p = new URLSearchParams(); + // Merge into the current query — other scripts (e.g. runs-filter.js) + // also own some keys, so wipe only ours before repopulating. + const p = new URLSearchParams(window.location.search); + for (const k of ['intro', 'picker', 'ds', 'n', 'f', 'j']) p.delete(k); const introEl = document.getElementById('intro'); const pickerEl = document.getElementById('picker'); if (introEl && introEl.open) p.set('intro', '1'); diff --git a/app/web/static/runs-filter.js b/app/web/static/runs-filter.js index ef11e7c..471eed1 100644 --- a/app/web/static/runs-filter.js +++ b/app/web/static/runs-filter.js @@ -37,6 +37,26 @@ slot.setAttribute('hx-vals', JSON.stringify(stateAsQuery())); } + function syncUrl() { + // Mirror chip state to URL query — merge so we don't stomp on other + // scripts' keys (dataset-picker writes intro/picker/ds/n/f/j). + const p = new URLSearchParams(window.location.search); + for (const ax of AXES) p.delete(ax.key); + for (const ax of AXES) { + if (ax.selected != null) p.set(ax.key, ax.selected); + } + const qs = p.toString(); + history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname); + } + + function applyUrlState() { + const u = new URLSearchParams(window.location.search); + for (const ax of AXES) { + const v = u.get(ax.key); + ax.selected = v || null; + } + } + function paint(ax) { if (!ax.el) return; ax.el.innerHTML = ''; @@ -101,6 +121,7 @@ ax.selected = (ax.selected === v) ? null : v; paint(ax); syncHtmxVals(); + syncUrl(); updateCounter(); triggerRunsRefresh(); }); @@ -115,9 +136,15 @@ } }); + applyUrlState(); syncHtmxVals(); refreshUniverse(); updateCounter(); + // If the URL had filter params, kick a refresh so #runs-slot's initial + // HTML (rendered unfiltered server-side) gets replaced with the matching + // slice. Without this the user would see the full list until the 3s + // poll tick. + if (AXES.some((ax) => ax.selected != null)) triggerRunsRefresh(); // Periodically refresh the universe so newly-introduced values appear. setInterval(refreshUniverse, 30_000); })(); diff --git a/app/web/templates/index.html b/app/web/templates/index.html index 51294ff..eaf52ec 100644 --- a/app/web/templates/index.html +++ b/app/web/templates/index.html @@ -500,7 +500,7 @@ - +