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 `