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
Give favorited Library queries that aren't chart-shaped a real tile instead of being skipped.
Today classifyTile (src/core/dashboard.js) skips multi-row results it can't chart
(reason:'nonChartable'); this phase turns those into table tiles, adds a logs mode
(Grafana-style compact log presentation) as a specialization of the table kind, and adds result caps for every dashboard tile (chart included — tiles previously fetched uncapped).
Phase D9 of the Dashboard epic (#149). #149 pinned "table tiles are out of v1 … a future phase
with its own small design" (pinned decision 3) — this is that design. Primary use case: logs
panels, plus any tabular favorite (e.g. SELECT name, engine FROM system.tables).
2026-07-10 revision — body reconciled to the final design pinned with the user (plan
session of 2026-07-10) and its implementation. Notable changes from the first draft: caps are two-tier (best-effort server hint + guaranteed client trim, row cap 5000 not 1000, plus a
byte cap and a separate display cap); an explicit saved view:'table'always means plain
grid (the earlier "alias the message column" escape hatch is superseded); logs is {kind:'table', mode:'logs'}, not its own kind.
2026-07-10 review amendment — the heuristic ranking flipped after code review: the
log-shape signal now outranks autoChart (but never an explicit saved chart cfg or view:'table'). Without this, any numeric column (thread_id, OTel SeverityNumber) made autoChart win first, so a SELECT * over a real log table rendered as a meaningless line
chart and the logs view was unreachable. Explicit intent still always wins; the heuristics
only rank against each other, specific-before-generic.
Decisions pinned (2026-07-10, with the user — not reopened here)
Saved view wins + table fallback. A favorite saved with view:'table' (already
persisted, src/state.js; validated on import by cleanView) renders as a plain grid
tile even when chartable. Multi-row non-chartable results become table tiles instead of being
skipped; logs detection applies only to that fallback bucket — an explicit view:'table'
always means plain grid (no heuristic override). Forcing logs mode explicitly arrives as a {type:'logs'}chart-config type (Panels: visualization registry + Panel drawer tab + Library panel field #166) rather than a new view value. Single-row
(future KPI, D5) and empty results still skip.
Logs mode ships in the same phase — log-shaped fallback results get a compact
presentation (level colors, monospace, wrapped message).
Result caps for ALL dashboard tiles: a best-effort application-requested cap via URL
settings plus a guaranteed client-side trim. Under readonly=2 a query-level SETTINGS max_result_rows=0 can override the URL settings — documented as best-effort, not a
security/resource boundary. The deployment-level option for a real boundary is a server-side settings constraint/profile (out of SPA scope).
Interactivity: sort + column-resize + internal scroll via the app-state-free renderGrid (src/ui/results.js); cell-click detail and expand modal stay in D7. The logs
view is scroll-only by design (no sorting/resize — query order verbatim).
Design
Caps — two tiers, honest truncation detection. Constants in src/core/dashboard.js:
DASH_TILE_ROW_CAP = 5000 — rows kept per tile; preserves the 5000-point line/area chart cap
(CHART_ROW_CAPS, src/core/chart-data.js) so charts don't regress.
DASH_TABLE_DISPLAY_CAP = 1000 — rows rendered by the grid/logs views.
Server (best-effort): queryDashboardTile sends max_result_rows = DASH_TILE_ROW_CAP + 1
(the +1 is the truncation sentinel), max_result_bytes, and result_overflow_mode:'break' ('break' overshoots at block boundaries; the client trim absorbs
it). Client (guaranteed): parseJsonResult(json, cap) trims to the cap and sets meta.truncated = data.length > cap; meta.rows is redefined as rows shown — after block
overshoot the response's json.rows is neither the full count nor the displayed count, so it is
no longer exposed. Display: charts keep CHART_ROW_CAPS unchanged; grid and logs render DASH_TABLE_DISPLAY_CAP rows with the shared in-body "+N more rows truncated for display"
footer (reachable: up to 5000 fetched > 1000 displayed). Tile footer on meta.truncated: "first 5,000 rows fetched — sorting/charts cover this prefix only" — honest that client-side
sort and chart aggregation cover a prefix of the underlying result (ties into #111: chart
totals over a capped tile must not be read as full-result totals).
Logs detection convention (pure heuristic — never applied over an explicit view:'table'
or a valid saved chart cfg, but ranked ahead of the autoChart heuristic, see the review
amendment above). A result qualifies as logs when both required parts match
(first matching column by position; names case-insensitive; types checked after stripping
wrappers via the exported chartStripType — handles Nullable(LowCardinality(...)) and
parameterized DateTime64(9, 'UTC') / FixedString(64) / Enum8(...)):
Covers system.text_log, OTel (Timestamp/SeverityText/Body — severitytext is that
CamelCase name lowercased), typical app log tables. Remaining columns render as dimmed key=value extras after the message; object/array extra values (OTel map attributes) get
compact JSON.stringify, truncated to 80 chars.
classifyTile(columns, rows, savedChart, savedView) evolution — precedence, each step exits:
savedView === 'table' → {kind:'table', mode:'grid'} (explicit choice; no logs heuristic;
other/garbage view values treated as unset)
valid saved chart cfg → {kind:'chart', cfg} (explicit user intent)
detectLogsView(columns) → {kind:'table', mode:'logs', shape} (specific heuristic beats
generic — a wrong guess is recoverable by saving a chart cfg or view:'table')
Logs is a specialization of the table kind (mode discriminates). Skip-note copy
(src/ui/dashboard.js) mentions only empty/KPI.
New modules: src/core/logs.js (pure, 100%: detectLogsView, logLevelClass with the full
alias table incl. ClickHouse's 'Test' → trace, formatLogTime, logRowDisplay) and src/ui/logs.js (100%: renderLogs({columns, rows, shape, cap}) → .dash-logs, one div.log-row.log-<levelClass> per row with span.log-time / span.log-level (omitted when the
shape has no level) / span.log-msg / dimmed span.log-extras).
Dashboard tile branches (applyTileResult): a table classification shows the card and
renders either renderLogs or renderGrid. Grid sort/width state persists on the stable slot
across refreshes and filter re-runs, keyed by schemaKey(columns) — a schema change resets
it, a re-run keeps it; header-click sorts re-paint locally, no re-query. slot.destroy
stays null (destroySlotChart handles chart→table flips).
CSS (src/styles.css): tile-internal scrolling for .res-table-wrap/.dash-logs
(flex/min-size constraints); .dash-logs monospace 11px scroll box; per-level border+level
colors via --log-fatal/error/warn/info/debug/trace vars in both theme blocks.
Best-effort cap semantics (documented, deliberate)
Tile queries run with readonly=2, which still permits query-level SETTINGS — so a favorite
containing SETTINGS max_result_rows = 0 overrides the URL-level caps and ClickHouse buffers /
returns the full result (queryJson reads the whole body). The client trim then still bounds
what the SPA keeps and renders at 5,000 rows, and max_result_bytes mitigates wide-row blowups,
but the caps are not a resource boundary. Deployments that need a hard boundary should apply
a server-side settings constraint/profile for the dashboard's user/role — out of SPA scope.
Tests (same change; per-file gate)
tests/unit/logs.test.js (detection positives/negatives incl. wrapped/parameterized types,
alias table, extras incl. object→JSON, renderLogs DOM incl. no-level + cap footer); tests/unit/dashboard.test.js (classifyTile precedence incl. view:'table' forcing grid on
chartable AND log-shaped results, fallback grid/logs, parseJsonResult cap/sentinel/meta.rows,
UI: local sort with no re-query, sort/width persistence vs schema-change reset, truncation note
in both chart and table branches, skip note counts only empty/KPI, table→skip DOM clearing, app.runTile trim); tests/unit/ch-client.test.js (tile URL carries max_result_rows=5001, max_result_bytes=50000000, result_overflow_mode=break, readonly=2).
Manual verification (demo cluster)
Star (a) a chartable query saved while viewing Table → plain grid; (b) SELECT name, engine FROM system.tables LIMIT 50 → fallback grid; (c) SELECT event_time, level, message FROM system.text_log ORDER BY event_time DESC LIMIT 20000 → logs view + "first 5,000 rows fetched"
note + in-body "+N more" at 1,000; (d) cap-override probe: SELECT number FROM numbers(100000) SETTINGS max_result_rows = 0 → confirms best-effort semantics (client trim
still bounds display at 5,000); and a line chart over ~3,000 time-series points still renders
all points (no chart-cap regression). Check sort/resize persistence across Refresh and filter
edits, level colors in both themes.
Risks
Best-effort server cap: a hostile/careless favorite can still pull a huge response into
memory before the client trim (queryJson buffers fully); max_result_bytes mitigates,
server-side profile constraint is the real boundary (documented above, out of SPA scope).
Byte-cap breaks are not directly detectable client-side (review finding, accepted
2026-07-10): only the max_result_rows +1 sentinel drives meta.truncated. Accepted
because overflow checks fire at block granularity — a max_result_bytes break normally
delivers at least one full block (default max_block_size ≈ 65k rows), which trips the
5,001-row sentinel anyway. Residual silent band: very wide rows (~10KB+ avg), where preferred_block_size_bytes adaptively shrinks blocks below the row cap — within
best-effort-by-design.
Legacy view:'table' stamping (review finding, accepted 2026-07-10): view records
the result tab open at save time (default Table), so a favorite whose chart was configured
but saved from the Table tab renders as a plain grid. Accepted — consistent with the
workbench restore behavior, repairable by re-saving from the Chart tab, and properly
resolved by Panels: visualization registry + Panel drawer tab + Library panel field #166's explicit type picker.
Non-goals
Cell-click detail and expand modal (D7); KPI tiles (D5); curated filters (D6); export (D8);
sorting/resize/interactivity inside the logs view (scroll-only by design); explicitly forcing
logs mode (#166, as a {type:'logs'} chart-cfg type); server-side settings profiles
(deployment concern).
Tracking
Phase D9 of #149. Builds on D3 (#152, merged) for the tile-fetch/query plumbing the caps slot
into; independent of D4-D8.
Goal
Give favorited Library queries that aren't chart-shaped a real tile instead of being skipped.
Today
classifyTile(src/core/dashboard.js) skips multi-row results it can't chart(
reason:'nonChartable'); this phase turns those into table tiles, adds a logs mode(Grafana-style compact log presentation) as a specialization of the table kind, and adds
result caps for every dashboard tile (chart included — tiles previously fetched uncapped).
Phase D9 of the Dashboard epic (#149). #149 pinned "table tiles are out of v1 … a future phase
with its own small design" (pinned decision 3) — this is that design. Primary use case: logs
panels, plus any tabular favorite (e.g.
SELECT name, engine FROM system.tables).Decisions pinned (2026-07-10, with the user — not reopened here)
view:'table'(alreadypersisted,
src/state.js; validated on import bycleanView) renders as a plain gridtile even when chartable. Multi-row non-chartable results become table tiles instead of being
skipped; logs detection applies only to that fallback bucket — an explicit
view:'table'always means plain grid (no heuristic override). Forcing logs mode explicitly arrives as a
{type:'logs'}chart-config type (Panels: visualization registry + Panel drawer tab + Library panel field #166) rather than a newviewvalue. Single-row(future KPI, D5) and empty results still skip.
presentation (level colors, monospace, wrapped message).
settings plus a guaranteed client-side trim. Under
readonly=2a query-levelSETTINGS max_result_rows=0can override the URL settings — documented as best-effort, not asecurity/resource boundary. The deployment-level option for a real boundary is a
server-side settings constraint/profile (out of SPA scope).
renderGrid(src/ui/results.js); cell-click detail and expand modal stay in D7. The logsview is scroll-only by design (no sorting/resize — query order verbatim).
Design
Caps — two tiers, honest truncation detection. Constants in
src/core/dashboard.js:DASH_TILE_ROW_CAP = 5000— rows kept per tile; preserves the 5000-point line/area chart cap(
CHART_ROW_CAPS,src/core/chart-data.js) so charts don't regress.DASH_TILE_BYTE_CAP = 50_000_000—max_result_bytesguard for wide log rows, best-effort.DASH_TABLE_DISPLAY_CAP = 1000— rows rendered by the grid/logs views.Server (best-effort):
queryDashboardTilesendsmax_result_rows = DASH_TILE_ROW_CAP + 1(the
+1is the truncation sentinel),max_result_bytes, andresult_overflow_mode:'break'('break' overshoots at block boundaries; the client trim absorbsit). Client (guaranteed):
parseJsonResult(json, cap)trims to the cap and setsmeta.truncated = data.length > cap;meta.rowsis redefined as rows shown — after blockovershoot the response's
json.rowsis neither the full count nor the displayed count, so it isno longer exposed. Display: charts keep
CHART_ROW_CAPSunchanged; grid and logs renderDASH_TABLE_DISPLAY_CAProws with the shared in-body "+N more rows truncated for display"footer (reachable: up to 5000 fetched > 1000 displayed). Tile footer on
meta.truncated:"first 5,000 rows fetched — sorting/charts cover this prefix only" — honest that client-side
sort and chart aggregation cover a prefix of the underlying result (ties into #111: chart
totals over a capped tile must not be read as full-result totals).
Logs detection convention (pure heuristic — never applied over an explicit
view:'table'or a valid saved chart cfg, but ranked ahead of the
autoChartheuristic, see the reviewamendment above). A result qualifies as logs when both required parts match
(first matching column by position; names case-insensitive; types checked after stripping
wrappers via the exported
chartStripType— handlesNullable(LowCardinality(...))andparameterized
DateTime64(9, 'UTC')/FixedString(64)/Enum8(...)):message,msg,body,log,linelevel,severity,log_level,loglevel,severity_text,severitytextCovers
system.text_log, OTel (Timestamp/SeverityText/Body—severitytextis thatCamelCase name lowercased), typical app log tables. Remaining columns render as dimmed
key=valueextras after the message; object/array extra values (OTel map attributes) getcompact
JSON.stringify, truncated to 80 chars.classifyTile(columns, rows, savedChart, savedView)evolution — precedence, each step exits:{kind:'skip', reason:'empty'}(unchanged){kind:'skip', reason:'kpi'}(unchanged, D5's territory)savedView === 'table'→{kind:'table', mode:'grid'}(explicit choice; no logs heuristic;other/garbage view values treated as unset)
{kind:'chart', cfg}(explicit user intent)detectLogsView(columns)→{kind:'table', mode:'logs', shape}(specific heuristic beatsgeneric — a wrong guess is recoverable by saving a chart cfg or
view:'table')autoChart→{kind:'chart', cfg}else{kind:'table', mode:'grid'}—skip/nonChartabledisappears entirelyLogs is a specialization of the table kind (
modediscriminates). Skip-note copy(
src/ui/dashboard.js) mentions only empty/KPI.New modules:
src/core/logs.js(pure, 100%:detectLogsView,logLevelClasswith the fullalias table incl. ClickHouse's
'Test'→ trace,formatLogTime,logRowDisplay) andsrc/ui/logs.js(100%:renderLogs({columns, rows, shape, cap})→.dash-logs, onediv.log-row.log-<levelClass>per row withspan.log-time/span.log-level(omitted when theshape has no level) /
span.log-msg/ dimmedspan.log-extras).Dashboard tile branches (
applyTileResult): atableclassification shows the card andrenders either
renderLogsorrenderGrid. Grid sort/width state persists on the stable slotacross refreshes and filter re-runs, keyed by
schemaKey(columns)— a schema change resetsit, a re-run keeps it; header-click sorts re-paint locally, no re-query.
slot.destroystays null (
destroySlotCharthandles chart→table flips).CSS (
src/styles.css): tile-internal scrolling for.res-table-wrap/.dash-logs(flex/min-size constraints);
.dash-logsmonospace 11px scroll box; per-level border+levelcolors via
--log-fatal/error/warn/info/debug/tracevars in both theme blocks.Best-effort cap semantics (documented, deliberate)
Tile queries run with
readonly=2, which still permits query-levelSETTINGS— so a favoritecontaining
SETTINGS max_result_rows = 0overrides the URL-level caps and ClickHouse buffers /returns the full result (
queryJsonreads the whole body). The client trim then still boundswhat the SPA keeps and renders at 5,000 rows, and
max_result_bytesmitigates wide-row blowups,but the caps are not a resource boundary. Deployments that need a hard boundary should apply
a server-side settings constraint/profile for the dashboard's user/role — out of SPA scope.
Tests (same change; per-file gate)
tests/unit/logs.test.js(detection positives/negatives incl. wrapped/parameterized types,alias table, extras incl. object→JSON, renderLogs DOM incl. no-level + cap footer);
tests/unit/dashboard.test.js(classifyTile precedence incl.view:'table'forcing grid onchartable AND log-shaped results, fallback grid/logs, parseJsonResult cap/sentinel/meta.rows,
UI: local sort with no re-query, sort/width persistence vs schema-change reset, truncation note
in both chart and table branches, skip note counts only empty/KPI, table→skip DOM clearing,
app.runTiletrim);tests/unit/ch-client.test.js(tile URL carriesmax_result_rows=5001,max_result_bytes=50000000,result_overflow_mode=break,readonly=2).Manual verification (demo cluster)
Star (a) a chartable query saved while viewing Table → plain grid; (b)
SELECT name, engine FROM system.tables LIMIT 50→ fallback grid; (c)SELECT event_time, level, message FROM system.text_log ORDER BY event_time DESC LIMIT 20000→ logs view + "first 5,000 rows fetched"note + in-body "+N more" at 1,000; (d) cap-override probe:
SELECT number FROM numbers(100000) SETTINGS max_result_rows = 0→ confirms best-effort semantics (client trimstill bounds display at 5,000); and a line chart over ~3,000 time-series points still renders
all points (no chart-cap regression). Check sort/resize persistence across Refresh and filter
edits, level colors in both themes.
Risks
memory before the client trim (
queryJsonbuffers fully);max_result_bytesmitigates,server-side profile constraint is the real boundary (documented above, out of SPA scope).
message, now also ahead of autoChart) —acceptable; explicit
view:'table'or a saved chart cfg is the clean opt-out, the{type:'logs'}chart-cfg type (Panels: visualization registry + Panel drawer tab + Library panel field #166) the future explicit opt-in.chart-correctness follow-up remains Chart aggregation sums only a truncated prefix of fetched rows, and the note overstates what the chart represents #111's scope.
2026-07-10): only the
max_result_rows+1 sentinel drivesmeta.truncated. Acceptedbecause overflow checks fire at block granularity — a
max_result_bytesbreak normallydelivers at least one full block (default
max_block_size≈ 65k rows), which trips the5,001-row sentinel anyway. Residual silent band: very wide rows (~10KB+ avg), where
preferred_block_size_bytesadaptively shrinks blocks below the row cap — withinbest-effort-by-design.
view:'table'stamping (review finding, accepted 2026-07-10):viewrecordsthe result tab open at save time (default Table), so a favorite whose chart was configured
but saved from the Table tab renders as a plain grid. Accepted — consistent with the
workbench restore behavior, repairable by re-saving from the Chart tab, and properly
resolved by Panels: visualization registry + Panel drawer tab + Library panel field #166's explicit type picker.
Non-goals
Cell-click detail and expand modal (D7); KPI tiles (D5); curated filters (D6); export (D8);
sorting/resize/interactivity inside the logs view (scroll-only by design); explicitly forcing
logs mode (#166, as a
{type:'logs'}chart-cfg type); server-side settings profiles(deployment concern).
Tracking
Phase D9 of #149. Builds on D3 (#152, merged) for the tile-fetch/query plumbing the caps slot
into; independent of D4-D8.