diff --git a/CHANGELOG.md b/CHANGELOG.md index 0594d776..d4497680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,58 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Added +- **ClickHouse 26.6 `FORMAT PNG` results render as real images** (#307). A + query with a trailing explicit `FORMAT PNG` now runs through a new binary + transport (`runQuery` reads bytes, never `Response.text()` — no base64 + anywhere) and is validated client-side (PNG signature, IHDR, 8192×8192 / + 32M-pixel / 64 MiB caps in the pure `src/core/png.ts`) before any object + URL exists. The Workbench shows a locked "Image (PNG)" result view — + centered, scrollable, aspect-preserving, alt-named from the tab/saved + query — with exact-bytes `Download PNG` (Copy is disabled); blob URLs are + revoked on rerun, tab close, and result replacement. Dashboards gain a + schema-validated `image` panel type (`fit` contain/cover/actual, + `background` theme/transparent/checkerboard, `alt`): the only tile shape + allowed to carry an authored `FORMAT` clause, and only `PNG` — every + other panel/role keeps the blanket rejection. Tiles render the image in + the full body, repaint on image identity (resize never re-executes), and + revoke the previous URL on replacement in both layout engines. Image + bytes are never persisted (saved queries, history, workspaces, bundles). + New importable demo library `examples/image-panels.json` (spiral, RGB + gradient, RGBA checkerboard glow, and an original ray-traced sphere + crediting ClickHouse's RayTracer demos) — requires ClickHouse 26.6+. + +### Fixed +- **`npm run local` can now opt an OAuth connection into `ch_auth: "basic"`.** + `build/local.py` only ever emitted the default `bearer` auth for OAuth + connections read from `~/.clickhouse-client/config.xml`, so there was no way + to locally exercise the ch-jwt-verify path (stock/OSS ClickHouse behind a + Basic-password JWT verifier, `docs/CLICKHOUSE-OSS-OAUTH.md`) — the browser + always sent `Authorization: Bearer`, which such a server rejects + (`AUTHENTICATION_FAILED`, "'Bearer' HTTP Authorization scheme is not + supported"). A `basic` tag on the connection now mirrors + `install.sh --ch-auth basic` into the generated `config.json`'s IdP entry. +- **Starring a query in a fresh workspace (and importing favorited queries) + now actually creates Dashboard tiles** (#307 merge-gate finding; pre-existing + Phase-8 regression). `toggleTileMembership` used to no-op while the + workspace's dashboard document was `null`, and **File → Import queries** + never synced favorite→tile membership — so the Dashboard's "star a query to + add it" empty-state was a dead end and README's documented `kpi-panel.json` + import flow showed nothing. Starring a panel-role query now creates the + dashboard document on demand, and `planImportQueries` runs the new + additive-only, idempotent `syncFavoriteTileMembership` so imported + favorited panel-role queries arrive with tiles. A starred query with a + trailing `FORMAT PNG` and no explicit panel type is now auto-inferred as an + Image panel (owner decision) — only explicitly-typed non-image panels keep + the FORMAT rejection, and for PNG that error now says how to fix it ("set + its panel type to Image"). +- **Large `FORMAT PNG` results are no longer truncated by the ROWS selector** + (#307 merge-gate finding). The row cap (`max_result_rows` + + `result_overflow_mode=break`) was sent for every raw-format run, so a + 1024×1024 image lost all but its first ~64 pixel rows; `runQuery` now + skips the cap for binary formats — the client-side PNG dimension/byte + limits remain the real guards. + ### Changed - **Grafana-grid KPI tiles are polished in both Dashboard modes** (#316, follow-on to #291). Edit mode keeps the full editing shell but drops the diff --git a/README.md b/README.md index d17c21f0..abb2c571 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,12 @@ layer) straight from filter inputs, and [`examples/iceberg-dba-dashboard.json`](examples/iceberg-dba-dashboard.json) (DBA, with snapshot/metadata **log panels**) explore them with one shared `catalog` filter across every tile. +The [`examples/image-panels.json`](examples/image-panels.json) library is four +Image panels (a rainbow polar spiral, an RGB gradient, a transparent RGBA glow +over a checkerboard background, and a small ray-traced shaded sphere inspired +by [ClickHouse/RayTracer](https://github.com/ClickHouse/RayTracer)) that +each author an explicit `FORMAT PNG` query, demonstrating the Image result +view and Dashboard Image panel — needs **ClickHouse 26.6+** for `FORMAT PNG`. ## How it works @@ -646,6 +652,13 @@ by default, so a stock server works. For an **OAuth** connection you also regist `http://localhost:8900/sql` as a redirect URI with the IdP. Override the serve port with `PORT` and the config path with `LOCAL_CH_CONFIG`. Ctrl-C stops it. +If that cluster is stock/OSS ClickHouse behind a +[ch-jwt-verify](https://github.com/Altinity/ch-jwt-verify) deployment rather than +an Antalya `` build, add `basic` to the +`` so the browser sends the JWT as the HTTP Basic password instead of +`Authorization: Bearer` — see +[docs/CLICKHOUSE-OSS-OAUTH.md](docs/CLICKHOUSE-OSS-OAUTH.md). + **From Docker** — the container is a static nginx server that takes an explicit `config.json` rather than reading `~/.clickhouse-client`. See [Run in Docker](#run-in-docker) above. diff --git a/build/local.py b/build/local.py index b5e46623..f5282fbf 100644 --- a/build/local.py +++ b/build/local.py @@ -13,7 +13,11 @@ • a plain connection (hostname/user/password) → prefills the credentials form. • a connection carrying clickhouse-client's OAuth keys (`oauth-url`, `oauth-client-id`, optional `oauth-client-secret` for a Web client like Google, - `oauth-audience`) → an OAuth sign-in against that cluster. + `oauth-audience`) → an OAuth sign-in against that cluster. Add + `basic` if that cluster is stock/OSS ClickHouse behind a + ch-jwt-verify deployment (docs/CLICKHOUSE-OSS-OAUTH.md) rather than a + `` build — the browser then sends the JWT as the HTTP + Basic password instead of `Authorization: Bearer`. A connection with `1` is flagged `insecure` in config.json. The browser can't skip TLS validation @@ -150,15 +154,24 @@ def collect(): oauth_client = _text(conn, "oauth-client-id", "oauth_client_id") oauth_secret = _text(conn, "oauth-client-secret", "oauth_client_secret") oauth_aud = _text(conn, "oauth-audience", "oauth_audience") + # Stock/OSS ClickHouse can't validate a Bearer JWT itself (see + # docs/CLICKHOUSE-OSS-OAUTH.md) — a ch-jwt-verify deployment instead expects + # the JWT as the HTTP Basic *password*. `--ch-auth basic` on install.sh bakes + # this into a real deploy's config.json; mirror it here so a local + # can opt into the same `ch_auth: "basic"` the browser already understands. + ch_auth = _text(conn, "ch-auth", "ch_auth").lower() if oauth_url and oauth_client: - idps_by_id.setdefault(name, { + idp = { "id": name, "label": name, "issuer": oauth_url, "client_id": oauth_client, # Optional: a Web-client secret (e.g. Google) for the code exchange. # Empty → public PKCE. clickhouse-client has no such flag, so this is # a local-only convenience key read from the same connection. "client_secret": oauth_secret, "audience": oauth_aud, "bearer": "access_token" if oauth_aud else "id_token", - }) + } + if ch_auth == "basic": + idp["ch_auth"] = "basic" + idps_by_id.setdefault(name, idp) hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name, "insecure": insecure, "_alts": alts}) else: diff --git a/docs/CLICKHOUSE-OSS-OAUTH.md b/docs/CLICKHOUSE-OSS-OAUTH.md index 844a40c3..fdbfafd6 100644 --- a/docs/CLICKHOUSE-OSS-OAUTH.md +++ b/docs/CLICKHOUSE-OSS-OAUTH.md @@ -42,6 +42,11 @@ token's `email` claim (falling back to `preferred_username` / `sub`): } ``` +For a real deploy this is `install.sh --ch-auth basic` (baked into the rendered +`config.json`). For `npm run local`, add `basic` to the OAuth +`` in `~/.clickhouse-client/config.xml` — `build/local.py` picks it up +and sets the same `ch_auth: "basic"` on that IdP's generated entry. + `bearer` usually stays at its default `id_token` here (ch-jwt-verify validates the id_token as the password). Combine with `audience` only if your verifier enforces one. diff --git a/examples/image-panels.json b/examples/image-panels.json new file mode 100644 index 00000000..9f939d00 --- /dev/null +++ b/examples/image-panels.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json", + "format": "altinity-sql-browser/saved-queries", + "version": 2, + "exportedAt": "2026-07-19T00:00:00.000Z", + "queries": [ + { + "id": "image-spiral", + "sql": "WITH\n 1024 AS size,\n 3000 AS n_points,\n 6 AS turns\nSELECT\n toInt32(round(size / 2 + (4 + (number / n_points) * (size / 2 - 8)) * cos(turns * 2 * pi() * number / n_points))) AS x,\n toInt32(round(size / 2 + (4 + (number / n_points) * (size / 2 - 8)) * sin(turns * 2 * pi() * number / n_points))) AS y,\n toUInt8(round(128 + 127 * sin(turns * 2 * pi() * number / n_points))) AS r,\n toUInt8(round(128 + 127 * sin(turns * 2 * pi() * number / n_points + 2.094))) AS g,\n toUInt8(round(128 + 127 * sin(turns * 2 * pi() * number / n_points + 4.188))) AS b\nFROM numbers(n_points)\nFORMAT PNG\nSETTINGS output_format_image_width = 1024, output_format_image_height = 1024", + "specVersion": 1, + "spec": { + "name": "Image: rainbow spiral (1024×1024)", + "description": "Plots 3000 points along a 6-turn polar spiral as an RGB FORMAT PNG image, hue-cycling colour by phase. Needs ClickHouse 26.6+ for FORMAT PNG. Columns must be named x,y (integer) and r,g,b (UInt8) per the PNG format contract; canvas size comes from output_format_image_width/height (default 1024).", + "favorite": true, + "view": "panel", + "panel": { + "cfg": { + "type": "image", + "fit": "contain", + "background": "theme", + "alt": "A rainbow-coloured spiral traced by 3000 points" + } + } + } + }, + { + "id": "image-rgb-gradient", + "sql": "SELECT\n toUInt8(number % 256) AS r,\n toUInt8(intDiv(number, 256) % 256) AS g,\n toUInt8(128 + 64 * sin(number / 4096.0)) AS b\nFROM numbers(65536)\nFORMAT PNG\nSETTINGS output_format_image_width = 256, output_format_image_height = 256", + "specVersion": 1, + "spec": { + "name": "Image: RGB gradient (256×256)", + "description": "A cheap 256×256 RGB FORMAT PNG with no explicit x,y columns — row order alone (row-major, left-to-right then top-to-bottom) fills the canvas. Needs ClickHouse 26.6+ for FORMAT PNG; canvas size set via output_format_image_width/height SETTINGS.", + "favorite": true, + "view": "panel", + "panel": { + "cfg": { + "type": "image", + "fit": "contain", + "background": "theme", + "alt": "A smooth RGB colour gradient" + } + } + } + }, + { + "id": "image-rgba-glow", + "sql": "WITH 256 AS size\nSELECT\n toUInt8(60) AS r,\n toUInt8(160) AS g,\n toUInt8(240) AS b,\n toUInt8(255 * exp(-1 * (\n pow((toFloat64(number % size) - size / 2) / (size / 4), 2) +\n pow((toFloat64(intDiv(number, size)) - size / 2) / (size / 4), 2)\n ))) AS a\nFROM numbers(size * size)\nFORMAT PNG\nSETTINGS output_format_image_width = 256, output_format_image_height = 256", + "specVersion": 1, + "spec": { + "name": "Image: transparent RGBA glow (256×256)", + "description": "A soft blue radial glow with a Gaussian alpha falloff — an r,g,b,a FORMAT PNG demonstrating the panel's checkerboard background option for transparent images. Needs ClickHouse 26.6+ for FORMAT PNG.", + "favorite": true, + "view": "panel", + "panel": { + "cfg": { + "type": "image", + "fit": "contain", + "background": "checkerboard", + "alt": "A soft blue glow fading to full transparency at the edges, shown over a checkerboard background" + } + } + } + }, + { + "id": "image-raytrace-sphere", + "sql": "-- Inspired by ClickHouse's SQL ray-tracing demos (github.com/ClickHouse/RayTracer);\n-- this is an original, minimal analytic ray/sphere scene, not a copy of that project.\nWITH\n 256 AS size,\n 0.0 AS cx, 0.0 AS cy, -3.0 AS cz, 1.0 AS cr,\n -2.0 AS lx, 3.0 AS ly, -1.0 AS lz\nSELECT\n x, y,\n toUInt8(greatest(0, least(255, round(255 * (0.05 + 0.55 * shade))))) AS r,\n toUInt8(greatest(0, least(255, round(255 * (0.08 + 0.55 * shade))))) AS g,\n toUInt8(greatest(0, least(255, round(255 * (0.35 + 0.60 * shade))))) AS b\nFROM (\n SELECT\n x, y, hit,\n if(hit, greatest(0.0, nx * lux + ny * luy + nz * luz), 1.0 - v * 0.5) AS shade\n FROM (\n SELECT\n x, y, hit,\n (px - cx) / cr AS nx, (py - cy) / cr AS ny, (pz - cz) / cr AS nz,\n (lx - px) / llen AS lux, (ly - py) / llen AS luy, (lz - pz) / llen AS luz,\n v\n FROM (\n SELECT\n x, y, u, v, dx, dy, dz, t, hit,\n t * dx AS px, t * dy AS py, t * dz AS pz,\n sqrt(pow(lx - t * dx, 2) + pow(ly - t * dy, 2) + pow(lz - t * dz, 2)) AS llen\n FROM (\n SELECT\n x, y, u, v, dx, dy, dz,\n (-b - sqrt(greatest(disc, 0))) / 2 AS t,\n disc >= 0 AS hit\n FROM (\n SELECT\n x, y, u, v, dx, dy, dz,\n 2 * (-cx * dx + -cy * dy + -cz * dz) AS b,\n (cx * cx + cy * cy + cz * cz - cr * cr) AS c,\n 2 * (-cx * dx + -cy * dy + -cz * dz) * 2 * (-cx * dx + -cy * dy + -cz * dz)\n - 4 * (cx * cx + cy * cy + cz * cz - cr * cr) AS disc\n FROM (\n SELECT\n x, y, u, v,\n u / len AS dx, v / len AS dy, -1.0 / len AS dz\n FROM (\n SELECT\n x, y, u, v, sqrt(u * u + v * v + 1.0) AS len\n FROM (\n SELECT\n toInt32(number % size) AS x,\n toInt32(intDiv(number, size)) AS y,\n (toFloat64(number % size) / size - 0.5) * 2 AS u,\n (0.5 - toFloat64(intDiv(number, size)) / size) * 2 AS v\n FROM numbers(size * size)\n )\n )\n )\n )\n )\n )\n )\n)\nORDER BY y, x\nFORMAT PNG\nSETTINGS output_format_image_width = 256, output_format_image_height = 256", + "specVersion": 1, + "spec": { + "name": "Image: ray-traced sphere (256×256)", + "description": "An original, minimal analytic ray/sphere-intersection scene with Lambertian shading over a sky-gradient background, rendered entirely in SQL as an RGB FORMAT PNG. Inspired by ClickHouse's SQL ray-tracing demos (github.com/ClickHouse/RayTracer) — not a copy of that project's code. CPU cost grows with the pixel count (size×size rows); keep size small (256 here) unless you deliberately want a slower, higher-resolution render — a 1024 render can take noticeably longer. Needs ClickHouse 26.6+ for FORMAT PNG.", + "favorite": true, + "view": "panel", + "panel": { + "cfg": { + "type": "image", + "fit": "contain", + "background": "theme", + "alt": "A shaded sphere lit from one side, rendered by ray-tracing math in SQL, over a gradient sky background" + } + } + } + } + ] +} diff --git a/schemas/generated/library-v2.bundle.schema.json b/schemas/generated/library-v2.bundle.schema.json index ecd91cc0..c97dfb60 100644 --- a/schemas/generated/library-v2.bundle.schema.json +++ b/schemas/generated/library-v2.bundle.schema.json @@ -1061,6 +1061,56 @@ "content" ] }, + { + "title": "Image", + "description": "A single ClickHouse `FORMAT PNG` result rendered as an image. The authored SQL must end in an explicit FORMAT PNG clause.", + "x-altinity-status": "implemented", + "x-altinity-snippet": { + "type": "image" + }, + "properties": { + "type": { + "const": "image" + }, + "fit": { + "title": "Image fit", + "description": "contain scales the image to fit within the tile preserving aspect ratio, cover fills the tile and crops overflow, and actual renders at intrinsic CSS pixel size in a scrollable body.", + "type": "string", + "enum": [ + "contain", + "cover", + "actual" + ], + "default": "contain" + }, + "background": { + "title": "Image background", + "description": "theme uses the surrounding surface color, transparent shows nothing behind the image, and checkerboard renders a checkerboard pattern (useful for a transparent PNG).", + "type": "string", + "enum": [ + "transparent", + "checkerboard", + "theme" + ], + "default": "theme" + }, + "alt": { + "title": "Alt text", + "description": "Accessible description for the image. Falls back to the tile/query title when absent.", + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": true, + "x-altinity-order": [ + "type", + "fit", + "background", + "alt" + ] + }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", @@ -1082,7 +1132,8 @@ "kpi", "table", "logs", - "text" + "text", + "image" ] } } diff --git a/schemas/query-spec-v1.schema.json b/schemas/query-spec-v1.schema.json index 836edf56..2f31f752 100644 --- a/schemas/query-spec-v1.schema.json +++ b/schemas/query-spec-v1.schema.json @@ -549,6 +549,37 @@ "additionalProperties": true, "x-altinity-order": ["type", "content"] }, + { + "title": "Image", + "description": "A single ClickHouse `FORMAT PNG` result rendered as an image. The authored SQL must end in an explicit FORMAT PNG clause.", + "x-altinity-status": "implemented", + "x-altinity-snippet": { "type": "image" }, + "properties": { + "type": { "const": "image" }, + "fit": { + "title": "Image fit", + "description": "contain scales the image to fit within the tile preserving aspect ratio, cover fills the tile and crops overflow, and actual renders at intrinsic CSS pixel size in a scrollable body.", + "type": "string", + "enum": ["contain", "cover", "actual"], + "default": "contain" + }, + "background": { + "title": "Image background", + "description": "theme uses the surrounding surface color, transparent shows nothing behind the image, and checkerboard renders a checkerboard pattern (useful for a transparent PNG).", + "type": "string", + "enum": ["transparent", "checkerboard", "theme"], + "default": "theme" + }, + "alt": { + "title": "Alt text", + "description": "Accessible description for the image. Falls back to the tile/query title when absent.", + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": true, + "x-altinity-order": ["type", "fit", "background", "alt"] + }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", @@ -558,7 +589,7 @@ "type": { "type": "string", "minLength": 1, - "not": { "enum": ["bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text"] } + "not": { "enum": ["bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text", "image"] } } }, "required": ["type"], diff --git a/src/application/export-service.ts b/src/application/export-service.ts index 12acc228..f41da75c 100644 --- a/src/application/export-service.ts +++ b/src/application/export-service.ts @@ -158,6 +158,11 @@ export interface ExportHooks { /** Fire-and-forget schema reload after a schema-mutating script statement * actually ran (mirrors `WorkbenchHooks.loadSchema`). */ loadSchema(): void; + /** #318: free a displayed image's blob URL before `tab.result` is replaced + * wholesale — mirrors `WorkbenchHooks.revokeResultImage` (workbench- + * session.ts) exactly; a no-op for every non-image result (`results.js`'s + * `revokeResultImageUrl` itself is the shared no-op guard). */ + revokeResultImage(result: unknown): void; } /** Every side effect this service needs, injected as a narrow bag — mirrors @@ -478,6 +483,7 @@ export function createExportService(deps: ExportServiceDeps): ExportService { status: 'pending', file: null, bytes: 0, startedAt: null, ms: 0, error: null, })); const scriptExportResult: ScriptExportResult = { scriptExport: entries, startedAt: t0 }; + deps.hooks.revokeResultImage(tab.result); // #318: free a displayed image's URL before it's overwritten Object.assign(tab, { result: scriptExportResult }); deps.state.resultSort = { col: null, dir: 'asc' }; exportScriptCancelled = false; diff --git a/src/application/query-execution-service.ts b/src/application/query-execution-service.ts index feb4845d..a77d137d 100644 --- a/src/application/query-execution-service.ts +++ b/src/application/query-execution-service.ts @@ -16,7 +16,8 @@ import type { ChCtx, RunQueryOptions, RunQueryResult } from '../net/ch-client.js'; import type { runQuery, killQuery } from '../net/ch-client.js'; -import { applyStreamLine } from '../core/stream.js'; +import { applyStreamLine, looksLikeChException, parseExceptionText } from '../core/stream.js'; +import { validatePng } from '../core/png.js'; import type { StreamResult } from '../core/stream.js'; import { isRowReturning } from '../core/sql-split.js'; import { parseSelectResult, firstRowPreview, SELECT_ROW_CAP } from '../core/script-result.js'; @@ -122,6 +123,12 @@ export interface AttemptResult extends RunQueryResult { transient?: boolean; } +// Bounded textual prefix decoded from a failed-PNG-validation binary body to +// check for an embedded ClickHouse exception (#307 item 6) — large enough for +// any real CH error message, small enough to never meaningfully allocate on a +// huge (already MAX_PNG_BYTES-capped) body. +const CH_EXCEPTION_PREFIX_BYTES = 4096; + // ClickHouse's transient "session is busy / locked by a concurrent client" // (SESSION_IS_LOCKED, code 373) — retryable once the prior request releases it. const SESSION_BUSY = /SESSION_IS_LOCKED|session .* is locked|locked by a concurrent/i; @@ -177,7 +184,29 @@ export function createQueryExecutionService(deps: QueryExecutionDeps): QueryExec onChunk, }); if (out.error != null) result.error = out.error; - else if (out.raw != null) { + else if (out.binary != null) { + const check = validatePng(out.binary.bytes); + if (check.ok) { + result.image = { + kind: 'image', + format: 'PNG', + mimeType: 'image/png', + bytes: out.binary.bytes, + width: check.width, + height: check.height, + }; + result.progress.bytes = out.binary.bytes.byteLength; + } else { + // ClickHouse can fail AFTER sending a 2xx status (headers are + // already committed once streaming starts) and append its + // exception as plain text instead of PNG bytes — surface that CH + // error verbatim rather than a confusing "structurally invalid + // PNG" message (#307 item 6). Bounded + non-fatal: a real PNG's + // binary prefix will never decode into exception-shaped text. + const prefix = new TextDecoder('utf-8', { fatal: false }).decode(out.binary.bytes.subarray(0, CH_EXCEPTION_PREFIX_BYTES)); + result.error = looksLikeChException(prefix) ? parseExceptionText(prefix) : 'Invalid PNG result: ' + check.reason; + } + } else if (out.raw != null) { result.rawText = out.raw; result.progress.bytes = out.raw.length; } diff --git a/src/application/schema-graph-session.ts b/src/application/schema-graph-session.ts index 23efec14..822389c8 100644 --- a/src/application/schema-graph-session.ts +++ b/src/application/schema-graph-session.ts @@ -116,6 +116,13 @@ export interface SchemaGraphHooks { * `SchemaGraphAuthRequiredError` (the shell needs both: the hook to sign * out, the error to pick `view.fail`'s message). */ onAuthFailed(): void; + /** Frees a discarded result's own Image (PNG) object URL (#307) — called + * by `show()` right before it overwrites `tab.result` wholesale with the + * fresh loading placeholder. A no-op for every non-image result (matches + * `WorkbenchHooks.revokeResultImage`'s own doc comment — same seam shape, + * same convention: the session stays ignorant of `app.revokeObjectUrl`/ + * results.ts's URL cache), so this call site can invoke it unconditionally. */ + revokeResultImage(result: unknown): void; } // ── Construction deps ──────────────────────────────────────────────────────── @@ -202,6 +209,7 @@ export function createSchemaGraphSession(deps: SchemaGraphDeps): SchemaGraphSess // system.dictionaries) is a network round trip. const result: SchemaGraphResult = newResult('Table'); result.schemaGraph = { focus, loading: true, nodes: [], edges: [] }; + deps.hooks.revokeResultImage(tab.result); // #307: free a displayed image's URL before it's overwritten Object.assign(tab, { result }); // `result` is the stale-write guard (mirrors #97's identity-guard shape, // and WorkbenchSession's own run() guard): captured once, checked before diff --git a/src/core/export.ts b/src/core/export.ts index f4e0cfea..db9e18b0 100644 --- a/src/core/export.ts +++ b/src/core/export.ts @@ -56,6 +56,7 @@ export function formatFileMeta(format?: string | null): FileMeta { if (/^XML$/i.test(f)) return { ext: 'xml', mime: 'application/xml' }; if (/^Markdown$/i.test(f)) return { ext: 'md', mime: 'text/markdown' }; if (/^SQLInsert$/i.test(f)) return { ext: 'sql', mime: 'application/sql' }; + if (/^PNG$/i.test(f)) return { ext: 'png', mime: 'image/png' }; return { ext: 'txt', mime: 'text/plain' }; // Pretty*, Vertical, Values, unknown } diff --git a/src/core/format.ts b/src/core/format.ts index e5827f54..1952c843 100644 --- a/src/core/format.ts +++ b/src/core/format.ts @@ -299,6 +299,16 @@ export function supportsExplainPretty(v?: string | null): boolean { return major > 26 || (major === 26 && minor >= 3); } +/** + * True when `fmt` is a binary (non-text) ClickHouse output format that the + * transport must read as bytes rather than decode as text — currently just + * `PNG` (#307's image results). Case-insensitive, matched by exact name (not + * prefix, unlike `formatFileMeta`'s families) since PNG has no name variants. + */ +export function isBinaryFormat(fmt?: string | null): boolean { + return /^PNG$/i.test(String(fmt || '')); +} + /** * Short display name for the header user control: the local-part of an email * (before '@'). Falls back to the whole string when there's no '@', and '' for diff --git a/src/core/panel-cfg.ts b/src/core/panel-cfg.ts index 9c8d1e92..6e333af6 100644 --- a/src/core/panel-cfg.ts +++ b/src/core/panel-cfg.ts @@ -6,6 +6,9 @@ // | table (no schema-bound fields) // | logs ({time?,msg?,level?} column NAMES) // | text ({content} — needs no result at all) +// | image ({fit?,background?,alt?} — a single +// FORMAT PNG result, #307; needs a +// query but no column-role fields) // // Field policy (pinned in #166): unknown cfg fields are ignored by validation // and *preserved* by clone/normalize — the additive forward-compatibility @@ -15,7 +18,7 @@ // storage too; rendering falls back via `resolvePanel` with a diagnostic. import type { - AreaPanelCfg, BarPanelCfg, FieldConfig, HbarPanelCfg, KpiPanelCfg, LinePanelCfg, + AreaPanelCfg, BarPanelCfg, FieldConfig, HbarPanelCfg, ImagePanelCfg, KpiPanelCfg, LinePanelCfg, LogsPanelCfg, Panel, PanelCfg, PiePanelCfg, TablePanelCfg, TextPanelCfg, } from '../generated/json-schema.types.js'; import { @@ -42,7 +45,7 @@ export interface Column { // generated schema branches themselves (no hand-duplicated literal list). type ChartFamilyCfg = BarPanelCfg | HbarPanelCfg | LinePanelCfg | AreaPanelCfg | PiePanelCfg; export type ChartFamilyType = ChartFamilyCfg['type']; -type KnownPanelCfg = ChartFamilyCfg | KpiPanelCfg | TablePanelCfg | LogsPanelCfg | TextPanelCfg; +type KnownPanelCfg = ChartFamilyCfg | KpiPanelCfg | TablePanelCfg | LogsPanelCfg | TextPanelCfg | ImagePanelCfg; export type KnownPanelTypeId = KnownPanelCfg['type']; // chart-data.js is unconverted (checkJs:false), so TS infers its exports' @@ -63,7 +66,7 @@ const schemaKey = _schemaKey as (columns: Column[] | null | undefined) => string export const CHART_FAMILY: Set = new Set(CHART_TYPES.map((t) => t.value)); /** Every v1 panel type id, in picker order (chart family first). */ -export const PANEL_TYPE_IDS: KnownPanelTypeId[] = ['kpi', ...CHART_FAMILY, 'table', 'logs', 'text']; +export const PANEL_TYPE_IDS: KnownPanelTypeId[] = ['kpi', ...CHART_FAMILY, 'table', 'logs', 'text', 'image']; const KNOWN_TYPES: Set = new Set(PANEL_TYPE_IDS); @@ -194,7 +197,7 @@ export function panelCfgValid( const type = (cfg as { type?: unknown }).type; if (isChartFamily(type)) return chartCfgValid(cfg, columns); if (type === 'logs') return resolveLogsShape(cfg as LogsCfgLike, columns) != null; - return type === 'kpi' || type === 'table' || type === 'text'; + return type === 'kpi' || type === 'table' || type === 'text' || type === 'image'; } /** @@ -458,7 +461,7 @@ export function resolvePanel(saved: Panel | null | undefined, input: PanelInput) if (detected) return resolved({ cfg, shape: detected, rederived: true, fallback: false }); return fallbackTo('Saved logs panel: no time + message columns in this result.'); } - if (cfg.type === 'table' || cfg.type === 'text') { + if (cfg.type === 'table' || cfg.type === 'text' || cfg.type === 'image') { return resolved({ cfg, rederived: false, fallback: false }); } return fallbackTo('Unknown panel type "' + String(cfg.type) + '" (saved by a newer build?).'); diff --git a/src/core/panel-execution.ts b/src/core/panel-execution.ts index 5b908265..d1466335 100644 --- a/src/core/panel-execution.ts +++ b/src/core/panel-execution.ts @@ -11,6 +11,27 @@ export function isKpiPanel(panel: Panel | null | undefined): boolean { return panel?.cfg?.type === 'kpi'; } +/** True when `panel`'s resolved cfg is the Image (PNG) arm (#307) — the + * explicit-panel type whose transport `panelExecution` also owns (a single + * binary FORMAT PNG result, not a structured streaming one). */ +export function isImagePanel(panel: Panel | null | undefined): boolean { + return panel?.cfg?.type === 'image'; +} + +/** #307 UX fix: should an UNCONFIGURED panel-role tile (no explicit `panel.cfg` + * at all — `panel` is null or has no `cfg`) be treated as an Image panel? True + * only when the authored SQL's trailing FORMAT clause is exactly `PNG` + * (case-insensitive, via `detectSqlFormat`). An explicitly-typed panel (any + * type, including a non-image one) never re-types here — this is purely the + * "nothing chosen yet" heuristic, parallel to `autoPanel`'s result-shape + * heuristic but keyed on the authored SQL rather than the result. */ +export function shouldInferImagePanel(panel: Panel | Record | null | undefined, sql: string): boolean { + const cfg = panel && typeof panel === 'object' ? (panel as { cfg?: unknown }).cfg : undefined; + if (cfg && typeof cfg === 'object') return false; + const format = detectSqlFormat(sql); + return !!format && format.toUpperCase() === 'PNG'; +} + /** A saved query's explicit, known-typed panel payload, or null. Unknown * panel-cfg shapes stay non-null-ish only through resolvePanel's diagnostic * fallback. Shared by the Dashboard's ordinary-tile path and its KPI-band @@ -51,6 +72,36 @@ export function panelExecution( sql: string, defaults: PanelExecutionDefaults = {}, ): PanelExecutionResult { + if (isImagePanel(panel)) { + const authoredFormat = detectSqlFormat(sql); + if (!authoredFormat) { + return { + ...defaults, + owned: true, + error: 'Image panel requires an explicit FORMAT PNG clause.', + params: { ...(defaults.params || {}) }, + }; + } + if (authoredFormat.toUpperCase() !== 'PNG') { + return { + ...defaults, + owned: true, + error: `Image panel requires FORMAT PNG. Remove FORMAT ${authoredFormat} from the SQL.`, + params: { ...(defaults.params || {}) }, + }; + } + return { + ...defaults, + owned: true, + error: null, + format: 'PNG', + // rowLimit is not meaningful for a binary (single-blob) result — kept + // at 0 so a caller that still reads it (e.g. a shared row-cap gate) + // never treats an image tile as a multi-row streaming result. + rowLimit: 0, + params: { ...(defaults.params || {}) }, + }; + } if (!isKpiPanel(panel)) return { ...defaults, owned: false, error: null, params: { ...(defaults.params || {}) } }; const authoredFormat = detectSqlFormat(sql); if (authoredFormat) { diff --git a/src/core/png.ts b/src/core/png.ts new file mode 100644 index 00000000..fe66fa83 --- /dev/null +++ b/src/core/png.ts @@ -0,0 +1,92 @@ +// Pure PNG-bytes validation for ClickHouse `FORMAT PNG` image results (#307). +// No DOM, no globals — the raw bytes come off the wire (ch-client's binary +// branch); this module only inspects the PNG signature + IHDR chunk so the +// UI can trust width/height/size before handing the bytes to an /blob +// URL. Never decodes pixel data. + +/** Hard caps a PNG result must satisfy to be considered a valid, renderable + * image — guards against a malformed/huge response wedging the tab. */ +export const MAX_PNG_WIDTH = 8192; +export const MAX_PNG_HEIGHT = 8192; +export const MAX_PNG_PIXELS = 32_000_000; +export const MAX_PNG_BYTES = 64 * 1024 * 1024; + +/** The 8-byte PNG file signature (always the first 8 bytes of a real PNG). */ +const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; + +/** A validated `FORMAT PNG` result, ready for the results panel to render as + * an image. `width`/`height` come from the IHDR chunk (no pixel decode). */ +export interface ImageResultPayload { + kind: 'image'; + format: 'PNG'; + mimeType: 'image/png'; + bytes: Uint8Array; + width: number; + height: number; +} + +/** `validatePng`'s outcome: the IHDR-reported dimensions, or a human-readable + * reason a result error line can show verbatim. */ +export type ValidatePngResult = + | { ok: true; width: number; height: number } + | { ok: false; reason: string }; + +/** The constant, structurally-fixed IEND chunk: 4-byte zero length, 'IEND' + * type, and its (also constant, since IEND never carries data) CRC-32. */ +const IEND_TAIL = [0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130]; // len=0, 'IEND', CRC + +/** + * Validate `bytes` as a well-formed, in-bounds, structurally-COMPLETE PNG: + * non-empty, under `MAX_PNG_BYTES`, the 8-byte PNG signature, a readable + * IHDR chunk (first chunk at offset 8, length EXACTLY 13, type 'IHDR', its + * 13 data bytes + 4-byte CRC fully in bounds) with positive width/height + * within `MAX_PNG_WIDTH`/`MAX_PNG_HEIGHT` and `width*height <= + * MAX_PNG_PIXELS`, AND a terminal IEND chunk (the last 12 bytes: zero + * length + 'IEND' + IEND's constant CRC) — guarding against a + * header-only/truncated response that never actually finished writing. + * Never decodes pixel data — only the signature + IHDR header + trailing + * IEND bytes. Pure. + */ +export function validatePng(bytes: Uint8Array): ValidatePngResult { + if (!bytes || bytes.length === 0) return { ok: false, reason: 'Empty PNG result' }; + if (bytes.length > MAX_PNG_BYTES) { + return { ok: false, reason: `PNG result too large (${bytes.length} bytes, max ${MAX_PNG_BYTES})` }; + } + if (bytes.length < 8 + 8 + 13) { + return { ok: false, reason: 'PNG result too short to contain a header' }; + } + for (let i = 0; i < PNG_SIGNATURE.length; i++) { + if (bytes[i] !== PNG_SIGNATURE[i]) return { ok: false, reason: 'Not a valid PNG (bad signature)' }; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const chunkLength = view.getUint32(8, false); + const chunkType = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]); + if (chunkType !== 'IHDR') return { ok: false, reason: 'Not a valid PNG (missing IHDR chunk)' }; + if (chunkLength !== 13) return { ok: false, reason: 'Not a valid PNG (IHDR chunk too short)' }; + // IHDR's 13 data bytes + its 4-byte CRC must actually be present. + if (8 + 8 + 13 + 4 > bytes.length) { + return { ok: false, reason: 'PNG result truncated (incomplete IHDR chunk)' }; + } + const width = view.getUint32(16, false); + const height = view.getUint32(20, false); + if (width <= 0 || height <= 0) return { ok: false, reason: 'Not a valid PNG (non-positive dimensions)' }; + if (width > MAX_PNG_WIDTH || height > MAX_PNG_HEIGHT) { + return { ok: false, reason: `PNG dimensions too large (${width}x${height}, max ${MAX_PNG_WIDTH}x${MAX_PNG_HEIGHT})` }; + } + if (width * height > MAX_PNG_PIXELS) { + return { ok: false, reason: `PNG has too many pixels (${width * height}, max ${MAX_PNG_PIXELS})` }; + } + // A structurally complete PNG always ends with IEND (zero-length, constant + // CRC) as its very last 12 bytes — a header-only/truncated body never has + // this, which is exactly what this guards against. (No separate + // length-vs-IEND_TAIL.length check needed: the bounds check above already + // guarantees `bytes.length >= 33 > IEND_TAIL.length`, so `tailStart` is + // always non-negative here.) + const tailStart = bytes.length - IEND_TAIL.length; + for (let i = 0; i < IEND_TAIL.length; i++) { + if (bytes[tailStart + i] !== IEND_TAIL[i]) { + return { ok: false, reason: 'PNG result truncated (missing IEND chunk)' }; + } + } + return { ok: true, width, height }; +} diff --git a/src/core/result-choice.ts b/src/core/result-choice.ts index f831de79..1c339f06 100644 --- a/src/core/result-choice.ts +++ b/src/core/result-choice.ts @@ -12,7 +12,7 @@ const CHART_TYPES = _CHART_TYPES as { value: ChartFamilyType; label: string }[]; /** The panel types the Result-choice picker actually offers as options — * `table` is deliberately excluded (see PICKABLE_PANEL_TYPES below). */ -export type PickablePanelType = 'kpi' | ChartFamilyType | 'logs' | 'text'; +export type PickablePanelType = 'kpi' | ChartFamilyType | 'logs' | 'text' | 'image'; /** One Panel-arm option in the Result-choice picker. */ export interface PanelResultChoice { @@ -36,6 +36,7 @@ export const PANEL_RESULT_CHOICES: readonly PanelResultChoice[] = Object.freeze( ({ id: `panel:${value}`, kind: 'panel', panelType: value, label })), { id: 'panel:logs', kind: 'panel', panelType: 'logs', label: 'Logs' }, { id: 'panel:text', kind: 'panel', panelType: 'text', label: 'Text' }, + { id: 'panel:image', kind: 'panel', panelType: 'image', label: 'Image' }, ]); export const DASHBOARD_ROLE_RESULT_CHOICES: readonly RoleResultChoice[] = Object.freeze([ diff --git a/src/core/stream.ts b/src/core/stream.ts index b21a44a3..6bb10f96 100644 --- a/src/core/stream.ts +++ b/src/core/stream.ts @@ -7,6 +7,8 @@ // `applyStreamLine` folds one parsed object into a mutable result; keeping it // pure (no fetch, no DOM) makes the streaming parser fully unit-testable. +import type { ImageResultPayload } from './png.js'; + /** One streamed result column, as reported by a `{meta}` line. */ export interface StreamColumn { name: string; @@ -37,6 +39,10 @@ export interface StreamResult { pct: number; rowLimit: number; capped: boolean; + /** A validated `FORMAT PNG` image result (#307), or null for every other + * format/until one is set. Mutually exclusive with `rawText`/`rows` in + * practice — the transport picks one branch per query. */ + image: ImageResultPayload | null; } /** @@ -58,6 +64,7 @@ export function newResult(fmt: string, rowLimit = 0): StreamResult { pct: 0, rowLimit, capped: false, + image: null, }; } @@ -133,6 +140,19 @@ export function parseExceptionText(text: string): string { return text; } +/** + * Heuristic: does `text` look like it carries a ClickHouse exception, rather + * than arbitrary/binary data? Used by the `FORMAT PNG` binary path (#307 + * item 6) to tell a genuine CH error — which can arrive with a 2xx HTTP + * status once headers are already sent (see `findExceptionFrame`'s doc) — apart + * from a structurally invalid/garbage PNG body. Matches `DB::Exception` + * anywhere, a leading `Code: ` line (CH's plain-text error prefix), or a + * `{"exception": ...}` JSON line (CH's mid-stream exception frame). Pure. + */ +export function looksLikeChException(text: string): boolean { + return text.includes('DB::Exception') || /^Code:\s*\d+/m.test(text) || /^\{"exception"/m.test(text); +} + const EXCEPTION_MARKER = '__exception__'; // ClickHouse WriteBufferFromHTTPServerResponse // Re-decode a latin1 (1 byte -> 1 char) slice back into proper UTF-8 text. diff --git a/src/dashboard/application/dashboard-authoring-session.ts b/src/dashboard/application/dashboard-authoring-session.ts index b8680c67..b23d359f 100644 --- a/src/dashboard/application/dashboard-authoring-session.ts +++ b/src/dashboard/application/dashboard-authoring-session.ts @@ -31,6 +31,7 @@ import { resolveDashboardPresentations } from '../model/presentation-resolver.js import { buildDashboardExportBundle } from '../model/dashboard-export.js'; import { defaultLayoutRegistry } from '../layouts/layout-registry.js'; import { applyCommand } from './dashboard-commands.js'; +import { createEmptyDashboardDocument } from './tile-membership.js'; import type { DashboardCommand, DashboardCommandResult } from './dashboard-commands.js'; import { createQueryResolver } from './dashboard-query-resolver.js'; import { validateStoredWorkspaceDocument } from '../../workspace/stored-workspace.js'; @@ -83,13 +84,12 @@ export interface DashboardAuthoringSessionDeps { nowISO?: () => string; } -function createEmptyDashboard(id: string): DashboardDocumentV1 { - return { - documentVersion: 1, id, title: 'Dashboard', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, - filters: [], tiles: [], - }; -} +// The empty-Dashboard shape is shared with `tile-membership.ts`'s +// `createEmptyDashboardDocument` (#307: starring a query / importing queries +// into a Dashboard-less workspace needs the exact same "start one" shape this +// session already used) — that module owns it now, this just re-exports the +// name this file's callers already use. +const createEmptyDashboard = createEmptyDashboardDocument; /** Build a `DashboardAuthoringSession` bound to `deps`. */ export function createDashboardAuthoringSession( diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 167e6419..46ca2e63 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -34,7 +34,8 @@ import { hasOptionalBlocks } from '../../core/optional-blocks.js'; import { detectSqlFormat } from '../../core/format.js'; import { DASH_TILE_ROW_CAP, DASH_TILE_BYTE_CAP } from '../../core/dashboard.js'; import { queryName } from '../../core/saved-query.js'; -import { panelExecution } from '../../core/panel-execution.js'; +import { panelExecution, shouldInferImagePanel } from '../../core/panel-execution.js'; +import type { ImageResultPayload } from '../../core/png.js'; import { filterExecution } from '../../core/filter-execution.js'; import { readFilterOptions } from '../../core/filter-options.js'; import { mergeDashboardFilterHelpers } from '../../core/dashboard-filters.js'; @@ -74,11 +75,18 @@ export interface ViewerTileState { title: string; status: ViewerTileStatus; isKpi: boolean; + /** True for a resolved Image (PNG) panel (#307) — `runTile` requires the + * authored SQL to carry `FORMAT PNG` for exactly this tile and publishes + * its result on `image` instead of `columns`/`rows`. */ + isImage: boolean; /** The resolved effective panel (base + variant + override), or null when the * presentation could not resolve (then `status` is 'error'). */ panel: Record | null; columns: Column[] | null; rows: unknown[][] | null; + /** A ready Image tile's validated PNG payload (#307); null for every other + * panel type, and while not yet ready. */ + image: ImageResultPayload | null; meta: { rows: number; ms: number; bytes: number; truncated: boolean } | null; error: string | null; /** Param names still needing a value (status 'unfilled'). */ @@ -233,6 +241,7 @@ interface TileRuntime { explicit: Panel | null; isKpi: boolean; isText: boolean; + isImage: boolean; presentationError: WorkspaceDiagnostic | null; gen: number; abortController: AbortController | null; @@ -318,22 +327,34 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa `No saved query ${JSON.stringify(tile.queryId)} for tile ${JSON.stringify(tile.id)}`, tile.id); } else { const resolved = resolvePresentation({ query, tile }); - if (resolved.ok) panel = resolved.panel; - else presentationError = resolved.diagnostics[0]; + if (resolved.ok) { + panel = resolved.panel; + // #307 UX fix: an unconfigured panel-role tile (no explicit + // `panel.cfg` at all) whose authored SQL ends in `FORMAT PNG` is + // treated as an Image panel — same as if `cfg.type:'image'` had been + // saved. Must run BEFORE the isKpi/isText/isImage derivation below so + // `explicit`, `state.panel`, and `panelExecution` (via + // `runtime.explicit`) all see a real image panel. Uses the base + // authored SQL (`query.sql`), not any later filter/param merge. + if (!cfgType(panel) && shouldInferImagePanel(panel, query.sql)) { + panel = { ...panel, cfg: { type: 'image' } }; + } + } else presentationError = resolved.diagnostics[0]; } const type = cfgType(panel); const isKpi = type === 'kpi'; const isText = type === 'text'; + const isImage = type === 'image'; const explicit: Panel | null = isObject(panel) && isObject(panel.cfg) ? (panel as unknown as Panel) : null; const title = (typeof tile.title === 'string' && tile.title) || (query ? queryName(query) : tile.queryId) || tile.id; const state: ViewerTileState = { - tileId: tile.id, queryId: tile.queryId, title, isKpi, panel, + tileId: tile.id, queryId: tile.queryId, title, isKpi, isImage, panel, status: presentationError ? 'error' : 'idle', - columns: null, rows: null, meta: null, + columns: null, rows: null, image: null, meta: null, error: presentationError ? presentationError.message : null, unfilled: [], progressRows: 0, }; - return { tile, query, panel, explicit, isKpi, isText, presentationError, gen: 0, abortController: null, state }; + return { tile, query, panel, explicit, isKpi, isText, isImage, presentationError, gen: 0, abortController: null, state }; } // One runtime record per tile, in semantic (dashboard.tiles) order. @@ -480,11 +501,23 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa format: 'Table', rowLimit: DASH_TILE_ROW_CAP + 1, params: { readonly: 2, max_result_bytes: DASH_TILE_BYTE_CAP, ...mergedSourceArgs(source) }, }); - const checkFormat = !runtime.isKpi; - if (execution.error || (checkFormat && detectSqlFormat(execSql))) { + // A tile's authored SQL may carry a trailing FORMAT clause only when its + // resolved panel OWNS a binary/non-streaming transport for it — today that + // is the KPI arm (silently normalized away) and the Image arm (panelExecution + // above already validated it's exactly `FORMAT PNG`, and set `execution.error` + // otherwise). Every other panel/role keeps the blanket rejection. + const checkFormat = !runtime.isKpi && !runtime.isImage; + const rejectedFormat = checkFormat ? detectSqlFormat(execSql) : null; + if (execution.error || rejectedFormat) { runtime.state.status = 'error'; + // #307: an explicitly-typed non-image panel whose authored format is + // specifically PNG gets an actionable message pointing at the fix + // (retype the panel to Image) rather than the generic "remove FORMAT" + // guidance, which would be wrong advice for a PNG-producing query. runtime.state.error = execution.error - || 'Dashboard panels require structured streaming results. Remove the explicit FORMAT clause.'; + || (rejectedFormat && rejectedFormat.toUpperCase() === 'PNG' + ? 'This query returns FORMAT PNG — set its panel type to Image to show it on the Dashboard.' + : 'Dashboard panels require structured streaming results. Remove the explicit FORMAT clause.'); publish(); return; } @@ -494,7 +527,11 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa const controller = new AbortController(); runtime.abortController = controller; const startedAt = deps.now(); - const rowCap = runtime.isKpi ? 2 : DASH_TILE_ROW_CAP; + // rowLimit/rowCap are meaningless for a binary (single-blob) Image result — + // `newResult`'s cap only trims streamed `{row}` lines, which a PNG response + // never emits (executeRead's binary branch populates `result.image` + // instead), so 0 (uncapped) is simply inert here rather than load-bearing. + const rowCap = runtime.isKpi ? 2 : runtime.isImage ? 0 : DASH_TILE_ROW_CAP; // `!`: panelExecution always resolves a concrete format ('Table' default or 'KPI'). const result = newResult(execution.format!, rowCap); await deps.exec.executeRead(result, { @@ -519,6 +556,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa runtime.state.unfilled = []; runtime.state.columns = result.columns as unknown as Column[]; runtime.state.rows = result.rows; + runtime.state.image = result.image; runtime.state.meta = tileResultMeta(result, startedAt, deps.now()); deps.recordBoundParams?.(source.statements.flatMap((statement) => statement.boundParams)); publish(); diff --git a/src/dashboard/application/tile-membership.ts b/src/dashboard/application/tile-membership.ts index 05424237..3486ff72 100644 --- a/src/dashboard/application/tile-membership.ts +++ b/src/dashboard/application/tile-membership.ts @@ -15,11 +15,26 @@ // Pure — no DOM, no persistence; the caller (state.ts's `toggleFavorite`) // folds the result into the same commit candidate as the favorite patch. +import { queryFavorite } from '../../core/saved-query.js'; import { queryDashboardRole } from '../model/workspace-semantics.js'; import { resolveLayoutPluginSync } from '../layouts/layout-registry.js'; import { regenerateGridFallback } from '../layouts/grafana-grid-layout.js'; import type { DashboardDocumentV1, SavedQueryV2 } from '../../generated/json-schema.types.js'; +/** Build a brand-new, empty flow@1 Dashboard document at revision 1. The + * single source of truth for "what does an empty Dashboard look like" — + * `dashboard-authoring-session.ts`'s `createEmptyDashboard` (the "workspace + * has no Dashboard yet, start one" path for the authoring session) reuses + * this rather than duplicating the shape. Pure; the id is minted by the + * caller so tests stay deterministic and production stays unguessable. */ +export function createEmptyDashboardDocument(id: string): DashboardDocumentV1 { + return { + documentVersion: 1, id, title: 'Dashboard', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + filters: [], tiles: [], + }; +} + /** Remove every tile referencing `queryId`, and scrub those tile ids out of * every filter's `targets` — the typed counterpart of saved-query-mutation.ts's * `removeAffectedTiles` (that one operates on unknown/untyped documents). */ @@ -46,7 +61,16 @@ function removeTilesForQuery(dashboard: DashboardDocumentV1, queryId: string): D * becomes a tile, matching `buildLegacyMigrationCandidate`). * - Star OFF → remove EVERY tile referencing the query and scrub those tile * ids from every filter's `targets`. - * - `dashboard` null in → `null` out (no Dashboard yet; favorite flip only). + * - `dashboard` null in + star ON on a panel-role query → a fresh empty + * Dashboard is created (`createEmptyDashboardDocument(genDashboardId())`) + * and the tile is added to it (#307: a fresh workspace has no Dashboard + * yet, but starring a panel-role query must still be the one bridge that + * creates the first tile — the bug was that `null` silently swallowed the + * star forever, and File → Import queries had no membership sync at all; + * see `syncFavoriteTileMembership` below for the import-side fix). + * - `dashboard` null in + star ON on a filter/setup-role query, or star OFF + * with `dashboard` null → `null` out unchanged (nothing to create or + * remove from). * * The result is always run through the ACTIVE layout engine's own * `normalize` (#291: flow@1 or grafana-grid@1, resolved from the document's @@ -57,25 +81,69 @@ function removeTilesForQuery(dashboard: DashboardDocumentV1, queryId: string): D * regenerated too (#291 "every grid mutation regenerates the flow@1 * fallback"; a no-op under flow@1) — this membership change adds/removes a * tile just like the authoring commands do. The result is always a fresh - * copy; `dashboard` is never mutated. + * copy; `dashboard` is never mutated. `genDashboardId` is only ever called + * when a Dashboard must be created from nothing (defaults to `genTileId` — + * tile and Dashboard ids share the same id space in production, `uid('ws-')` + * off `app.genId`, so one generator covers both). */ export function toggleTileMembership( dashboard: DashboardDocumentV1 | null, query: SavedQueryV2, favorite: boolean, genTileId: () => string, + genDashboardId: () => string = genTileId, ): DashboardDocumentV1 | null { - if (!dashboard) return null; - const hasTile = dashboard.tiles.some((tile) => tile.queryId === query.id); - let next = dashboard; + let base = dashboard; + if (!base) { + if (!favorite || queryDashboardRole(query) !== 'panel') return null; + base = createEmptyDashboardDocument(genDashboardId()); + } + const hasTile = base.tiles.some((tile) => tile.queryId === query.id); + let next = base; if (favorite) { if (!hasTile && queryDashboardRole(query) === 'panel') { - next = { ...dashboard, tiles: [...dashboard.tiles, { id: genTileId(), queryId: query.id }] }; + next = { ...base, tiles: [...base.tiles, { id: genTileId(), queryId: query.id }] }; } } else if (hasTile) { - next = removeTilesForQuery(dashboard, query.id); + next = removeTilesForQuery(base, query.id); } const normalized = resolveLayoutPluginSync(next.layout).normalize(next); regenerateGridFallback(normalized.layout, normalized.tiles); return normalized; } + +/** + * Sync Dashboard tile membership for EVERY currently-favorited panel-role + * query at once (#307 "File → Import queries has no membership sync"): + * additive-only — appends `{ id: genTileId(), queryId }` for each favorited + * panel-role query with no existing tile, in `queries` order; never removes + * a tile for an unfavorited query (unlike `toggleTileMembership`'s star-OFF + * path, this is not a single flip — running it must never destroy tile + * membership an existing Dashboard already declared for other reasons). + * Creates a fresh empty Dashboard (via `createEmptyDashboardDocument`) when + * `dashboard` is null and at least one tile needs to be added; stays `null` + * when nothing qualifies. Idempotent — a second call with the same inputs is + * a no-op. Ends with the same normalize + `regenerateGridFallback` tail as + * `toggleTileMembership`, so layout stays consistent either way. + */ +export function syncFavoriteTileMembership( + dashboard: DashboardDocumentV1 | null, + queries: readonly SavedQueryV2[], + genTileId: () => string, + genDashboardId: () => string = genTileId, +): DashboardDocumentV1 | null { + const missing = queries.filter((query) => ( + queryFavorite(query) + && queryDashboardRole(query) === 'panel' + && !dashboard?.tiles.some((tile) => tile.queryId === query.id) + )); + if (!missing.length) return dashboard; + const base = dashboard ?? createEmptyDashboardDocument(genDashboardId()); + const next: DashboardDocumentV1 = { + ...base, + tiles: [...base.tiles, ...missing.map((query) => ({ id: genTileId(), queryId: query.id }))], + }; + const normalized = resolveLayoutPluginSync(next.layout).normalize(next); + regenerateGridFallback(normalized.layout, normalized.tiles); + return normalized; +} diff --git a/src/env.types.ts b/src/env.types.ts index b51fc3c3..a8f391ed 100644 --- a/src/env.types.ts +++ b/src/env.types.ts @@ -53,6 +53,13 @@ export interface CreateAppEnv { * `{ clipboard: { writeText } }` or omits the field entirely. */ navigator?: { clipboard?: Clipboard } & Record; download?: (filename: string, mime: string, content: BlobPart) => void; + /** Object-URL seam for the Image (PNG) result view (#307) — `Blob`/`URL` + * are real-browser-only (no happy-dom support), so tests always inject a + * fake pair. Real fallback (inside `createApp`) is + * `URL.createObjectURL(new Blob([bytes], {type: mime}))` / + * `URL.revokeObjectURL(url)`. */ + createObjectUrl?: (bytes: Uint8Array, mime: string) => string; + revokeObjectUrl?: (url: string) => void; handoffMs?: number; handoffListenMs?: number; } diff --git a/src/generated/json-schema-validators.js b/src/generated/json-schema-validators.js index 9bab9120..0a7b83e2 100644 --- a/src/generated/json-schema-validators.js +++ b/src/generated/json-schema-validators.js @@ -285,10 +285,11 @@ var require_formats = __commonJS({ // json-schema-standalone.js var validateQuerySpecV1 = validate20; -var schema31 = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://altinity.com/schemas/altinity-sql-browser/query-spec-v1.schema.json", "title": "Altinity SQL Browser saved-query Spec v1", "description": "The user-authored query.spec document. Saved-query envelope fields are intentionally outside this schema.", "x-altinity-kind": "query-spec", "x-altinity-version": 1, "type": "object", "properties": { "name": { "title": "Name", "description": "Panel, tile, and Library title.", "type": "string", "minLength": 1, "pattern": "\\S", "examples": ["Revenue by country"] }, "description": { "title": "Description", "description": "Optional authoring note shown with the saved query.", "type": "string" }, "favorite": { "title": "Favorite", "description": "Whether the query is included in favorite-driven surfaces.", "type": "boolean", "default": false }, "view": { "title": "Preferred result view", "description": "The result representation restored when the saved query opens.", "type": "string", "enum": ["table", "json", "panel"], "default": "table" }, "panel": { "$ref": "#/$defs/panel" }, "dashboard": { "$ref": "#/$defs/queryDashboardPresentationV1" } }, "additionalProperties": true, "x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard"], "$defs": { "columnName": { "title": "Result column", "description": "Exact top-level ClickHouse result-column name.", "type": "string", "minLength": 1, "x-altinity-completion": { "source": "resultColumns" } }, "resultColumnIndex": { "title": "Result column index", "description": "Zero-based index of a ClickHouse result column.", "type": "integer", "minimum": 0, "x-altinity-completion": { "source": "resultColumnIndexes" } }, "deltaPresentation": { "title": "Delta presentation", "description": "Display metadata for a runtime KPI delta value.", "type": "object", "properties": { "displayName": { "title": "Delta label", "description": "Optional visible label for the delta.", "type": "string" }, "unit": { "title": "Delta unit", "description": "Display-only suffix appended to the delta.", "type": "string" }, "decimals": { "title": "Delta decimal places", "description": "Requested display rounding for the delta.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [1] }, "positiveIsGood": { "title": "Positive is good", "description": "Whether a positive runtime delta has good semantics.", "type": "boolean" }, "show": { "title": "Show delta", "description": "Whether a present runtime delta is rendered.", "type": "boolean", "default": true } }, "additionalProperties": true, "x-altinity-order": ["displayName", "unit", "decimals", "positiveIsGood", "show"] }, "fieldConfigValue": { "title": "Field presentation metadata", "description": "Known presentation metadata for one result column. Unknown renderer extensions are retained.", "type": "object", "properties": { "displayName": { "title": "Display name", "description": "Rendered label for the field.", "type": "string" }, "decimals": { "title": "Decimal places", "description": "Requested number of decimal places for numeric display.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [2] }, "description": { "title": "Description", "description": "Supporting display text for the field.", "type": "string" }, "unit": { "title": "Unit", "description": "Display-only suffix appended to the value.", "type": "string", "examples": ["%"] }, "color": { "title": "Color", "description": "Theme token or CSS color hint interpreted by the renderer.", "type": "string" }, "noValue": { "title": "No-value text", "description": "Text shown for NULL or unavailable values.", "type": "string", "default": "\u2014" }, "hidden": { "title": "Hidden", "description": "Suppress this otherwise eligible result field.", "type": "boolean", "default": false }, "delta": { "$ref": "#/$defs/deltaPresentation" } }, "additionalProperties": true, "x-altinity-order": ["displayName", "description", "unit", "decimals", "color", "noValue", "hidden", "delta"] }, "fieldConfig": { "title": "Panel field configuration", "description": "Default and per-column display metadata.", "type": "object", "properties": { "defaults": { "$ref": "#/$defs/fieldConfigValue" }, "columns": { "title": "Column overrides", "description": "Display metadata keyed by exact result-column name.", "type": "object", "additionalProperties": { "$ref": "#/$defs/fieldConfigValue" }, "x-altinity-key-completion": { "source": "resultColumns" } } }, "additionalProperties": true, "x-altinity-order": ["defaults", "columns"] }, "queryPresentationPatchV1": { "title": "Presentation patch", "description": "RFC 7396 JSON Merge Patch applied over the saved query's base panel object. Object members merge recursively, arrays replace the previous array completely, and a null member deletes an optional property. A patch may not change panel.cfg.type, and the final resolved panel must validate like a normal saved-query panel.", "type": "object", "additionalProperties": true }, "dashboardSizeHints": { "title": "Size hints", "description": "Layout-neutral tile size hints. Authoring may derive an initial placement from them; they never own actual Dashboard placement.", "type": "object", "properties": { "preferred": { "title": "Preferred size", "description": "Preferred layout-neutral tile size.", "type": "string", "enum": ["compact", "medium", "wide"] }, "minimum": { "title": "Minimum size", "description": "Smallest layout-neutral tile size that still renders usefully.", "type": "string", "enum": ["compact", "medium", "wide"] }, "aspectRatio": { "title": "Aspect ratio", "description": "Preferred width/height ratio hint.", "type": "number", "exclusiveMinimum": 0 } }, "additionalProperties": true, "x-altinity-order": ["preferred", "minimum", "aspectRatio"] }, "queryDashboardPresentationV1": { "title": "Dashboard configuration", "description": "Reusable Dashboard presentation for this query: role, named presentation variants, and layout-neutral size hints. Actual Dashboard sequence, placement, and tile-local overrides live in the Dashboard document. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { "title": "Dashboard role (Panel, Filter, or Setup)", "description": "How the saved query participates in a dashboard. panel (the default) creates a visualization tile. filter returns exactly one row whose supported top-level Array, named Tuple Array, or Map columns provide option lists for parameters with the same exact names; it creates no tile and its SQL cannot declare parameters. setup is reserved for serialized Dashboard setup execution.", "type": "string", "enum": ["panel", "filter", "setup"], "default": "panel", "examples": ["filter"] }, "defaultVariant": { "title": "Default presentation variant", "description": "Variant applied when a tile does not select one. Must name an existing entry in variants.", "type": "string", "minLength": 1, "maxLength": 256 }, "variants": { "title": "Presentation variants", "description": "Named reusable presentation patches, each an RFC 7396 JSON Merge Patch over the base panel.", "type": "object", "maxProperties": 32, "propertyNames": { "minLength": 1, "maxLength": 256 }, "additionalProperties": { "$ref": "#/$defs/queryPresentationPatchV1" } }, "sizeHints": { "$ref": "#/$defs/dashboardSizeHints" } }, "additionalProperties": true, "x-altinity-order": ["role", "defaultVariant", "variants", "sizeHints"] }, "panel": { "title": "Panel configuration", "description": "Visualization and field metadata for the saved query.", "type": "object", "properties": { "cfg": { "$ref": "#/$defs/panelCfg" }, "key": { "title": "Result schema key", "description": "Saved result-column signature used to detect stale positional roles.", "type": ["string", "null"] }, "fieldConfig": { "$ref": "#/$defs/fieldConfig" } }, "additionalProperties": true, "x-altinity-order": ["cfg", "key", "fieldConfig"] }, "lineChartStyle": { "title": "Line style", "description": "Renderer-independent line presentation. Unknown fields and future string values remain storable.", "type": "object", "properties": { "curve": { "title": "Line curve", "description": "linear draws straight segments, smooth uses monotone interpolation, and stepped draws step-after segments.", "anyOf": [{ "type": "string", "enum": ["linear", "smooth", "stepped"] }, { "type": "string" }], "default": "linear" }, "points": { "title": "Point markers", "description": "auto shows markers only for sparse results, show always displays them, and hide retains hover targets without visible markers.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "scale": { "title": "Value scale", "description": "zero anchors the value axis at zero, data uses the data range, and auto uses the chart-type default.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "data" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["curve", "points", "scale", "legend", "grid", "axes"] }, "areaChartStyle": { "title": "Area style", "description": "Curve, marker, and additive stacking presentation for Area charts.", "allOf": [{ "$ref": "#/$defs/lineChartStyle" }, { "type": "object", "properties": { "stack": { "title": "Area stacking", "description": "overlay draws series independently; stacked uses one shared additive stack without normalization.", "anyOf": [{ "type": "string", "enum": ["overlay", "stacked"] }, { "type": "string" }], "default": "overlay" } }, "additionalProperties": true }], "x-altinity-order": ["curve", "points", "stack", "scale", "legend", "grid", "axes"] }, "barChartStyle": { "title": "Bar and Column style", "description": "Grouping and category-spacing presentation shared by horizontal Bar and vertical Column charts.", "type": "object", "properties": { "mode": { "title": "Bar grouping", "description": "grouped draws measures side by side; stacked adds them on one shared value stack.", "anyOf": [{ "type": "string", "enum": ["grouped", "stacked"] }, { "type": "string" }], "default": "grouped" }, "density": { "title": "Category spacing", "description": "normal uses standard spacing, compact reduces gaps, and joined removes category gaps.", "anyOf": [{ "type": "string", "enum": ["normal", "compact", "joined"] }, { "type": "string" }], "default": "normal" }, "scale": { "title": "Value scale", "description": "zero and auto anchor Bar/Column at zero; data uses the data range.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "zero" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["mode", "density", "scale", "legend", "grid", "axes"] }, "pieChartStyle": { "title": "Pie style", "description": "Pie or Donut shape presentation.", "type": "object", "properties": { "shape": { "title": "Pie shape", "description": "pie fills the center; donut uses a fixed 60% cutout.", "anyOf": [{ "type": "string", "enum": ["pie", "donut"] }, { "type": "string" }], "default": "pie" }, "legend": { "title": "Legend visibility", "description": "show renders the slice legend; hide relies on tooltips.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "show" }, "frame": { "title": "Chart frame", "description": "compact reduces Pie layout padding; normal retains the standard frame.", "anyOf": [{ "type": "string", "enum": ["normal", "compact"] }, { "type": "string" }], "default": "normal" } }, "additionalProperties": true, "x-altinity-order": ["shape", "legend", "frame"] }, "chartCfg": { "type": "object", "properties": { "x": { "$ref": "#/$defs/resultColumnIndex", "default": 0 }, "y": { "title": "Measure columns", "description": "One or more zero-based result-column indexes used as measures.", "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/resultColumnIndex" } }, "series": { "title": "Series column", "description": "Optional zero-based result-column index used to split series.", "oneOf": [{ "$ref": "#/$defs/resultColumnIndex" }, { "type": "null" }], "default": null, "x-altinity-completion": { "source": "resultColumnIndexes" } } }, "required": ["x", "y"], "additionalProperties": true, "x-altinity-order": ["type", "x", "y", "series"] }, "styledChartCfg": { "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "type": "object", "properties": { "style": { "type": "object" } } }], "x-altinity-order": ["type", "style", "x", "y", "series"] }, "panelCfg": { "title": "Panel type configuration", "description": "Discriminated visualization configuration. Unknown types remain storable for forward compatibility.", "type": "object", "required": ["type"], "properties": { "type": { "title": "Panel type", "description": "Visualization renderer identifier.", "type": "string", "minLength": 1 } }, "additionalProperties": true, "x-altinity-discriminator": "type", "x-altinity-order": ["type"], "oneOf": [{ "title": "Column chart", "description": "Vertical columns using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "bar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "bar" } }, "required": ["type"] }] }, { "title": "Horizontal bar chart", "description": "Horizontal bars using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "hbar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "hbar" } }, "required": ["type"] }] }, { "title": "Line chart", "description": "Line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "line", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/lineChartStyle" } } }, { "properties": { "type": { "const": "line" } }, "required": ["type"] }] }, { "title": "Area chart", "description": "Filled line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "area", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/areaChartStyle" } } }, { "properties": { "type": { "const": "area" } }, "required": ["type"] }] }, { "title": "Pie chart", "description": "Pie slices using one positional measure role.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "pie", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/pieChartStyle" } } }, { "properties": { "type": { "const": "pie" }, "y": { "type": "array", "maxItems": 1 } }, "required": ["type"] }] }, { "title": "KPI", "description": "One-row scalar and named-tuple KPI cards.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "kpi" }, "properties": { "type": { "const": "kpi" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type"] }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "table" }, "properties": { "type": { "const": "table" } }, "required": ["type"], "additionalProperties": true }, { "title": "Logs", "description": "Timestamped log messages with optional explicit result-column roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "logs", "time": "event_time", "msg": "message", "level": "level" }, "properties": { "type": { "const": "logs" }, "time": { "$ref": "#/$defs/columnName" }, "msg": { "$ref": "#/$defs/columnName" }, "level": { "$ref": "#/$defs/columnName" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "time", "msg", "level"] }, { "title": "Markdown text", "description": "Safe Markdown content that does not require a SQL result.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "text", "content": "# Heading\n\nMarkdown content." }, "properties": { "type": { "const": "text" }, "content": { "title": "Markdown content", "description": "Source text for the safe Markdown renderer.", "type": "string", "default": "" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "content"] }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", "x-altinity-status": "planned", "x-altinity-snippet": { "type": "future-panel" }, "properties": { "type": { "type": "string", "minLength": 1, "not": { "enum": ["bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text"] } } }, "required": ["type"], "additionalProperties": true }] } } }; +var schema31 = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://altinity.com/schemas/altinity-sql-browser/query-spec-v1.schema.json", "title": "Altinity SQL Browser saved-query Spec v1", "description": "The user-authored query.spec document. Saved-query envelope fields are intentionally outside this schema.", "x-altinity-kind": "query-spec", "x-altinity-version": 1, "type": "object", "properties": { "name": { "title": "Name", "description": "Panel, tile, and Library title.", "type": "string", "minLength": 1, "pattern": "\\S", "examples": ["Revenue by country"] }, "description": { "title": "Description", "description": "Optional authoring note shown with the saved query.", "type": "string" }, "favorite": { "title": "Favorite", "description": "Whether the query is included in favorite-driven surfaces.", "type": "boolean", "default": false }, "view": { "title": "Preferred result view", "description": "The result representation restored when the saved query opens.", "type": "string", "enum": ["table", "json", "panel"], "default": "table" }, "panel": { "$ref": "#/$defs/panel" }, "dashboard": { "$ref": "#/$defs/queryDashboardPresentationV1" } }, "additionalProperties": true, "x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard"], "$defs": { "columnName": { "title": "Result column", "description": "Exact top-level ClickHouse result-column name.", "type": "string", "minLength": 1, "x-altinity-completion": { "source": "resultColumns" } }, "resultColumnIndex": { "title": "Result column index", "description": "Zero-based index of a ClickHouse result column.", "type": "integer", "minimum": 0, "x-altinity-completion": { "source": "resultColumnIndexes" } }, "deltaPresentation": { "title": "Delta presentation", "description": "Display metadata for a runtime KPI delta value.", "type": "object", "properties": { "displayName": { "title": "Delta label", "description": "Optional visible label for the delta.", "type": "string" }, "unit": { "title": "Delta unit", "description": "Display-only suffix appended to the delta.", "type": "string" }, "decimals": { "title": "Delta decimal places", "description": "Requested display rounding for the delta.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [1] }, "positiveIsGood": { "title": "Positive is good", "description": "Whether a positive runtime delta has good semantics.", "type": "boolean" }, "show": { "title": "Show delta", "description": "Whether a present runtime delta is rendered.", "type": "boolean", "default": true } }, "additionalProperties": true, "x-altinity-order": ["displayName", "unit", "decimals", "positiveIsGood", "show"] }, "fieldConfigValue": { "title": "Field presentation metadata", "description": "Known presentation metadata for one result column. Unknown renderer extensions are retained.", "type": "object", "properties": { "displayName": { "title": "Display name", "description": "Rendered label for the field.", "type": "string" }, "decimals": { "title": "Decimal places", "description": "Requested number of decimal places for numeric display.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [2] }, "description": { "title": "Description", "description": "Supporting display text for the field.", "type": "string" }, "unit": { "title": "Unit", "description": "Display-only suffix appended to the value.", "type": "string", "examples": ["%"] }, "color": { "title": "Color", "description": "Theme token or CSS color hint interpreted by the renderer.", "type": "string" }, "noValue": { "title": "No-value text", "description": "Text shown for NULL or unavailable values.", "type": "string", "default": "\u2014" }, "hidden": { "title": "Hidden", "description": "Suppress this otherwise eligible result field.", "type": "boolean", "default": false }, "delta": { "$ref": "#/$defs/deltaPresentation" } }, "additionalProperties": true, "x-altinity-order": ["displayName", "description", "unit", "decimals", "color", "noValue", "hidden", "delta"] }, "fieldConfig": { "title": "Panel field configuration", "description": "Default and per-column display metadata.", "type": "object", "properties": { "defaults": { "$ref": "#/$defs/fieldConfigValue" }, "columns": { "title": "Column overrides", "description": "Display metadata keyed by exact result-column name.", "type": "object", "additionalProperties": { "$ref": "#/$defs/fieldConfigValue" }, "x-altinity-key-completion": { "source": "resultColumns" } } }, "additionalProperties": true, "x-altinity-order": ["defaults", "columns"] }, "queryPresentationPatchV1": { "title": "Presentation patch", "description": "RFC 7396 JSON Merge Patch applied over the saved query's base panel object. Object members merge recursively, arrays replace the previous array completely, and a null member deletes an optional property. A patch may not change panel.cfg.type, and the final resolved panel must validate like a normal saved-query panel.", "type": "object", "additionalProperties": true }, "dashboardSizeHints": { "title": "Size hints", "description": "Layout-neutral tile size hints. Authoring may derive an initial placement from them; they never own actual Dashboard placement.", "type": "object", "properties": { "preferred": { "title": "Preferred size", "description": "Preferred layout-neutral tile size.", "type": "string", "enum": ["compact", "medium", "wide"] }, "minimum": { "title": "Minimum size", "description": "Smallest layout-neutral tile size that still renders usefully.", "type": "string", "enum": ["compact", "medium", "wide"] }, "aspectRatio": { "title": "Aspect ratio", "description": "Preferred width/height ratio hint.", "type": "number", "exclusiveMinimum": 0 } }, "additionalProperties": true, "x-altinity-order": ["preferred", "minimum", "aspectRatio"] }, "queryDashboardPresentationV1": { "title": "Dashboard configuration", "description": "Reusable Dashboard presentation for this query: role, named presentation variants, and layout-neutral size hints. Actual Dashboard sequence, placement, and tile-local overrides live in the Dashboard document. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { "title": "Dashboard role (Panel, Filter, or Setup)", "description": "How the saved query participates in a dashboard. panel (the default) creates a visualization tile. filter returns exactly one row whose supported top-level Array, named Tuple Array, or Map columns provide option lists for parameters with the same exact names; it creates no tile and its SQL cannot declare parameters. setup is reserved for serialized Dashboard setup execution.", "type": "string", "enum": ["panel", "filter", "setup"], "default": "panel", "examples": ["filter"] }, "defaultVariant": { "title": "Default presentation variant", "description": "Variant applied when a tile does not select one. Must name an existing entry in variants.", "type": "string", "minLength": 1, "maxLength": 256 }, "variants": { "title": "Presentation variants", "description": "Named reusable presentation patches, each an RFC 7396 JSON Merge Patch over the base panel.", "type": "object", "maxProperties": 32, "propertyNames": { "minLength": 1, "maxLength": 256 }, "additionalProperties": { "$ref": "#/$defs/queryPresentationPatchV1" } }, "sizeHints": { "$ref": "#/$defs/dashboardSizeHints" } }, "additionalProperties": true, "x-altinity-order": ["role", "defaultVariant", "variants", "sizeHints"] }, "panel": { "title": "Panel configuration", "description": "Visualization and field metadata for the saved query.", "type": "object", "properties": { "cfg": { "$ref": "#/$defs/panelCfg" }, "key": { "title": "Result schema key", "description": "Saved result-column signature used to detect stale positional roles.", "type": ["string", "null"] }, "fieldConfig": { "$ref": "#/$defs/fieldConfig" } }, "additionalProperties": true, "x-altinity-order": ["cfg", "key", "fieldConfig"] }, "lineChartStyle": { "title": "Line style", "description": "Renderer-independent line presentation. Unknown fields and future string values remain storable.", "type": "object", "properties": { "curve": { "title": "Line curve", "description": "linear draws straight segments, smooth uses monotone interpolation, and stepped draws step-after segments.", "anyOf": [{ "type": "string", "enum": ["linear", "smooth", "stepped"] }, { "type": "string" }], "default": "linear" }, "points": { "title": "Point markers", "description": "auto shows markers only for sparse results, show always displays them, and hide retains hover targets without visible markers.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "scale": { "title": "Value scale", "description": "zero anchors the value axis at zero, data uses the data range, and auto uses the chart-type default.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "data" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["curve", "points", "scale", "legend", "grid", "axes"] }, "areaChartStyle": { "title": "Area style", "description": "Curve, marker, and additive stacking presentation for Area charts.", "allOf": [{ "$ref": "#/$defs/lineChartStyle" }, { "type": "object", "properties": { "stack": { "title": "Area stacking", "description": "overlay draws series independently; stacked uses one shared additive stack without normalization.", "anyOf": [{ "type": "string", "enum": ["overlay", "stacked"] }, { "type": "string" }], "default": "overlay" } }, "additionalProperties": true }], "x-altinity-order": ["curve", "points", "stack", "scale", "legend", "grid", "axes"] }, "barChartStyle": { "title": "Bar and Column style", "description": "Grouping and category-spacing presentation shared by horizontal Bar and vertical Column charts.", "type": "object", "properties": { "mode": { "title": "Bar grouping", "description": "grouped draws measures side by side; stacked adds them on one shared value stack.", "anyOf": [{ "type": "string", "enum": ["grouped", "stacked"] }, { "type": "string" }], "default": "grouped" }, "density": { "title": "Category spacing", "description": "normal uses standard spacing, compact reduces gaps, and joined removes category gaps.", "anyOf": [{ "type": "string", "enum": ["normal", "compact", "joined"] }, { "type": "string" }], "default": "normal" }, "scale": { "title": "Value scale", "description": "zero and auto anchor Bar/Column at zero; data uses the data range.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "zero" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["mode", "density", "scale", "legend", "grid", "axes"] }, "pieChartStyle": { "title": "Pie style", "description": "Pie or Donut shape presentation.", "type": "object", "properties": { "shape": { "title": "Pie shape", "description": "pie fills the center; donut uses a fixed 60% cutout.", "anyOf": [{ "type": "string", "enum": ["pie", "donut"] }, { "type": "string" }], "default": "pie" }, "legend": { "title": "Legend visibility", "description": "show renders the slice legend; hide relies on tooltips.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "show" }, "frame": { "title": "Chart frame", "description": "compact reduces Pie layout padding; normal retains the standard frame.", "anyOf": [{ "type": "string", "enum": ["normal", "compact"] }, { "type": "string" }], "default": "normal" } }, "additionalProperties": true, "x-altinity-order": ["shape", "legend", "frame"] }, "chartCfg": { "type": "object", "properties": { "x": { "$ref": "#/$defs/resultColumnIndex", "default": 0 }, "y": { "title": "Measure columns", "description": "One or more zero-based result-column indexes used as measures.", "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/resultColumnIndex" } }, "series": { "title": "Series column", "description": "Optional zero-based result-column index used to split series.", "oneOf": [{ "$ref": "#/$defs/resultColumnIndex" }, { "type": "null" }], "default": null, "x-altinity-completion": { "source": "resultColumnIndexes" } } }, "required": ["x", "y"], "additionalProperties": true, "x-altinity-order": ["type", "x", "y", "series"] }, "styledChartCfg": { "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "type": "object", "properties": { "style": { "type": "object" } } }], "x-altinity-order": ["type", "style", "x", "y", "series"] }, "panelCfg": { "title": "Panel type configuration", "description": "Discriminated visualization configuration. Unknown types remain storable for forward compatibility.", "type": "object", "required": ["type"], "properties": { "type": { "title": "Panel type", "description": "Visualization renderer identifier.", "type": "string", "minLength": 1 } }, "additionalProperties": true, "x-altinity-discriminator": "type", "x-altinity-order": ["type"], "oneOf": [{ "title": "Column chart", "description": "Vertical columns using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "bar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "bar" } }, "required": ["type"] }] }, { "title": "Horizontal bar chart", "description": "Horizontal bars using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "hbar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "hbar" } }, "required": ["type"] }] }, { "title": "Line chart", "description": "Line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "line", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/lineChartStyle" } } }, { "properties": { "type": { "const": "line" } }, "required": ["type"] }] }, { "title": "Area chart", "description": "Filled line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "area", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/areaChartStyle" } } }, { "properties": { "type": { "const": "area" } }, "required": ["type"] }] }, { "title": "Pie chart", "description": "Pie slices using one positional measure role.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "pie", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/pieChartStyle" } } }, { "properties": { "type": { "const": "pie" }, "y": { "type": "array", "maxItems": 1 } }, "required": ["type"] }] }, { "title": "KPI", "description": "One-row scalar and named-tuple KPI cards.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "kpi" }, "properties": { "type": { "const": "kpi" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type"] }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "table" }, "properties": { "type": { "const": "table" } }, "required": ["type"], "additionalProperties": true }, { "title": "Logs", "description": "Timestamped log messages with optional explicit result-column roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "logs", "time": "event_time", "msg": "message", "level": "level" }, "properties": { "type": { "const": "logs" }, "time": { "$ref": "#/$defs/columnName" }, "msg": { "$ref": "#/$defs/columnName" }, "level": { "$ref": "#/$defs/columnName" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "time", "msg", "level"] }, { "title": "Markdown text", "description": "Safe Markdown content that does not require a SQL result.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "text", "content": "# Heading\n\nMarkdown content." }, "properties": { "type": { "const": "text" }, "content": { "title": "Markdown content", "description": "Source text for the safe Markdown renderer.", "type": "string", "default": "" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "content"] }, { "title": "Image", "description": "A single ClickHouse `FORMAT PNG` result rendered as an image. The authored SQL must end in an explicit FORMAT PNG clause.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "image" }, "properties": { "type": { "const": "image" }, "fit": { "title": "Image fit", "description": "contain scales the image to fit within the tile preserving aspect ratio, cover fills the tile and crops overflow, and actual renders at intrinsic CSS pixel size in a scrollable body.", "type": "string", "enum": ["contain", "cover", "actual"], "default": "contain" }, "background": { "title": "Image background", "description": "theme uses the surrounding surface color, transparent shows nothing behind the image, and checkerboard renders a checkerboard pattern (useful for a transparent PNG).", "type": "string", "enum": ["transparent", "checkerboard", "theme"], "default": "theme" }, "alt": { "title": "Alt text", "description": "Accessible description for the image. Falls back to the tile/query title when absent.", "type": "string" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "fit", "background", "alt"] }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", "x-altinity-status": "planned", "x-altinity-snippet": { "type": "future-panel" }, "properties": { "type": { "type": "string", "minLength": 1, "not": { "enum": ["bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text", "image"] } } }, "required": ["type"], "additionalProperties": true }] } } }; var func1 = require_ucs2length().default; var pattern4 = new RegExp("\\S", "u"); var schema32 = { "title": "Panel configuration", "description": "Visualization and field metadata for the saved query.", "type": "object", "properties": { "cfg": { "$ref": "#/$defs/panelCfg" }, "key": { "title": "Result schema key", "description": "Saved result-column signature used to detect stale positional roles.", "type": ["string", "null"] }, "fieldConfig": { "$ref": "#/$defs/fieldConfig" } }, "additionalProperties": true, "x-altinity-order": ["cfg", "key", "fieldConfig"] }; +var schema33 = { "title": "Panel type configuration", "description": "Discriminated visualization configuration. Unknown types remain storable for forward compatibility.", "type": "object", "required": ["type"], "properties": { "type": { "title": "Panel type", "description": "Visualization renderer identifier.", "type": "string", "minLength": 1 } }, "additionalProperties": true, "x-altinity-discriminator": "type", "x-altinity-order": ["type"], "oneOf": [{ "title": "Column chart", "description": "Vertical columns using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "bar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "bar" } }, "required": ["type"] }] }, { "title": "Horizontal bar chart", "description": "Horizontal bars using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "hbar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "hbar" } }, "required": ["type"] }] }, { "title": "Line chart", "description": "Line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "line", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/lineChartStyle" } } }, { "properties": { "type": { "const": "line" } }, "required": ["type"] }] }, { "title": "Area chart", "description": "Filled line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "area", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/areaChartStyle" } } }, { "properties": { "type": { "const": "area" } }, "required": ["type"] }] }, { "title": "Pie chart", "description": "Pie slices using one positional measure role.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "pie", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/pieChartStyle" } } }, { "properties": { "type": { "const": "pie" }, "y": { "type": "array", "maxItems": 1 } }, "required": ["type"] }] }, { "title": "KPI", "description": "One-row scalar and named-tuple KPI cards.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "kpi" }, "properties": { "type": { "const": "kpi" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type"] }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "table" }, "properties": { "type": { "const": "table" } }, "required": ["type"], "additionalProperties": true }, { "title": "Logs", "description": "Timestamped log messages with optional explicit result-column roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "logs", "time": "event_time", "msg": "message", "level": "level" }, "properties": { "type": { "const": "logs" }, "time": { "$ref": "#/$defs/columnName" }, "msg": { "$ref": "#/$defs/columnName" }, "level": { "$ref": "#/$defs/columnName" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "time", "msg", "level"] }, { "title": "Markdown text", "description": "Safe Markdown content that does not require a SQL result.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "text", "content": "# Heading\n\nMarkdown content." }, "properties": { "type": { "const": "text" }, "content": { "title": "Markdown content", "description": "Source text for the safe Markdown renderer.", "type": "string", "default": "" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "content"] }, { "title": "Image", "description": "A single ClickHouse `FORMAT PNG` result rendered as an image. The authored SQL must end in an explicit FORMAT PNG clause.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "image" }, "properties": { "type": { "const": "image" }, "fit": { "title": "Image fit", "description": "contain scales the image to fit within the tile preserving aspect ratio, cover fills the tile and crops overflow, and actual renders at intrinsic CSS pixel size in a scrollable body.", "type": "string", "enum": ["contain", "cover", "actual"], "default": "contain" }, "background": { "title": "Image background", "description": "theme uses the surrounding surface color, transparent shows nothing behind the image, and checkerboard renders a checkerboard pattern (useful for a transparent PNG).", "type": "string", "enum": ["transparent", "checkerboard", "theme"], "default": "theme" }, "alt": { "title": "Alt text", "description": "Accessible description for the image. Falls back to the tile/query title when absent.", "type": "string" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "fit", "background", "alt"] }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", "x-altinity-status": "planned", "x-altinity-snippet": { "type": "future-panel" }, "properties": { "type": { "type": "string", "minLength": 1, "not": { "enum": ["bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text", "image"] } } }, "required": ["type"], "additionalProperties": true }] }; var schema39 = { "title": "Bar and Column style", "description": "Grouping and category-spacing presentation shared by horizontal Bar and vertical Column charts.", "type": "object", "properties": { "mode": { "title": "Bar grouping", "description": "grouped draws measures side by side; stacked adds them on one shared value stack.", "anyOf": [{ "type": "string", "enum": ["grouped", "stacked"] }, { "type": "string" }], "default": "grouped" }, "density": { "title": "Category spacing", "description": "normal uses standard spacing, compact reduces gaps, and joined removes category gaps.", "anyOf": [{ "type": "string", "enum": ["normal", "compact", "joined"] }, { "type": "string" }], "default": "normal" }, "scale": { "title": "Value scale", "description": "zero and auto anchor Bar/Column at zero; data uses the data range.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "zero" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["mode", "density", "scale", "legend", "grid", "axes"] }; var schema41 = { "title": "Line style", "description": "Renderer-independent line presentation. Unknown fields and future string values remain storable.", "type": "object", "properties": { "curve": { "title": "Line curve", "description": "linear draws straight segments, smooth uses monotone interpolation, and stepped draws step-after segments.", "anyOf": [{ "type": "string", "enum": ["linear", "smooth", "stepped"] }, { "type": "string" }], "default": "linear" }, "points": { "title": "Point markers", "description": "auto shows markers only for sparse results, show always displays them, and hide retains hover targets without visible markers.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "scale": { "title": "Value scale", "description": "zero anchors the value axis at zero, data uses the data range, and auto uses the chart-type default.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "data" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["curve", "points", "scale", "legend", "grid", "axes"] }; var schema44 = { "title": "Pie style", "description": "Pie or Donut shape presentation.", "type": "object", "properties": { "shape": { "title": "Pie shape", "description": "pie fills the center; donut uses a fixed 60% cutout.", "anyOf": [{ "type": "string", "enum": ["pie", "donut"] }, { "type": "string" }], "default": "pie" }, "legend": { "title": "Legend visibility", "description": "show renders the slice legend; hide relies on tooltips.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "show" }, "frame": { "title": "Chart frame", "description": "compact reduces Pie layout padding; normal retains the standard frame.", "anyOf": [{ "type": "string", "enum": ["normal", "compact"] }, { "type": "string" }], "default": "normal" } }, "additionalProperties": true, "x-altinity-order": ["shape", "legend", "frame"] }; @@ -2656,11 +2657,8 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r errors++; } if (data.type !== void 0) { - let data40 = data.type; - const _errs199 = errors; - const _errs200 = errors; - if (!(data40 === "bar" || data40 === "hbar" || data40 === "line" || data40 === "area" || data40 === "pie" || data40 === "kpi" || data40 === "table" || data40 === "logs" || data40 === "text")) { - const err116 = {}; + if ("image" !== data.type) { + const err116 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/9/properties/type/const", keyword: "const", params: { allowedValue: "image" }, message: "must be equal to constant" }; if (vErrors === null) { vErrors = [err116]; } else { @@ -2668,37 +2666,32 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - var valid53 = _errs200 === errors; - if (valid53) { - const err117 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/9/properties/type/not", keyword: "not", params: {}, message: "must NOT be valid" }; + } + if (data.fit !== void 0) { + let data41 = data.fit; + if (typeof data41 !== "string") { + const err117 = { instancePath: instancePath + "/fit", schemaPath: "#/oneOf/9/properties/fit/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { vErrors = [err117]; } else { vErrors.push(err117); } errors++; - } else { - errors = _errs199; - if (vErrors !== null) { - if (_errs199) { - vErrors.length = _errs199; - } else { - vErrors = null; - } - } } - if (typeof data40 === "string") { - if (func1(data40) < 1) { - const err118 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/9/properties/type/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; - if (vErrors === null) { - vErrors = [err118]; - } else { - vErrors.push(err118); - } - errors++; + if (!(data41 === "contain" || data41 === "cover" || data41 === "actual")) { + const err118 = { instancePath: instancePath + "/fit", schemaPath: "#/oneOf/9/properties/fit/enum", keyword: "enum", params: { allowedValues: schema33.oneOf[9].properties.fit.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err118]; + } else { + vErrors.push(err118); } - } else { - const err119 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/9/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + errors++; + } + } + if (data.background !== void 0) { + let data42 = data.background; + if (typeof data42 !== "string") { + const err119 = { instancePath: instancePath + "/background", schemaPath: "#/oneOf/9/properties/background/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { vErrors = [err119]; } else { @@ -2706,6 +2699,26 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } + if (!(data42 === "transparent" || data42 === "checkerboard" || data42 === "theme")) { + const err120 = { instancePath: instancePath + "/background", schemaPath: "#/oneOf/9/properties/background/enum", keyword: "enum", params: { allowedValues: schema33.oneOf[9].properties.background.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err120]; + } else { + vErrors.push(err120); + } + errors++; + } + } + if (data.alt !== void 0) { + if (typeof data.alt !== "string") { + const err121 = { instancePath: instancePath + "/alt", schemaPath: "#/oneOf/9/properties/alt/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err121]; + } else { + vErrors.push(err121); + } + errors++; + } } } var _valid0 = _errs195 === errors; @@ -2720,6 +2733,83 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r props0 = true; } } + const _errs204 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.type === void 0) { + const err122 = { instancePath, schemaPath: "#/oneOf/10/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; + if (vErrors === null) { + vErrors = [err122]; + } else { + vErrors.push(err122); + } + errors++; + } + if (data.type !== void 0) { + let data44 = data.type; + const _errs208 = errors; + const _errs209 = errors; + if (!(data44 === "bar" || data44 === "hbar" || data44 === "line" || data44 === "area" || data44 === "pie" || data44 === "kpi" || data44 === "table" || data44 === "logs" || data44 === "text" || data44 === "image")) { + const err123 = {}; + if (vErrors === null) { + vErrors = [err123]; + } else { + vErrors.push(err123); + } + errors++; + } + var valid54 = _errs209 === errors; + if (valid54) { + const err124 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/10/properties/type/not", keyword: "not", params: {}, message: "must NOT be valid" }; + if (vErrors === null) { + vErrors = [err124]; + } else { + vErrors.push(err124); + } + errors++; + } else { + errors = _errs208; + if (vErrors !== null) { + if (_errs208) { + vErrors.length = _errs208; + } else { + vErrors = null; + } + } + } + if (typeof data44 === "string") { + if (func1(data44) < 1) { + const err125 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/10/properties/type/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err125]; + } else { + vErrors.push(err125); + } + errors++; + } + } else { + const err126 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/10/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err126]; + } else { + vErrors.push(err126); + } + errors++; + } + } + } + var _valid0 = _errs204 === errors; + if (_valid0 && valid0) { + valid0 = false; + passing0 = [passing0, 10]; + } else { + if (_valid0) { + valid0 = true; + passing0 = 10; + if (props0 !== true) { + props0 = true; + } + } + } } } } @@ -2730,11 +2820,11 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r } } if (!valid0) { - const err120 = { instancePath, schemaPath: "#/oneOf", keyword: "oneOf", params: { passingSchemas: passing0 }, message: "must match exactly one schema in oneOf" }; + const err127 = { instancePath, schemaPath: "#/oneOf", keyword: "oneOf", params: { passingSchemas: passing0 }, message: "must match exactly one schema in oneOf" }; if (vErrors === null) { - vErrors = [err120]; + vErrors = [err127]; } else { - vErrors.push(err120); + vErrors.push(err127); } errors++; } else { @@ -2749,42 +2839,42 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r } if (data && typeof data == "object" && !Array.isArray(data)) { if (data.type === void 0) { - const err121 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; + const err128 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; if (vErrors === null) { - vErrors = [err121]; + vErrors = [err128]; } else { - vErrors.push(err121); + vErrors.push(err128); } errors++; } if (data.type !== void 0) { - let data41 = data.type; - if (typeof data41 === "string") { - if (func1(data41) < 1) { - const err122 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + let data45 = data.type; + if (typeof data45 === "string") { + if (func1(data45) < 1) { + const err129 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; if (vErrors === null) { - vErrors = [err122]; + vErrors = [err129]; } else { - vErrors.push(err122); + vErrors.push(err129); } errors++; } } else { - const err123 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + const err130 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { - vErrors = [err123]; + vErrors = [err130]; } else { - vErrors.push(err123); + vErrors.push(err130); } errors++; } } } else { - const err124 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err131 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err124]; + vErrors = [err131]; } else { - vErrors.push(err124); + vErrors.push(err131); } errors++; } diff --git a/src/generated/json-schema.types.ts b/src/generated/json-schema.types.ts index c1bd9908..b9a0160c 100644 --- a/src/generated/json-schema.types.ts +++ b/src/generated/json-schema.types.ts @@ -499,6 +499,39 @@ export interface TextPanelCfg { [k: string]: unknown; } +/** + * Image + * + * A single ClickHouse `FORMAT PNG` result rendered as an image. The authored SQL must end in an explicit FORMAT PNG clause. + */ +export interface ImagePanelCfg { + /** + * Panel type + * + * Visualization renderer identifier. + */ + type: "image"; + /** + * Image fit + * + * contain scales the image to fit within the tile preserving aspect ratio, cover fills the tile and crops overflow, and actual renders at intrinsic CSS pixel size in a scrollable body. + */ + fit?: "contain" | "cover" | "actual"; + /** + * Image background + * + * theme uses the surrounding surface color, transparent shows nothing behind the image, and checkerboard renders a checkerboard pattern (useful for a transparent PNG). + */ + background?: "transparent" | "checkerboard" | "theme"; + /** + * Alt text + * + * Accessible description for the image. Falls back to the tile/query title when absent. + */ + alt?: string; + [k: string]: unknown; +} + /** * Future panel type * @@ -524,7 +557,7 @@ export interface FuturePanelCfg { * * Discriminated visualization configuration. Unknown types remain storable for forward compatibility. */ -export type PanelCfg = BarPanelCfg | HbarPanelCfg | LinePanelCfg | AreaPanelCfg | PiePanelCfg | KpiPanelCfg | TablePanelCfg | LogsPanelCfg | TextPanelCfg | FuturePanelCfg; +export type PanelCfg = BarPanelCfg | HbarPanelCfg | LinePanelCfg | AreaPanelCfg | PiePanelCfg | KpiPanelCfg | TablePanelCfg | LogsPanelCfg | TextPanelCfg | ImagePanelCfg | FuturePanelCfg; /** * Altinity SQL Browser saved-query Spec v1 diff --git a/src/generated/json-schemas.js b/src/generated/json-schemas.js index 51acf5a6..a2c3bef5 100644 --- a/src/generated/json-schemas.js +++ b/src/generated/json-schemas.js @@ -1057,6 +1057,56 @@ export const querySpecV1Schema = { "content" ] }, + { + "title": "Image", + "description": "A single ClickHouse `FORMAT PNG` result rendered as an image. The authored SQL must end in an explicit FORMAT PNG clause.", + "x-altinity-status": "implemented", + "x-altinity-snippet": { + "type": "image" + }, + "properties": { + "type": { + "const": "image" + }, + "fit": { + "title": "Image fit", + "description": "contain scales the image to fit within the tile preserving aspect ratio, cover fills the tile and crops overflow, and actual renders at intrinsic CSS pixel size in a scrollable body.", + "type": "string", + "enum": [ + "contain", + "cover", + "actual" + ], + "default": "contain" + }, + "background": { + "title": "Image background", + "description": "theme uses the surrounding surface color, transparent shows nothing behind the image, and checkerboard renders a checkerboard pattern (useful for a transparent PNG).", + "type": "string", + "enum": [ + "transparent", + "checkerboard", + "theme" + ], + "default": "theme" + }, + "alt": { + "title": "Alt text", + "description": "Accessible description for the image. Falls back to the tile/query title when absent.", + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": true, + "x-altinity-order": [ + "type", + "fit", + "background", + "alt" + ] + }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", @@ -1078,7 +1128,8 @@ export const querySpecV1Schema = { "kpi", "table", "logs", - "text" + "text", + "image" ] } } diff --git a/src/net/ch-client.ts b/src/net/ch-client.ts index 54f02cc0..2c98caad 100644 --- a/src/net/ch-client.ts +++ b/src/net/ch-client.ts @@ -11,7 +11,8 @@ import { parseExceptionText, isAuthExpiredBody, authDeniedMessage } from '../cor import type { StreamLine } from '../core/stream.js'; import { parseAstTables, buildSchemaGraph, externalDbs } from '../core/schema-graph.js'; import type { SchemaGraphTableRow, SchemaGraphDictRow } from '../core/schema-graph.js'; -import { sqlString } from '../core/format.js'; +import { sqlString, isBinaryFormat } from '../core/format.js'; +import { MAX_PNG_BYTES } from '../core/png.js'; // ── Injected ctx seam ──────────────────────────────────────────────────────── @@ -852,6 +853,75 @@ export interface RunQueryResult { error?: string; raw?: string; streamed?: boolean; + /** A raw-mode `FORMAT PNG` (or other binary format) body, read as bytes + * rather than decoded as text (#307). `contentType` is normalized to + * `image/png` regardless of what the server's response header says — + * never base64-encoded. */ + binary?: { bytes: Uint8Array; contentType: string }; +} + +/** A too-large-result error message, shared by both the header-only and the + * incremental-read rejection paths so the wording stays consistent. */ +function tooLargeError(bytes: number): string { + return `PNG result too large (${bytes} bytes, max ${MAX_PNG_BYTES})`; +} + +/** + * Read a raw-mode binary (`FORMAT PNG`, #307) response body while enforcing + * `MAX_PNG_BYTES` DURING the read, never after — an oversized response must + * never be fully buffered into memory first. Three paths: + * - `Content-Length` present and over the cap: reject immediately without + * reading the body (cancel it so the connection can be reclaimed). + * - A readable stream (`resp.body`): accumulate chunks + a running byte + * count via the reader, cancelling and rejecting the instant the count + * exceeds the cap; on success, assemble one final `Uint8Array` (a single + * allocation of the now-known total) from the collected chunks. + * - No `resp.body` (defensive — some fetch polyfills/mocks omit it): fall + * back to `arrayBuffer()` with a post-hoc size check (can't avoid the one + * allocation in this case, but still refuses to *return* an oversized + * payload). + * An `AbortSignal` firing mid-read rejects exactly as `arrayBuffer()` would + * (the reader's `read()` rejects once the underlying fetch aborts) — this + * function doesn't need its own abort handling, it just lets that rejection + * propagate to `runQuery`'s caller. + */ +async function readBinaryBody(resp: Response): Promise { + const contentLength = resp.headers?.get?.('content-length'); + if (contentLength != null) { + const len = Number(contentLength); + if (Number.isFinite(len) && len > MAX_PNG_BYTES) { + await resp.body?.cancel?.(); + return { error: tooLargeError(len) }; + } + } + if (!resp.body) { + const buf = await resp.arrayBuffer(); + if (buf.byteLength > MAX_PNG_BYTES) { + return { error: tooLargeError(buf.byteLength) }; + } + return { binary: { bytes: new Uint8Array(buf), contentType: 'image/png' } }; + } + const reader = resp.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > MAX_PNG_BYTES) { + await reader.cancel(); + return { error: tooLargeError(total) }; + } + chunks.push(value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { binary: { bytes, contentType: 'image/png' } }; } /** @@ -885,7 +955,13 @@ export async function runQuery(ctx: ChCtx, sql: string, o: RunQueryOptions = {}) // FORMAT) and 0 for EXPLAIN/PIPELINE/ESTIMATE (which also run as 'Table', so the // exemption can't be told apart by format here). `break` can overshoot by up to // a block on the streaming path, which the applyStreamLine guard trims. - const cap: Record = (o.resultRowLimit ?? 0) > 0 + // A binary format (PNG, #307) is exempt regardless of what the caller passed: + // capping *source* rows server-side truncates the pixel grid itself (a + // max_result_rows=500 cap on a 1024x1024 image only fills ~64 rows before + // ClickHouse stops reading, leaving the rest of the canvas black — the row + // cap is meaningless for a single-blob result and the real guards are the + // client-side PNG dimension/byte limits applied after decode). + const cap: Record = (o.resultRowLimit ?? 0) > 0 && !isBinaryFormat(fmt) ? { max_result_rows: o.resultRowLimit!, result_overflow_mode: 'break' } : {}; const url = chUrl(ctx.origin, { @@ -907,6 +983,9 @@ export async function runQuery(ctx: ChCtx, sql: string, o: RunQueryOptions = {}) return { error: parseExceptionText(await resp.text()) }; } if (!isStreaming) { + if (isBinaryFormat(fmt)) { + return readBinaryBody(resp); + } return { raw: await resp.text() }; } const reader = resp.body!.getReader(); diff --git a/src/state.ts b/src/state.ts index ed189516..8c3e2f35 100644 --- a/src/state.ts +++ b/src/state.ts @@ -944,6 +944,12 @@ export interface HistoryResultSnapshot { rawText: string | null; rows: readonly unknown[]; progress: { elapsed_ns: number }; + /** A validated FORMAT PNG image result (#307) — like `rawText`, recorded as + * `rows: null` (no row count is meaningful for a single image), not the + * always-empty `rows.length`. Optional: every OTHER `HistoryResultSnapshot` + * caller (script/scriptExport runs go through `recordScriptHistory` + * instead, never this function) simply omits the field. */ + image?: unknown; } /** @@ -963,7 +969,7 @@ export function recordHistory( pushHistory( state, sqlText != null ? sqlText : tab.sqlDraft, - tab.result.rawText != null ? null : tab.result.rows.length, + tab.result.rawText != null || tab.result.image != null ? null : tab.result.rows.length, Math.round(tab.result.progress.elapsed_ns / 1e6), save, now, ); diff --git a/src/styles.css b/src/styles.css index ce96aa83..988d7d93 100644 --- a/src/styles.css +++ b/src/styles.css @@ -842,6 +842,19 @@ body { /* tabindex makes the pane keyboard-scrollable; suppress the focus ring. */ .raw-text-view:focus, .json-view:focus { outline: none; } +/* ------------ FORMAT PNG image result view (#307) ------------ */ +/* Centered, scrollable — a PNG bigger than the pane scrolls rather than + clipping; the image itself never upscales past its intrinsic size. */ +.image-result-view { + height: 100%; overflow: auto; + display: flex; align-items: center; justify-content: center; + background: var(--bg-table); +} +.image-result-view img { + max-width: 100%; max-height: 100%; + display: block; +} + /* ------------ EXPLAIN pipeline graph view ------------ */ /* Same pan/zoom surface as the fullscreen overlay: drag to pan (grab cursor), wheel to pan, ⌘/Ctrl+wheel to zoom, double-click to fit. */ @@ -1152,14 +1165,15 @@ body.detached-tab .graph-overlay-panel { .panel-config .chart-config { padding: 0; border-bottom: none; flex: 1; } .panel-body { flex: 1; min-height: 0; display: flex; flex-direction: column; } .panel-body > .chart-view, .panel-body > .res-table-wrap, -.panel-body > .dash-logs, .panel-body > .panel-with-note { flex: 1; min-height: 0; } +.panel-body > .dash-logs, .panel-body > .panel-with-note, +.panel-body > .panel-image-box { flex: 1; min-height: 0; } /* Detached-view / readonly panel: a resolved panel renders straight into the block-level `.res-body` (no `.panel-body` flex wrapper), so `flex:1` can't bound it. Like `.chart-view`, these need `height:100%` to fill `.res-body` and let their own `overflow:auto` scroll — otherwise `.dash-logs` grows to content height and `.res-body`'s `overflow:hidden` clips it with no scroll. */ .res-body > .dash-logs, .res-body > .md-view, -.res-body > .panel-with-note { height: 100%; } +.res-body > .panel-with-note, .res-body > .panel-image-box { height: 100%; } .panel-with-note { display: flex; flex-direction: column; } .panel-with-note > :last-child { flex: 1; min-height: 0; } .panel-note { @@ -1189,6 +1203,30 @@ body.detached-tab .graph-overlay-panel { } .md-view a { color: var(--accent); } +/* Image panel (#307): a single FORMAT PNG result rendered as an filling + the tile/panel body. `.panel-image-fit-*` is CSS-only (never re-runs the + query on resize); `.panel-image-bg-*` is the backdrop behind a + transparent PNG. */ +.panel-image-box { + display: flex; align-items: center; justify-content: center; + overflow: auto; width: 100%; height: 100%; +} +.panel-image-bg-theme { background: transparent; } +.panel-image-bg-transparent { background: transparent; } +.panel-image-bg-checkerboard { + background-image: + linear-gradient(45deg, var(--border-faint) 25%, transparent 25%), + linear-gradient(-45deg, var(--border-faint) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--border-faint) 75%), + linear-gradient(-45deg, transparent 75%, var(--border-faint) 75%); + background-size: 16px 16px; + background-position: 0 0, 0 8px, 8px -8px, -8px 0px; +} +.panel-image { display: block; max-width: 100%; max-height: 100%; } +.panel-image-fit-contain { width: 100%; height: 100%; object-fit: contain; } +.panel-image-fit-cover { width: 100%; height: 100%; object-fit: cover; } +.panel-image-fit-actual { width: auto; height: auto; max-width: none; max-height: none; } + /* ------------ schema tree ------------ */ .schema-search { padding: 8px 10px; @@ -2323,7 +2361,8 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } inside the tile instead of clipping; the workbench grid's height:100% is overridden back to auto (flex sizes it here). */ .dash-tile-body > .res-table-wrap, -.dash-tile-body > .dash-logs { flex: 1; min-width: 0; min-height: 0; } +.dash-tile-body > .dash-logs, +.dash-tile-body > .panel-image-box { flex: 1; min-width: 0; min-height: 0; } .dash-tile-body > .res-table-wrap { height: auto; } /* Logs view (#149 D9): a compact, scroll-only reading surface — level colors via the --log-* theme vars, monospace, wrapped messages. */ diff --git a/src/ui/app.ts b/src/ui/app.ts index ffdb84a6..46316eb1 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -36,7 +36,7 @@ import { createSpecCompletionSources } from '../editor/spec-completion-adapter.j import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs.js'; import type { QueryOrName } from './tabs.js'; import { batch } from '@preact/signals-core'; -import { renderResults } from './results.js'; +import { renderResults, revokeResultImageUrl } from './results.js'; import type { Result, QueryResult, ScriptResult, ScriptEntry } from './results.js'; import { renderDashboard } from './dashboard.js'; import { toggleThemeDom } from './theme-toggle.js'; @@ -245,6 +245,14 @@ export function createApp(env: CreateAppEnv = {}): App { // helper (defined below). The library title (name + dirty dot) repaints via a // libraryName/libraryDirty effect, so callers just mutate those signals. app.downloadFile = downloadFile; + app.createObjectUrl = env.createObjectUrl || ((bytes, mime) => { + const url = (win.URL || win.webkitURL)!; + const BlobCtor = win.Blob!; + return url.createObjectURL(new BlobCtor([bytes as BlobPart], { type: mime })); + }); + app.revokeObjectUrl = env.revokeObjectUrl || ((url) => { + (win.URL || win.webkitURL)!.revokeObjectURL(url); + }); // --- identity ------------------------------------------------------------ // Identity/auth reads (host/email/isSignedIn/…) live on `app.conn` itself @@ -588,6 +596,7 @@ export function createApp(env: CreateAppEnv = {}): App { showExportProgress: (onCancel) => showExportProgress(onCancel), toast: (message) => flashToast(message, { document: doc }), loadSchema: () => { void catalog.loadSchema(); }, + revokeResultImage: (result) => revokeResultImageUrl(app, result), }, }); app.exports = exportService; @@ -618,6 +627,7 @@ export function createApp(env: CreateAppEnv = {}): App { tickElapsed, saveJSON, onAuthFailed: () => chCtx.onSignedOut(), + revokeResultImage: (result) => revokeResultImageUrl(app, result), }, }); app.workbench = workbench; @@ -912,6 +922,7 @@ export function createApp(env: CreateAppEnv = {}): App { // clear just this — see clearFormatError) is app.ts/test-only, not // part of results.ts's own canonical `QueryResult` contract. const formatErrorResult: QueryResult & { formatError: true } = { ...newResult('Table'), error: msg, formatError: true }; + revokeResultImageUrl(app, tab.result); // #307: free a displayed image's URL before it's overwritten Object.assign(tab, { result: formatErrorResult }); app.state.resultView.value = 'table'; renderResults(app); // explicit: the format-error tab.result is an in-place write, and resultView may already be 'table' (no effect) @@ -944,6 +955,7 @@ export function createApp(env: CreateAppEnv = {}): App { hooks: { renderResults: () => renderResults(app), onAuthFailed: () => chCtx.onSignedOut(), + revokeResultImage: (result) => revokeResultImageUrl(app, result), }, }); app.graph = graph; @@ -1108,7 +1120,13 @@ export function createApp(env: CreateAppEnv = {}): App { // original untyped property read did — never actually a `QueryResult`). const r = app.activeTab().result as (QueryResult & { script?: unknown }) | null; // A script result is a per-statement grid, not a single exportable table. - return r && !r.error && !r.script && (r.rawText != null || r.rows.length > 0) ? r : null; + // A FORMAT PNG image result (#307) is neither — Copy/Export are text/row + // operations and an image has no tabular text form (Download PNG, the + // results pane's own toolbar button, is the only export path for it). + // `rows.length > 0` is already false for an image result (its `rows` is + // always `[]`) so this never changes behavior, only makes the exclusion + // explicit against a future result shape where that stops being true. + return r && !r.error && !r.script && !r.image && (r.rawText != null || r.rows.length > 0) ? r : null; } // `targetDoc` defaults to the main document, but a detached view (issue // #100's Data Pane) passes its own — the Clipboard API ties writeText's diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 7cbd2ffc..c8474877 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -289,6 +289,14 @@ export interface App { saveVarRecent(): void; recordHistory(tab: Tab, sqlText?: string): void; downloadFile(filename: string, mime: string, content: BlobPart): void; + /** Object-URL seam for the Image (PNG) result view (#307) — mirrors + * `downloadFile`'s injected-seam-with-real-fallback shape. `createObjectUrl` + * mints a fresh `blob:` URL for `bytes`; `revokeObjectUrl` frees one minted + * by it. results.ts creates at most one URL per `ImageResultPayload` and + * revokes it when that result is replaced/discarded (workbench-session.ts's + * run(), tabs.ts's closeTab). */ + createObjectUrl(bytes: Uint8Array, mime: string): string; + revokeObjectUrl(url: string): void; /** Whether the header library-name field is in its inline-edit state. Not a * signal — file-menu.js renders it directly. */ editingLibrary: boolean; diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index d2f9e896..a47f8ea6 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -34,6 +34,7 @@ import { Icon as IconUntyped } from './icons.js'; import { renderResolvedPanel } from './panels.js'; import { resolvePanel } from '../core/panel-cfg.js'; import type { Column } from '../core/panel-cfg.js'; +import type { ImageResultPayload } from '../core/png.js'; import { DASH_TILE_ROW_CAP, DASH_TABLE_DISPLAY_CAP } from '../core/dashboard.js'; import { formatBytes as formatBytesUntyped, formatRows as formatRowsUntyped, @@ -124,14 +125,19 @@ export interface DashboardApp { const valueString = (value: unknown): string => (typeof value === 'string' ? value : value == null ? '' : String(value)); -/** #291 review F4: `renderDashboard` can run more than once against the SAME - * window — `app.reloadDashboardRoute()` (app.ts) re-invokes it in place after - * an import-commit while already on `/dashboard` (file-menu.ts's Import - * flow). Module-level so a later call can find and remove the PRIOR call's - * resize listener before installing its own; without this, repeated renders - * stack listeners that all still close over their own render's now-stale +/** #291 review F4 / #318: `renderDashboard` can run more than once against the + * SAME window — `app.reloadDashboardRoute()` (app.ts) re-invokes it in place + * after an import-commit while already on `/dashboard` (file-menu.ts's Import + * flow). Module-level so a later call can fully tear down the PRIOR call's + * live state before installing its own: the resize listener (#291's own + * fix), the signals `effect()` (its own `dispose` — a second live effect + * would double-publish/double-paint over the new render's DOM), the + * `DashboardViewerSession` (in-flight requests, generations — `session. + * destroy()`), and every retained per-tile renderer (Chart.js instances, + * #307 image blob URLs — `destroyChart`). Without this, a repeated render + * leaks all of the above, each still closing over its own render's now-stale * `session`/`currentDoc`/`containerWidthPx`. */ -let installedGridResizeListener: { win: Window; handler: () => void } | null = null; +let installedDashboardDisposer: (() => void) | null = null; /** * Build the flow preset switcher as a compact `