runs filter: persist chip state in URL + server-render initial slice

- 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.
This commit is contained in:
Michael Pilosov 2026-04-22 18:21:51 -06:00
parent 3a951b387a
commit 4f6e900c05
4 changed files with 36 additions and 4 deletions

View File

@ -742,14 +742,16 @@ async def index(request: Request) -> HTMLResponse:
reducers = _reducer_choices() reducers = _reducer_choices()
default_reducer = reducers[0]["key"] if reducers else None default_reducer = reducers[0]["key"] if reducers else None
default_spec = REDUCERS.get(default_reducer) if default_reducer 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: 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) dep_id = await PREFECT.deployment_id(client)
views = [_run_view(r) for r in runs] views = [_run_view(r) for r in runs]
_mark_stale_views(views) _mark_stale_views(views)
# Pre-resolve the two <details> sections' open state from the URL so # Pre-resolve the two <details> sections' open state from the URL so
# first paint matches (no flash). Intro defaults closed, picker open. # first paint matches (no flash). Intro defaults closed, picker open.
q = request.query_params
intro_open = q.get("intro") == "1" intro_open = q.get("intro") == "1"
picker_open = q.get("picker") != "0" picker_open = q.get("picker") != "0"
# Also pre-resolve the radio-group selections so n/f/j render with the # Also pre-resolve the radio-group selections so n/f/j render with the

View File

@ -298,7 +298,10 @@ async function main() {
// the query string so a refresh restores the page. Intro defaults to // the query string so a refresh restores the page. Intro defaults to
// closed (no param); picker defaults to open (no param). // closed (no param); picker defaults to open (no param).
function updateUrlState() { 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 introEl = document.getElementById('intro');
const pickerEl = document.getElementById('picker'); const pickerEl = document.getElementById('picker');
if (introEl && introEl.open) p.set('intro', '1'); if (introEl && introEl.open) p.set('intro', '1');

View File

@ -37,6 +37,26 @@
slot.setAttribute('hx-vals', JSON.stringify(stateAsQuery())); 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) { function paint(ax) {
if (!ax.el) return; if (!ax.el) return;
ax.el.innerHTML = ''; ax.el.innerHTML = '';
@ -101,6 +121,7 @@
ax.selected = (ax.selected === v) ? null : v; ax.selected = (ax.selected === v) ? null : v;
paint(ax); paint(ax);
syncHtmxVals(); syncHtmxVals();
syncUrl();
updateCounter(); updateCounter();
triggerRunsRefresh(); triggerRunsRefresh();
}); });
@ -115,9 +136,15 @@
} }
}); });
applyUrlState();
syncHtmxVals(); syncHtmxVals();
refreshUniverse(); refreshUniverse();
updateCounter(); 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. // Periodically refresh the universe so newly-introduced values appear.
setInterval(refreshUniverse, 30_000); setInterval(refreshUniverse, 30_000);
})(); })();

View File

@ -500,7 +500,7 @@
<script type="module" src="/static/dataset-picker.js?v=12"></script> <script type="module" src="/static/dataset-picker.js?v=12"></script>
<script type="module" src="/static/metrics.js?v=11"></script> <script type="module" src="/static/metrics.js?v=11"></script>
<script src="/static/compare-select.js?v=4"></script> <script src="/static/compare-select.js?v=4"></script>
<script src="/static/runs-filter.js?v=6"></script> <script src="/static/runs-filter.js?v=7"></script>
<script type="module" src="/static/run-modal.js?v=3"></script> <script type="module" src="/static/run-modal.js?v=3"></script>
<script> <script>
// Anchor-links alone don't expand <details>; force it. // Anchor-links alone don't expand <details>; force it.