You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Upgrade a Dashboard filter from D3's free-text input to a dropdown (filter: <param>) or
multiselect (filter[]: <param>) when a specially-named Library query supplies the option
list — plus the machinery D3 explicitly deferred: parallel option-query execution, caching,
and cascading (a filter's option list can itself depend on another filter's current value).
Phase D6 of the Dashboard epic (#149 §6, "Global filters" items 2-4, and the "Open questions"
entry "Cascading evaluation order"). Builds on D3 (#152, merged) — the param-detection, state.varValues, per-tile debounce/generation-guard, and unfilled-param gating this phase
reuses wholesale, just fanned out to a second class of query (option-queries) alongside tiles.
Already decided (from #149 §6 — not reopened here)
Naming convention, no schema change: a saved query named exactly filter: <param> (or filter[]: <param> for multiselect) supplies that param's options — matched by name, not a
new field on the saved-query object.
Result-column convention: 1 column → values only; 2 columns → value (bound/injected) + label (displayed) — e.g. store an airport code, show its name.
Author controls cost: the filter query is arbitrary SQL (joins, dimension tables, DISTINCT, ORDER BY) — we deliberately do not introspect the schema to auto-derive options,
since a tile may join arbitrary tables with no single reliable column to DISTINCT.
filters: bundle (one query, many params) stays deferred — per-param queries remain the
only supported shape; revisit only if a real dashboard accumulates many cheap enums worth
batching.
Prior art — what we take from Grafana, and what we deliberately don't
Grafana's variable system is the closest existing design for "curated, cascading dashboard
filters," and its evaluation model is worth reusing directly:
Parent-first evaluation order via a dependency graph among variables ("chained variables"
in Grafana's docs) — a variable whose query references another variable is evaluated only
after that other variable has a value.
Dependent-selection invalidation — when a parent variable changes, Grafana re-runs every
variable that depends on it and, if the previously-selected value is no longer in the
refreshed option list, resets it (cascading further if that variable has its own dependents).
"All" for multiselect degrades to "no filter", not a special sentinel value.
What we do not take from Grafana: its $variable/macro text-substitution layer
($conditionalTest, $__timeFilter, raw $var interpolation into SQL strings). That exists in
Grafana only because its variables are untyped string substitution with no query-parameter
protocol underneath. We already have a type-safe, injection-safe substitution mechanism
(ClickHouse-native {name:Type}, #134) — a filter:/filter[]: query is just an ordinary saved SELECT, and cascading dependency is just "this SQL happens to reference another {param:Type}"
— detected with the exact same detectParams/readStatementParams this codebase already
has. No macro syntax, no SQL string-rewriting, nothing new to parse.
Scope
src/core/dashboard.js — new pure functions (100%-covered), building directly on D3's dashboardParams:
// A curated filter: a saved query named "filter: <param>" or "filter[]: <param>"// (whitespace-trimmed, case-sensitive prefix). Detection is name-based, reusing// no new parsing beyond a name match + the existing readStatementParams for its SQL.constFILTER_NAME_RE=/^filter(\[\])?:\s*(\S.*)$/;exportfunctiondetectCuratedFilters(savedQueries){// → [{ param, multi, sql, dependsOn: string[] }], deduped by param (first match wins,// consistent with dashboardParams' first-appearance-order convention). `dependsOn` is// readStatementParams(sql) minus `param` itself — the cascading dependency edges.}exportfunctionorderCuratedFilters(filters){// Topological sort of the dependsOn DAG, parent-first. Returns// { order: [...], cycles: [...] } — a filter caught in a cycle (or depending on an// undetected/non-curated param) is excluded from `order` and reported in `cycles`// rather than throwing, so one bad filter degrades instead of breaking the dashboard.}
src/net/ch-client.js — a thin queryFilterOptions(ctx, sql, signal, params) wrapper
alongside D3's queryDashboardTile (same readonly:2, same queryJson core) — option-queries
are read-only tile-shaped requests, nothing new at the transport layer.
src/ui/dashboard.js — the new behavior:
Execution order: on load (and on Refresh), run every filter in order from orderCuratedFilters. Root filters (no dependsOn) fire immediately and in parallel
(reuse the existing bounded-concurrency fan-out already used for tile fetches — one shared
limiter, so N curated filters + M tiles never exceed the current concurrency ceiling
combined). A dependent filter waits until every param in its dependsOn has a value; while
waiting, its control renders disabled with a "depends on: year" hint — the same visual
language as D3's per-tile "enter a value for…" placeholder.
Caching: memoize each curated filter's last-fetched option list keyed by a stable
serialization of the subset of varValues it depends on ({} for a root filter — i.e. fetch
once, never again until Refresh). Navigating back to a previously-seen parent value is a
cache hit, no network call. Refresh clears the cache map entirely and re-fetches
everything from scratch — identical semantics to how Refresh already re-runs every tile.
Cascading on value change: when any filter's value changes —
re-run tiles referencing it (unchanged D3 mechanism), and
re-run every curated filter whose dependsOn includes it (its direct children in the DAG),
with a cache-bypassing fetch;
for each such child, if its current selection is no longer present in the refreshed
option list, clear it — which recursively repeats this step for the child's own children
(grandchildren re-run too) and also triggers the tile re-run for the now-empty param;
if the current selection is still valid, leave it untouched (no spurious tile re-runs from
an option-list refresh alone — only an actual value change propagates further).
Reuses D3's per-slot generation counter (extended to curated-filter slots, not just tiles) so
a stale in-flight option-query response can never overwrite a newer one.
Cycle / bad-dependency degradation: a filter reported in orderCuratedFilters's cycles
renders as a plain text input (D3's existing fallback control) with a small warning
affordance — the dashboard as a whole still works, only that one filter loses curation.
Multiselect control: filter[]: renders a native <select multiple> bound to {param:Array(T)} (paramArgs already threads array values through as ClickHouse expects —
no new encoding). A richer checklist-popover control is a future polish, not required for v1. "Select none"/"All" is just "no value" — the predicate is omitted, identical to a text
filter's empty-string semantics from D3; no (All) sentinel option is added to the list.
Tests — tests/unit/dashboard.test.js: detectCuratedFilters (name matching, both prefixes,
dedup, dependsOn extraction); orderCuratedFilters (linear chain, diamond dependency, a
2-cycle reported and excluded, a filter depending on a non-curated/undetected param treated as a
root with a permanently-unfilled dependency — same "enter a value" gating as a normal param).
UI-layer (src/ui/dashboard.js, integration-tested): parallel root fan-out fires together;
dependent waits then fires once its dependency is filled; changing a parent re-fetches only
direct children, invalidates a now-invalid child selection, and cascades to grandchildren;
Refresh busts the cache; a superseded option-query response is discarded (generation guard);
cache hit skips the network call on a revisited parent value.
Design decisions
Dependency detection reuses readStatementParams verbatim — a curated filter's SQL is
parsed exactly like tile SQL; "cascading" is not a new concept, just "this query happens to
reference another dashboard param."
Degrade, don't fail: a cyclic or otherwise-unresolvable filter dependency falls back to a
plain text input rather than blocking the whole dashboard from rendering.
One shared concurrency limiter across tiles and option-queries (not a second, independent
one) — avoids doubling worst-case concurrent-request pressure on the cluster when a dashboard
has both many tiles and many curated filters.
A saved query named filter: year upgrades the year filter from D3's text input to a
dropdown populated by that query's results; 1-column results use the value as its own
label, 2-column results show label while binding value.
A saved query named filter[]: carrier renders a multiselect; selecting 0 values omits
the predicate entirely (same as an empty text filter), selecting N values binds {carrier:Array(T)}.
Root curated filters (no dependency on another param) fetch their options in parallel
on dashboard load, sharing the existing tile-fetch concurrency limiter.
A filter query referencing another dashboard param (e.g. filter: origin selecting WHERE {year:UInt16} = ...) renders disabled until year has a value, then fetches.
Changing year re-fetches origin's option list; if the previously-selected origin is no
longer present, it's cleared (and any tile/filter depending on origin reacts
accordingly); if it's still present, it stays selected and nothing downstream re-runs.
A 2-filter cycle (or a filter depending on itself) is detected, excluded from normal
evaluation, and rendered as a plain text input — the rest of the dashboard is unaffected.
Revisiting a previously-seen parent value is a cache hit (no network request); Refresh
clears all curated-filter caches and re-fetches everything.
A stale (superseded) option-query response is discarded, never overwriting a newer one.
Coverage gates hold: new src/core/dashboard.js functions at 100%; src/net/ch-client.js
at 100%; src/ui/dashboard.js at its existing UI-layer gate; no new runtime dependency.
Non-goals
A richer checklist/popover multiselect control (native <select multiple> is the v1 UI); the filters: single-query-many-params bundle (#149, deferred); server-side/persisted caching
(client-side, cleared on tab close, same as D3); auto-refresh/polling of option lists; per-tile
Type/X/Y overrides, expand modal, drill-down (D7, #149); export (D8, #149).
Tracking
Phase D6 of #149. Depends on D3 (#152, merged) for the param-detection/varValues/generation-
guard primitives this phase fans out to option-queries.
Goal
Upgrade a Dashboard filter from D3's free-text input to a dropdown (
filter: <param>) ormultiselect (
filter[]: <param>) when a specially-named Library query supplies the optionlist — plus the machinery D3 explicitly deferred: parallel option-query execution, caching,
and cascading (a filter's option list can itself depend on another filter's current value).
Phase D6 of the Dashboard epic (#149 §6, "Global filters" items 2-4, and the "Open questions"
entry "Cascading evaluation order"). Builds on D3 (#152, merged) — the param-detection,
state.varValues, per-tile debounce/generation-guard, and unfilled-param gating this phasereuses wholesale, just fanned out to a second class of query (option-queries) alongside tiles.
Already decided (from #149 §6 — not reopened here)
filter: <param>(orfilter[]: <param>for multiselect) supplies that param's options — matched by name, not anew field on the saved-query object.
value(bound/injected) +label(displayed) — e.g. store an airport code, show its name.DISTINCT,ORDER BY) — we deliberately do not introspect the schema to auto-derive options,since a tile may join arbitrary tables with no single reliable column to
DISTINCT.filters:bundle (one query, many params) stays deferred — per-param queries remain theonly supported shape; revisit only if a real dashboard accumulates many cheap enums worth
batching.
Prior art — what we take from Grafana, and what we deliberately don't
Grafana's variable system is the closest existing design for "curated, cascading dashboard
filters," and its evaluation model is worth reusing directly:
in Grafana's docs) — a variable whose query references another variable is evaluated only
after that other variable has a value.
variable that depends on it and, if the previously-selected value is no longer in the
refreshed option list, resets it (cascading further if that variable has its own dependents).
What we do not take from Grafana: its
$variable/macro text-substitution layer(
$conditionalTest,$__timeFilter, raw$varinterpolation into SQL strings). That exists inGrafana only because its variables are untyped string substitution with no query-parameter
protocol underneath. We already have a type-safe, injection-safe substitution mechanism
(ClickHouse-native
{name:Type}, #134) — afilter:/filter[]:query is just an ordinary savedSELECT, and cascading dependency is just "this SQL happens to reference another{param:Type}"— detected with the exact same
detectParams/readStatementParamsthis codebase alreadyhas. No macro syntax, no SQL string-rewriting, nothing new to parse.
Scope
src/core/dashboard.js— new pure functions (100%-covered), building directly on D3'sdashboardParams:src/net/ch-client.js— a thinqueryFilterOptions(ctx, sql, signal, params)wrapperalongside D3's
queryDashboardTile(samereadonly:2, samequeryJsoncore) — option-queriesare read-only tile-shaped requests, nothing new at the transport layer.
src/ui/dashboard.js— the new behavior:orderfromorderCuratedFilters. Root filters (nodependsOn) fire immediately and in parallel(reuse the existing bounded-concurrency fan-out already used for tile fetches — one shared
limiter, so N curated filters + M tiles never exceed the current concurrency ceiling
combined). A dependent filter waits until every param in its
dependsOnhas a value; whilewaiting, its control renders disabled with a "depends on:
year" hint — the same visuallanguage as D3's per-tile "enter a value for…" placeholder.
serialization of the subset of
varValuesit depends on ({}for a root filter — i.e. fetchonce, never again until Refresh). Navigating back to a previously-seen parent value is a
cache hit, no network call. Refresh clears the cache map entirely and re-fetches
everything from scratch — identical semantics to how Refresh already re-runs every tile.
dependsOnincludes it (its direct children in the DAG),with a cache-bypassing fetch;
option list, clear it — which recursively repeats this step for the child's own children
(grandchildren re-run too) and also triggers the tile re-run for the now-empty param;
an option-list refresh alone — only an actual value change propagates further).
Reuses D3's per-slot generation counter (extended to curated-filter slots, not just tiles) so
a stale in-flight option-query response can never overwrite a newer one.
orderCuratedFilters'scyclesrenders as a plain text input (D3's existing fallback control) with a small warning
affordance — the dashboard as a whole still works, only that one filter loses curation.
filter[]:renders a native<select multiple>bound to{param:Array(T)}(paramArgsalready threads array values through as ClickHouse expects —no new encoding). A richer checklist-popover control is a future polish, not required for v1.
"Select none"/"All" is just "no value" — the predicate is omitted, identical to a text
filter's empty-string semantics from D3; no
(All)sentinel option is added to the list.Tests —
tests/unit/dashboard.test.js:detectCuratedFilters(name matching, both prefixes,dedup,
dependsOnextraction);orderCuratedFilters(linear chain, diamond dependency, a2-cycle reported and excluded, a filter depending on a non-curated/undetected param treated as a
root with a permanently-unfilled dependency — same "enter a value" gating as a normal param).
UI-layer (
src/ui/dashboard.js, integration-tested): parallel root fan-out fires together;dependent waits then fires once its dependency is filled; changing a parent re-fetches only
direct children, invalidates a now-invalid child selection, and cascades to grandchildren;
Refresh busts the cache; a superseded option-query response is discarded (generation guard);
cache hit skips the network call on a revisited parent value.
Design decisions
readStatementParamsverbatim — a curated filter's SQL isparsed exactly like tile SQL; "cascading" is not a new concept, just "this query happens to
reference another dashboard param."
plain text input rather than blocking the whole dashboard from rendering.
one) — avoids doubling worst-case concurrent-request pressure on the cluster when a dashboard
has both many tiles and many curated filters.
no background polling (consistent with Feature: open favorited Library queries as an interactive Dashboard #149's pinned "manual Refresh only in v1").
Acceptance criteria
filter: yearupgrades theyearfilter from D3's text input to adropdown populated by that query's results; 1-column results use the value as its own
label, 2-column results show
labelwhile bindingvalue.filter[]: carrierrenders a multiselect; selecting 0 values omitsthe predicate entirely (same as an empty text filter), selecting N values binds
{carrier:Array(T)}.on dashboard load, sharing the existing tile-fetch concurrency limiter.
filter: originselectingWHERE {year:UInt16} = ...) renders disabled untilyearhas a value, then fetches.yearre-fetchesorigin's option list; if the previously-selected origin is nolonger present, it's cleared (and any tile/filter depending on
originreactsaccordingly); if it's still present, it stays selected and nothing downstream re-runs.
evaluation, and rendered as a plain text input — the rest of the dashboard is unaffected.
clears all curated-filter caches and re-fetches everything.
src/core/dashboard.jsfunctions at 100%;src/net/ch-client.jsat 100%;
src/ui/dashboard.jsat its existing UI-layer gate; no new runtime dependency.Non-goals
A richer checklist/popover multiselect control (native
<select multiple>is the v1 UI); thefilters:single-query-many-params bundle (#149, deferred); server-side/persisted caching(client-side, cleared on tab close, same as D3); auto-refresh/polling of option lists; per-tile
Type/X/Y overrides, expand modal, drill-down (D7, #149); export (D8, #149).
Tracking
Phase D6 of #149. Depends on D3 (#152, merged) for the param-detection/
varValues/generation-guard primitives this phase fans out to option-queries.