Skip to content

**Phase D9 — table tiles + logs view**: saved view:'table' + non-chartable fallback render as table/logs tiles, server row cap for all tiles #164

Description

@BorisTyshkevich

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).

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)

  1. 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.
  2. Logs mode ships in the same phase — log-shaped fallback results get a compact
    presentation (level colors, monospace, wrapped message).
  3. 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).
  4. 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_TILE_BYTE_CAP = 50_000_000max_result_bytes guard for wide log rows, best-effort.
  • 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(...)):

Part Required Names Stripped-type check
time yes any column `/^DateTime(64)?((
message yes message, msg, body, log, line `/^(String
level no (colors only when present) level, severity, log_level, loglevel, severity_text, severitytext `/^(String

Covers system.text_log, OTel (Timestamp/SeverityText/Bodyseveritytext 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:

  1. 0 rows → {kind:'skip', reason:'empty'} (unchanged)
  2. 1 row → {kind:'skip', reason:'kpi'} (unchanged, D5's territory)
  3. savedView === 'table'{kind:'table', mode:'grid'} (explicit choice; no logs heuristic;
    other/garbage view values treated as unset)
  4. valid saved chart cfg{kind:'chart', cfg} (explicit user intent)
  5. detectLogsView(columns){kind:'table', mode:'logs', shape} (specific heuristic beats
    generic — a wrong guess is recoverable by saving a chart cfg or view:'table')
  6. autoChart{kind:'chart', cfg} else {kind:'table', mode:'grid'}
    skip/nonChartable disappears entirely

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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions