Skip to content

feat(#307): render ClickHouse FORMAT PNG results in Workbench and Dashboard#319

Open
BorisTyshkevich wants to merge 9 commits into
mainfrom
feat/png-image-results-307
Open

feat(#307): render ClickHouse FORMAT PNG results in Workbench and Dashboard#319
BorisTyshkevich wants to merge 9 commits into
mainfrom
feat/png-image-results-307

Conversation

@BorisTyshkevich

@BorisTyshkevich BorisTyshkevich commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

What & why

Closes #307. Closes #318 (the review-pass dashboard disposer implements it).

ClickHouse 26.6's FORMAT PNG output now renders as a real image in both the Workbench and Dashboard, over a genuinely binary transport — the old raw path decoded every non-streaming response with Response.text(), which corrupts PNG bytes.

  • Transport (src/net/ch-client.ts + src/core/format.ts): isBinaryFormat() formats are read as arrayBuffer() into RunQueryResult.binary (Uint8Array, content type normalized to image/png, never base64). Text raw formats, streaming Table/KPI/Filter, error parsing (wait_end_of_query=1 → non-2xx), and cancellation are unchanged.
  • Validation (src/core/png.ts, new, pure, 100%): signature + IHDR + limits (8192×8192, 32M pixels, 64 MiB) run in executeRead before any object URL can exist; failures surface as a normal result error.
  • Workbench: a locked "Image (PNG)" result view — centered, scrollable, aspect-preserving, accessible name from the tab/saved query (fallback PNG query result) — with exact-bytes Download PNG; Copy is disabled. Blob URLs come from a new injected createObjectUrl/revokeObjectUrl env seam, cached per payload, and revoked on rerun replacement, tab close, result overwrite (including the schema-lineage view), and format-error overwrite. History records rows: null; image bytes are never persisted anywhere.
  • Dashboard: new schema-validated image panel type (fit contain/cover/actual, background theme/transparent/checkerboard, alt; regenerated via the schema pipeline). panelExecution grows an image arm that requires an authored FORMAT PNG (rejecting missing or non-PNG formats); the viewer-session blanket FORMAT rejection now exempts only image panels — every other panel/role keeps rejecting authored formats, with explicit tests for line/table + FORMAT PNG. Tiles render the image filling the body, repaint on image identity (resize never re-executes), and revoke the previous URL through the shared destroy slot in both layout engines. Refresh uses the standard loading state (owner decision: the issue's keep-last-image wording described behavior no tile type has; matching current tiles was chosen — decisions pinned in the issue comment).
  • Demos (examples/image-panels.json + README): spiral, RGB gradient, RGBA checkerboard glow, and an original ray-traced-sphere homage crediting ClickHouse's RayTracer demos, all small-by-default with a CPU-cost warning — validated live against a ClickHouse 26.6.1 server. Requires ClickHouse 26.6+ (older servers show their normal error).
  • e2e (tests/e2e/image-result.*): real-browser proof that a PNG payload renders as a visible <img> behind the locked tab, downloads exact bytes, and letterboxes (pixel-sampled, not CSS-box-checked) in 3:1 and 1:3 dashboard tiles — chromium + webkit green (firefox can't launch in this sandbox, known environmental).

Out-of-scope finding filed as inbox #318 (pre-existing: dashboard tile removal skips renderer cleanup — chart instances leak too).

Merge-gate riders (owner-requested, from live testing): favorite/import tile-membership fix (pre-existing Phase-8 regression), uncapped binary formats (large PNGs no longer truncated by the ROWS selector), auto-inferred Image panel for unconfigured FORMAT PNG queries, RayTracer README link, and an unrelated owner change riding along: build/local.py <ch-auth>basic</ch-auth> support for ch-jwt-verify OAuth connections.

Checklist

  • npm test passes (the per-file coverage gate is non-negotiable)
  • Tests added/updated in the same change as the code
  • npm run build succeeds (single-file dist/sql.html)
  • Layers kept honest: pure logic in src/core/, network in src/net/ (injected fetch), DOM in src/ui/
  • No new runtime dependency (or it's a deliberate, justified addition — see CONTRIBUTING)
  • README / CHANGELOG.md ([Unreleased]) updated if behavior or the deployed surface changed
  • Reconciled affected tracked work (roadmap Roadmap to 1.0.0 #68, the issue body, ADR/CHANGELOG) if this change reshaped it — decisions pinned on Render ClickHouse FORMAT PNG results in Workbench and Dashboard #307; Roadmap to 1.0.0 #68 untouched by owner choice

🤖 Generated with Claude Code

https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB

BorisTyshkevich and others added 8 commits July 19, 2026 10:33
runQuery gains a binary raw-mode branch (arrayBuffer, normalized
image/png content type) for isBinaryFormat() formats; StreamResult
carries a validated ImageResultPayload; executeRead validates PNG
signature/IHDR/limits via the new pure src/core/png.ts before any
bytes reach the UI. PNG added to formatFileMeta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB
Workbench: image results render as a centered, scrollable <img> behind a
new injected createObjectUrl/revokeObjectUrl env seam, with a locked
'Image (PNG)' toolbar tab, Copy disabled, and exact-bytes Download PNG.
Object URLs are cached per payload (WeakMap) and revoked on rerun
replacement, tab close, and format-error overwrite; history records
rows:null for image results.

Dashboard: new schema-validated 'image' panel type (fit/background/alt)
regenerated through the schema pipeline; panelExecution image arm
requires authored FORMAT PNG and owns format='PNG'; the viewer-session
blanket FORMAT rejection now exempts only image panels; tiles render
the image in the full body with object-fit modes + checkerboard
background, repaint on image identity (resize never re-executes), and
revoke the previous URL via the shared destroy slot in both engines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB
examples/image-panels.json ships four FORMAT PNG demos (spiral, RGB
gradient, RGBA checkerboard glow, original ray-traced sphere credited
to ClickHouse's RayTracer demos) — all validated live against
ClickHouse 26.6. New e2e fixture proves the Workbench Image view and
Dashboard tile aspect-preserving fit with real pixel sampling in
chromium+webkit. README notes the 26.6+ requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB
…acks, gate tests + CHANGELOG

The schema-lineage view now revokes a displayed image result's blob URL
before replacing tab.result (new revokeResultImage hook, mirroring the
workbench-session pattern). The Panel-drawer/readonly image arms thread
the active tab name into alt with a 'PNG query result' final fallback.
Dashboard blanket-gate rejection now explicitly tested for line and
table panel types with authored FORMAT PNG. CHANGELOG [Unreleased]
documents the feature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB
…capped binary formats

Starring a panel-role query with no dashboard document now creates the
document (shared createEmptyDashboardDocument helper) instead of
silently doing nothing, and Import queries runs the new additive-only
syncFavoriteTileMembership so imported favorited panel-role queries get
tiles — the documented kpi-panel.json/image-panels.json import flows
show tiles again (pre-existing Phase-8 regression exposed by the #307
demos). runQuery no longer sends max_result_rows/result_overflow_mode
for binary formats: the source-row cap was truncating large PNGs to
their first rows; PNG dimension/byte limits remain the guards.

Both verified live against ClickHouse 26.6: image-panels import yields
4 rendering tiles, and the 1024x1024 spiral repro renders full-size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB
…eries

A panel-role tile whose query ends in FORMAT PNG but has no explicit
panel cfg is now treated as an Image panel (shouldInferImagePanel, pure
core helper) instead of hitting the blanket 'remove the explicit FORMAT
clause' rejection — starring a PNG query just works. Explicitly-typed
non-image panels with FORMAT PNG stay rejected, now with a guided
message naming the Image panel type. Owner decision at the merge gate;
verified live (save -> star -> dashboard tile renders the PNG).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB
…-auth> tag

build/local.py only ever emitted the default bearer auth for OAuth
connections from ~/.clickhouse-client/config.xml, so the ch-jwt-verify
path (stock/OSS ClickHouse behind a Basic-password JWT verifier) could
not be exercised locally — the browser always sent Authorization:
Bearer, which such a server rejects. A <ch-auth>basic</ch-auth> tag on
the connection now mirrors install.sh --ch-auth basic into the
generated config.json IdP entry. Docs + README updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB

@BorisTyshkevich BorisTyshkevich left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core binary-result and Image-panel design is appropriate, and CI is green, but the following issues should be fixed before merge:

  1. [P1] Enforce MAX_PNG_BYTES while reading the response. src/net/ch-client.ts currently calls response.arrayBuffer() before validatePng() applies the 64 MiB limit. An oversized response can therefore allocate far beyond the advertised cap and freeze or terminate the tab. Read the body incrementally, reject an oversized Content-Length immediately when present, cancel the reader after the cap is crossed, and only assemble an in-bounds final Uint8Array.

  2. [P2] Complete Dashboard renderer disposal. Image cleanup uses the shared renderer destroy slot, but removed tiles do not call it, and a same-window renderDashboard() reload disposes only the old resize listener. The previous signal effect, viewer session, tile renderers, charts, and blob URLs remain undisposed. Add a route-level disposer that disposes the effect/session, destroys every retained renderer, removes the listener, and calls renderer cleanup before pruning removed tile IDs. This includes the behavior currently filed as #318.

  3. [P2] Revoke image URLs before script export replaces tab.result. Normal run, schema graph, format-error, and tab-close paths dispose image results, but ExportService.exportScript() directly assigns scriptExportResult. Add a result-disposal hook to ExportServiceDeps and invoke it before this replacement, with a regression test starting from a rendered image result.

  4. [P2] Increment Dashboard revision when query import adds tiles. planImportQueries() can now mutate an existing Dashboard through syncFavoriteTileMembership(), but the candidate preserves the previous revision. A successful persisted Dashboard mutation must increment revision exactly once. Keep a newly created Dashboard at revision 1; increment an existing Dashboard only when synchronization actually changes it; preserve revision for a no-op import.

  5. [P2] Do not accept a header-only/truncated PNG as valid. validatePng() currently accepts a 29-byte signature+IHDR prefix with no IHDR CRC, image data, or IEND. Require IHDR length exactly 13, ensure the complete IHDR data and CRC are in bounds, and require a structurally complete terminal IEND. Also handle <img> decode failure by showing a result error and revoking the URL.

  6. [P2] Preserve useful ClickHouse errors returned with HTTP 2xx. Every successful binary response is treated as PNG bytes. When validation fails, the user receives only a structural PNG error, even if the body is ClickHouse exception text. Inspect a bounded textual prefix after validation failure and use parseExceptionText() when it resembles a ClickHouse error; otherwise retain the PNG validation message.

These are merge-blocking from this review. The transport allocation issue is the highest priority; the object-URL lifecycle and Dashboard revision invariant should also be resolved in this PR.

…ull dashboard disposal

P1: runQuery's binary branch enforces MAX_PNG_BYTES during transport —
oversized Content-Length rejects without reading, chunked reads cancel
the reader the moment the cap is crossed, and only an in-bounds payload
is assembled (single allocation). P2s: validatePng requires IHDR length
exactly 13 with in-bounds CRC and a structurally complete terminal
IEND; a 2xx body that fails PNG validation but resembles a ClickHouse
exception surfaces via parseExceptionText instead of a structural
error; renderDashboard gains a route-level disposer (effect, viewer
session, every retained tile renderer, resize listener) and removed
tiles destroy their renderer before pruning (implements #318, closes
with the PR); exportScript revokes a displayed image result's URL via a
new ExportHooks.revokeResultImage before replacing tab.result; <img>
decode failures show a result/tile error, revoke the URL, and clear the
cache so re-renders mint fresh URLs; query-import tile sync bumps an
existing Dashboard's revision exactly once when it actually changes
(new doc stays revision 1, no-op imports keep the reference).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB
@BorisTyshkevich

Copy link
Copy Markdown
Collaborator Author

All six review items addressed in f6c1f32:

  1. [P1] MAX_PNG_BYTES enforced during the readrunQuery's binary branch now uses a bounded readBinaryBody: an over-cap Content-Length rejects immediately without reading (body cancelled); otherwise the body is read incrementally via getReader(), the reader is cancelled the instant the running count crosses the cap, and only an in-bounds payload is assembled into one final Uint8Array. Abort mid-read still rejects with AbortError unchanged. Covered: header-only rejection (body never read), chunked over-cap (reader cancelled), in-bounds assembly, abort, and the no-resp.body fallback with a post-hoc check.

  2. [P2] Complete Dashboard disposal (implements Dashboard tile removal skips renderer cleanup (chart instances / image object URLs leak) #318) — removed tiles now invoke their renderer destroy (chart instance / image URL) before being pruned, and renderDashboard keeps a route-level disposer invoked on same-window re-entry: it disposes the signals effect, calls session.destroy(), destroys every retained tile renderer, and removes the resize listener. Closes #318 added to the PR body.

  3. [P2] Script-export revocation — new ExportHooks.revokeResultImage, invoked in exportScript() immediately before tab.result is replaced, wired to revokeResultImageUrl in app.ts; regression test starts from a rendered image result.

  4. [P2] Import revision invariantplanImportQueries bumps an existing Dashboard's revision exactly once when syncFavoriteTileMembership actually changed it; a newly created Dashboard stays at revision 1 (matching createEmptyDashboardDocument/legacy-migration convention); a no-op import preserves both the revision and the object reference. The bump lives in the plan layer, mirroring the authoring-session convention (workspace-repository.commit() never bumps).

  5. [P2] Strict PNG structure + decode-failure handlingvalidatePng now requires IHDR length exactly 13, the full IHDR data + CRC in bounds, and a structurally complete terminal IEND (zero length + type + constant CRC); the old 29-byte header-only fixture is rejected and all test fixtures were upgraded to structurally complete PNGs. <img> decode failure (onerror) in both the Workbench view and the Dashboard tile now shows the normal error presentation, revokes the URL, and clears the cache so a re-render mints a fresh URL.

  6. [P2] ClickHouse errors with HTTP 2xx preserved — on PNG validation failure, a bounded 4096-byte prefix is decoded and checked with a new pure looksLikeChException (DB::Exception, leading Code: <n>, or {"exception":…} line); matches surface via parseExceptionText, everything else keeps the structural PNG message.

Gate after all fixes: 147 files / 3800 unit tests green with per-file coverage held, tsc --noEmit clean, build ok, full Playwright suite 130/130 on chromium+webkit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant