From cec818b1754f5895e9652eaa1febb8a7a75adb17 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 19 Jul 2026 10:33:44 +0000 Subject: [PATCH 1/9] feat(#307): binary PNG transport + pure PNG validation core runQuery gains a binary raw-mode branch (arrayBuffer, normalized image/png content type) for isBinaryFormat() formats; StreamResult carries a validated ImageResultPayload; executeRead validates PNG signature/IHDR/limits via the new pure src/core/png.ts before any bytes reach the UI. PNG added to formatFileMeta. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB --- src/application/query-execution-service.ts | 18 +++- src/core/export.ts | 1 + src/core/format.ts | 10 ++ src/core/png.ts | 68 ++++++++++++++ src/core/stream.ts | 7 ++ src/net/ch-client.ts | 11 ++- tests/unit/ch-client.test.ts | 37 ++++++++ tests/unit/chart-render.test.ts | 1 + tests/unit/export.test.ts | 2 + tests/unit/format.test.ts | 23 ++++- tests/unit/panels.test.ts | 1 + tests/unit/png.test.ts | 102 +++++++++++++++++++++ tests/unit/query-execution-service.test.ts | 29 ++++++ tests/unit/stream.test.ts | 3 + 14 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 src/core/png.ts create mode 100644 tests/unit/png.test.ts diff --git a/src/application/query-execution-service.ts b/src/application/query-execution-service.ts index feb4845d..9391fe47 100644 --- a/src/application/query-execution-service.ts +++ b/src/application/query-execution-service.ts @@ -17,6 +17,7 @@ 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 { 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'; @@ -177,7 +178,22 @@ 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 { + result.error = 'Invalid PNG result: ' + check.reason; + } + } else if (out.raw != null) { result.rawText = out.raw; result.progress.bytes = out.raw.length; } 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/png.ts b/src/core/png.ts new file mode 100644 index 00000000..18a75a2b --- /dev/null +++ b/src/core/png.ts @@ -0,0 +1,68 @@ +// 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 }; + +/** + * Validate `bytes` as a well-formed, in-bounds PNG: non-empty, under + * `MAX_PNG_BYTES`, the 8-byte PNG signature, a readable IHDR chunk (first + * chunk at offset 8, length >= 13, type 'IHDR') with positive width/height + * within `MAX_PNG_WIDTH`/`MAX_PNG_HEIGHT` and `width*height <= + * MAX_PNG_PIXELS`. Never decodes pixel data — only the signature + IHDR + * header. 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)' }; + 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})` }; + } + return { ok: true, width, height }; +} diff --git a/src/core/stream.ts b/src/core/stream.ts index b21a44a3..c2124cc9 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, }; } diff --git a/src/net/ch-client.ts b/src/net/ch-client.ts index 54f02cc0..ff6a2a92 100644 --- a/src/net/ch-client.ts +++ b/src/net/ch-client.ts @@ -11,7 +11,7 @@ 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'; // ── Injected ctx seam ──────────────────────────────────────────────────────── @@ -852,6 +852,11 @@ 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 }; } /** @@ -907,6 +912,10 @@ export async function runQuery(ctx: ChCtx, sql: string, o: RunQueryOptions = {}) return { error: parseExceptionText(await resp.text()) }; } if (!isStreaming) { + if (isBinaryFormat(fmt)) { + const buf = await resp.arrayBuffer(); + return { binary: { bytes: new Uint8Array(buf), contentType: 'image/png' } }; + } return { raw: await resp.text() }; } const reader = resp.body!.getReader(); diff --git a/tests/unit/ch-client.test.ts b/tests/unit/ch-client.test.ts index 8ca9dae4..8c017756 100644 --- a/tests/unit/ch-client.test.ts +++ b/tests/unit/ch-client.test.ts @@ -18,6 +18,7 @@ interface FakeResponse { status: number; json?(): Promise; text(): Promise; + arrayBuffer?(): Promise; clone(): FakeResponse; body?: { getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }> } }; } @@ -32,6 +33,18 @@ function jsonResp(body: unknown, ok = true, status = ok ? 200 : 500): FakeRespon function textResp(text: string, ok = true, status = ok ? 200 : 500): FakeResponse { return { ok, status, text: async () => text, clone() { return this; } }; } +/** A raw-mode binary response (e.g. `FORMAT PNG`) — `arrayBuffer()` is the + * only reader `runQuery`'s binary branch calls; `text()` backs the non-2xx + * error path (parseExceptionText reads text, never bytes, on failure). */ +function binResp(bytes: Uint8Array, ok = true, status = ok ? 200 : 500): FakeResponse { + return { + ok, + status, + text: async () => new TextDecoder().decode(bytes), + arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer, + clone() { return this; }, + }; +} function streamResp(chunks: string[], ok = true): FakeResponse { let i = 0; return { @@ -511,6 +524,30 @@ describe('runQuery', () => { const ctx = ctxWith(async () => textResp('{"x":1}')); expect(await runQuery(ctx, 'x', { format: 'JSON' })).toEqual({ raw: '{"x":1}' }); }); + it('PNG raw mode reads bytes (never text/base64) and normalizes the content type (#307)', async () => { + const bytes = new Uint8Array([137, 80, 78, 71, 1, 2, 3]); + const ctx = ctxWith(async () => binResp(bytes)); + const out = await runQuery(ctx, 'SELECT plot(1) FORMAT PNG', { format: 'PNG' }); + expect(out.raw).toBeUndefined(); + expect(out.binary).toBeDefined(); + expect(out.binary!.contentType).toBe('image/png'); + expect(out.binary!.bytes).toBeInstanceOf(Uint8Array); + expect(Array.from(out.binary!.bytes)).toEqual(Array.from(bytes)); + }); + it('PNG raw mode with a case-insensitive format name is still read as binary', async () => { + const bytes = new Uint8Array([1, 2, 3]); + const ctx = ctxWith(async () => binResp(bytes)); + const out = await runQuery(ctx, 'x', { format: 'png' }); + expect(out.binary).toBeDefined(); + }); + it('PNG raw mode error response (non-2xx) is parsed as text, not bytes', async () => { + const errText = 'Code: 62. DB::Exception: Syntax error'; + const ctx = ctxWith(async () => textResp(errText, false, 500)); + const out = await runQuery(ctx, 'x', { format: 'PNG' }); + expect(out.binary).toBeUndefined(); + expect(out.raw).toBeUndefined(); + expect(out.error).toContain('Syntax error'); + }); it('passes the abort signal through', async () => { const ctx = ctxWith(async () => streamResp(['{"row":{}}\n'])); const signal = new AbortController().signal; diff --git a/tests/unit/chart-render.test.ts b/tests/unit/chart-render.test.ts index e69421d1..7b8fdd00 100644 --- a/tests/unit/chart-render.test.ts +++ b/tests/unit/chart-render.test.ts @@ -27,6 +27,7 @@ interface StreamResult { pct: number; rowLimit: number; capped: boolean; + image: unknown; [k: string]: unknown; } const newResult = (fmt: string, rowLimit = 0): StreamResult => newResultUntyped(fmt, rowLimit) as StreamResult; diff --git a/tests/unit/export.test.ts b/tests/unit/export.test.ts index f0f37ec4..343f9793 100644 --- a/tests/unit/export.test.ts +++ b/tests/unit/export.test.ts @@ -36,6 +36,8 @@ describe('formatFileMeta', () => { expect(formatFileMeta('XML')).toEqual({ ext: 'xml', mime: 'application/xml' }); expect(formatFileMeta('Markdown')).toEqual({ ext: 'md', mime: 'text/markdown' }); expect(formatFileMeta('SQLInsert')).toEqual({ ext: 'sql', mime: 'application/sql' }); + expect(formatFileMeta('PNG')).toEqual({ ext: 'png', mime: 'image/png' }); + expect(formatFileMeta('png')).toEqual({ ext: 'png', mime: 'image/png' }); }); it('falls back to txt for Pretty/Vertical/Values/unknown formats', () => { expect(formatFileMeta('PrettyCompact')).toEqual({ ext: 'txt', mime: 'text/plain' }); diff --git a/tests/unit/format.test.ts b/tests/unit/format.test.ts index 96f672a8..a2fcfb69 100644 --- a/tests/unit/format.test.ts +++ b/tests/unit/format.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - clamp, formatRows, formatBytes, timeAgo, sqlString, quoteIdent, qualifyIdent, inferQueryName, isNumericType, shortVersion, supportsExplainPretty, userShortName, withStatementBreak, detectSqlFormat, isSchemaMutatingSql, toSubquery, prepareExportSql, truncate, formatCompressionRatio, + clamp, formatRows, formatBytes, timeAgo, sqlString, quoteIdent, qualifyIdent, inferQueryName, isNumericType, shortVersion, supportsExplainPretty, userShortName, withStatementBreak, detectSqlFormat, isSchemaMutatingSql, toSubquery, prepareExportSql, truncate, formatCompressionRatio, isBinaryFormat, } from '../../src/core/format.js'; describe('clamp', () => { @@ -172,6 +172,11 @@ describe('detectSqlFormat', () => { expect(detectSqlFormat('SELECT 1 FORMAT CSV SETTINGS max_threads=1;')).toBe('CSV'); expect(detectSqlFormat('SELECT 1 SETTINGS max_threads=1 FORMAT CSV')).toBe('CSV'); // the other order }); + it('detects a trailing FORMAT PNG in either FORMAT/SETTINGS order (#307)', () => { + expect(detectSqlFormat('SELECT plot(x) FROM t FORMAT PNG')).toBe('PNG'); + expect(detectSqlFormat('SELECT plot(x) FROM t FORMAT PNG SETTINGS max_threads=1')).toBe('PNG'); + expect(detectSqlFormat('SELECT plot(x) FROM t SETTINGS max_threads=1 FORMAT PNG')).toBe('PNG'); + }); it('returns null without a trailing FORMAT clause', () => { expect(detectSqlFormat('SELECT 1')).toBeNull(); expect(detectSqlFormat("SELECT 'FORMAT JSON' AS x")).toBeNull(); // FORMAT not the trailing clause @@ -361,3 +366,19 @@ describe('toSubquery', () => { expect(toSubquery(null)).toBe(''); }); }); + +describe('isBinaryFormat', () => { + it('is true for PNG, case-insensitive', () => { + expect(isBinaryFormat('PNG')).toBe(true); + expect(isBinaryFormat('png')).toBe(true); + expect(isBinaryFormat('Png')).toBe(true); + }); + it('is false for other/empty/nullish formats', () => { + expect(isBinaryFormat('Table')).toBe(false); + expect(isBinaryFormat('JSON')).toBe(false); + expect(isBinaryFormat('PNGx')).toBe(false); + expect(isBinaryFormat('')).toBe(false); + expect(isBinaryFormat(null)).toBe(false); + expect(isBinaryFormat(undefined)).toBe(false); + }); +}); diff --git a/tests/unit/panels.test.ts b/tests/unit/panels.test.ts index 99332779..2c4dba3e 100644 --- a/tests/unit/panels.test.ts +++ b/tests/unit/panels.test.ts @@ -28,6 +28,7 @@ interface StreamResult { pct: number; rowLimit: number; capped: boolean; + image: unknown; // QueryTab.result (state.ts) holds this as an opaque Record // (only results.js knows the concrete shape); the index signature lets a // StreamResult assign straight into `tab.result` without a further cast. diff --git a/tests/unit/png.test.ts b/tests/unit/png.test.ts new file mode 100644 index 00000000..809246be --- /dev/null +++ b/tests/unit/png.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { + validatePng, MAX_PNG_WIDTH, MAX_PNG_HEIGHT, MAX_PNG_PIXELS, MAX_PNG_BYTES, +} from '../../src/core/png.js'; + +const SIG = [137, 80, 78, 71, 13, 10, 26, 10]; + +/** Build minimal PNG bytes: signature + an IHDR chunk (length/type/width/ + * height only — no CRC, no other chunks; `validatePng` never reads past + * IHDR's first 8 header bytes so this is sufficient). `ihdrLength` and + * `type` are overridable to exercise the malformed-chunk branches. */ +function makePng(opts: { + width?: number; + height?: number; + ihdrLength?: number; + type?: string; + extraTrailingBytes?: number; +} = {}): Uint8Array { + const { + width = 100, height = 50, ihdrLength = 13, type = 'IHDR', extraTrailingBytes = 0, + } = opts; + const bytes = new Uint8Array(8 + 8 + 13 + extraTrailingBytes); + bytes.set(SIG, 0); + const view = new DataView(bytes.buffer); + view.setUint32(8, ihdrLength, false); + for (let i = 0; i < 4; i++) bytes[12 + i] = type.charCodeAt(i); + view.setUint32(16, width, false); + view.setUint32(20, height, false); + return bytes; +} + +describe('validatePng', () => { + it('accepts a well-formed minimal PNG', () => { + expect(validatePng(makePng({ width: 100, height: 50 }))).toEqual({ ok: true, width: 100, height: 50 }); + }); + + it('rejects empty bytes', () => { + expect(validatePng(new Uint8Array(0))).toEqual({ ok: false, reason: 'Empty PNG result' }); + }); + + it('rejects bytes over MAX_PNG_BYTES', () => { + // A cheap over-cap buffer: length alone triggers the byte-cap branch + // before any signature/IHDR parsing. + const big = new Uint8Array(MAX_PNG_BYTES + 1); + const r = validatePng(big); + expect(r.ok).toBe(false); + expect((r as { ok: false; reason: string }).reason).toMatch(/too large/); + }); + + it('rejects bytes too short to hold a header', () => { + const r = validatePng(new Uint8Array(10)); + expect(r).toEqual({ ok: false, reason: 'PNG result too short to contain a header' }); + }); + + it('rejects a bad signature', () => { + const bytes = makePng(); + bytes[0] = 0; + expect(validatePng(bytes)).toEqual({ ok: false, reason: 'Not a valid PNG (bad signature)' }); + }); + + it('rejects a missing IHDR chunk type', () => { + const bytes = makePng({ type: 'IDAT' }); + expect(validatePng(bytes)).toEqual({ ok: false, reason: 'Not a valid PNG (missing IHDR chunk)' }); + }); + + it('rejects an IHDR chunk shorter than 13 bytes', () => { + const bytes = makePng({ ihdrLength: 12 }); + expect(validatePng(bytes)).toEqual({ ok: false, reason: 'Not a valid PNG (IHDR chunk too short)' }); + }); + + it('rejects zero/negative-equivalent (unsigned zero) width or height', () => { + expect(validatePng(makePng({ width: 0, height: 50 }))).toEqual({ ok: false, reason: 'Not a valid PNG (non-positive dimensions)' }); + expect(validatePng(makePng({ width: 100, height: 0 }))).toEqual({ ok: false, reason: 'Not a valid PNG (non-positive dimensions)' }); + }); + + it('rejects dimensions over MAX_PNG_WIDTH/HEIGHT', () => { + const wide = validatePng(makePng({ width: MAX_PNG_WIDTH + 1, height: 10 })); + expect(wide.ok).toBe(false); + expect((wide as { ok: false; reason: string }).reason).toMatch(/dimensions too large/); + const tall = validatePng(makePng({ width: 10, height: MAX_PNG_HEIGHT + 1 })); + expect(tall.ok).toBe(false); + expect((tall as { ok: false; reason: string }).reason).toMatch(/dimensions too large/); + }); + + it('rejects width*height over MAX_PNG_PIXELS while each dimension alone is in range', () => { + // sqrt(32_000_000) ≈ 5657; pick dims each under MAX_PNG_WIDTH/HEIGHT but + // whose product exceeds MAX_PNG_PIXELS. + const width = 6000; + const height = 6000; + expect(width).toBeLessThanOrEqual(MAX_PNG_WIDTH); + expect(height).toBeLessThanOrEqual(MAX_PNG_HEIGHT); + expect(width * height).toBeGreaterThan(MAX_PNG_PIXELS); + const r = validatePng(makePng({ width, height })); + expect(r.ok).toBe(false); + expect((r as { ok: false; reason: string }).reason).toMatch(/too many pixels/); + }); + + it('accepts dimensions exactly at the pixel cap', () => { + // 8000 x 4000 = 32,000,000 exactly. + expect(validatePng(makePng({ width: 8000, height: 4000 }))).toEqual({ ok: true, width: 8000, height: 4000 }); + }); +}); diff --git a/tests/unit/query-execution-service.test.ts b/tests/unit/query-execution-service.test.ts index 468837b1..34c214a6 100644 --- a/tests/unit/query-execution-service.test.ts +++ b/tests/unit/query-execution-service.test.ts @@ -125,6 +125,35 @@ describe('executeRead', () => { expect(out.progress.bytes).toBe(5); }); + it('sets result.image + progress.bytes from a valid out.binary PNG (#307)', async () => { + // Minimal well-formed PNG: signature + IHDR (length 13, type IHDR, 10x20). + const bytes = new Uint8Array(29); + bytes.set([137, 80, 78, 71, 13, 10, 26, 10], 0); + const view = new DataView(bytes.buffer); + view.setUint32(8, 13, false); + bytes.set([73, 72, 68, 82], 12); // 'IHDR' + view.setUint32(16, 10, false); + view.setUint32(20, 20, false); + const { fn } = fakeRunQuery([() => ({ binary: { bytes, contentType: 'image/png' } })]); + const svc = createQueryExecutionService(makeDeps({ runQuery: fn })); + const out = await svc.executeRead(newResult('PNG'), { sql: 'SELECT plot(1) FORMAT PNG', format: 'PNG' }); + expect(out.image).toEqual({ + kind: 'image', format: 'PNG', mimeType: 'image/png', bytes, width: 10, height: 20, + }); + expect(out.progress.bytes).toBe(29); + expect(out.rawText).toBeNull(); + expect(out.error).toBeNull(); + }); + + it('sets result.error (and drops the bytes) when out.binary fails PNG validation', async () => { + const badBytes = new Uint8Array([1, 2, 3]); // too short, bad signature + const { fn } = fakeRunQuery([() => ({ binary: { bytes: badBytes, contentType: 'image/png' } })]); + const svc = createQueryExecutionService(makeDeps({ runQuery: fn })); + const out = await svc.executeRead(newResult('PNG'), { sql: 'x', format: 'PNG' }); + expect(out.image).toBeNull(); + expect(out.error).toMatch(/^Invalid PNG result: /); + }); + it('defaults format to Table and rowLimit to 0 in the runQuery opts', async () => { const { fn, calls } = fakeRunQuery([() => ({ raw: '' })]); const svc = createQueryExecutionService(makeDeps({ runQuery: fn })); diff --git a/tests/unit/stream.test.ts b/tests/unit/stream.test.ts index 03d4e1ea..73c401b1 100644 --- a/tests/unit/stream.test.ts +++ b/tests/unit/stream.test.ts @@ -32,6 +32,9 @@ describe('newResult', () => { it('carries the row limit when given', () => { expect(newResult('Table', 500)).toMatchObject({ rowLimit: 500, capped: false }); }); + it('starts with a null image (#307)', () => { + expect(newResult('PNG')).toMatchObject({ image: null }); + }); }); describe('applyStreamLine', () => { From 288cca503b13c3deea00fbb984dc54538066137a Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 19 Jul 2026 10:53:36 +0000 Subject: [PATCH 2/9] feat(#307): Workbench Image view + Dashboard image panel for FORMAT PNG Workbench: image results render as a centered, scrollable behind a new injected createObjectUrl/revokeObjectUrl env seam, with a locked 'Image (PNG)' toolbar tab, Copy disabled, and exact-bytes Download PNG. Object URLs are cached per payload (WeakMap) and revoked on rerun replacement, tab close, and format-error overwrite; history records rows:null for image results. Dashboard: new schema-validated 'image' panel type (fit/background/alt) regenerated through the schema pipeline; panelExecution image arm requires authored FORMAT PNG and owns format='PNG'; the viewer-session blanket FORMAT rejection now exempts only image panels; tiles render the image in the full body with object-fit modes + checkerboard background, repaint on image identity (resize never re-executes), and revoke the previous URL via the shared destroy slot in both engines. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB --- .../generated/library-v2.bundle.schema.json | 53 ++++- schemas/query-spec-v1.schema.json | 33 +++- src/core/panel-cfg.ts | 13 +- src/core/panel-execution.ts | 37 ++++ src/core/result-choice.ts | 3 +- .../application/dashboard-viewer-session.ts | 30 ++- src/env.types.ts | 7 + src/generated/json-schema-validators.js | 184 +++++++++++++----- src/generated/json-schema.types.ts | 35 +++- src/generated/json-schemas.js | 53 ++++- src/state.ts | 8 +- src/styles.css | 45 ++++- src/ui/app.ts | 20 +- src/ui/app.types.ts | 8 + src/ui/dashboard.ts | 34 +++- src/ui/icons.ts | 3 + src/ui/panels.ts | 44 +++++ src/ui/results.ts | 111 ++++++++++- src/ui/tabs.ts | 11 ++ src/ui/workbench/workbench-session.ts | 12 ++ tests/helpers/fake-app.ts | 4 + tests/unit/app.test.ts | 41 ++++ tests/unit/dashboard-viewer-session.test.ts | 84 ++++++++ tests/unit/panel-cfg.test.ts | 25 ++- tests/unit/panel-execution.test.ts | 33 +++- tests/unit/panels.test.ts | 70 ++++++- tests/unit/result-choice.test.ts | 2 + tests/unit/results.test.ts | 86 +++++++- tests/unit/spec-completion.test.ts | 2 +- tests/unit/spec-schema.test.ts | 6 +- tests/unit/tabs.test.ts | 22 +++ tests/unit/workbench-session.test.ts | 1 + 32 files changed, 1027 insertions(+), 93 deletions(-) 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/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..c02eaae1 100644 --- a/src/core/panel-execution.ts +++ b/src/core/panel-execution.ts @@ -11,6 +11,13 @@ 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'; +} + /** 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 +58,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/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/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 167e6419..daeea701 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -35,6 +35,7 @@ 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 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; @@ -324,16 +333,17 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa 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,7 +490,12 @@ 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; + // 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; if (execution.error || (checkFormat && detectSqlFormat(execSql))) { runtime.state.status = 'error'; runtime.state.error = execution.error @@ -494,7 +509,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 +538,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/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/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..53914427 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 @@ -618,6 +626,7 @@ export function createApp(env: CreateAppEnv = {}): App { tickElapsed, saveJSON, onAuthFailed: () => chCtx.onSignedOut(), + revokeResultImage: (result) => revokeResultImageUrl(app, result), }, }); app.workbench = workbench; @@ -912,6 +921,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) @@ -1108,7 +1118,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..6768c26f 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, @@ -182,6 +183,12 @@ interface TileEl { panelState: { key: string;[k: string]: unknown } | null; destroy: (() => void) | null; paintedRows: unknown[][] | null; + /** The last image identity `paintPanel` painted (#307) — `rows` for an + * Image tile is always an empty array (a query result never streams + * `{row}` lines), so the `rows`-reference repaint gate below can't tell + * a fresh Image result from an unchanged one on its own; this is checked + * alongside it. */ + paintedImage: ImageResultPayload | null; } /** Synthesize a filter definition per distinct `{name:Type}` panel-tile param @@ -674,7 +681,7 @@ export async function renderDashboard(app: DashboardApp): Promise { }); } if (resizeHandle) wireGridResize(ts.tileId, resizeHandle, card); - const tileEl: TileEl = { card, body, foot, panelState: null, destroy: null, paintedRows: null }; + const tileEl: TileEl = { card, body, foot, panelState: null, destroy: null, paintedRows: null, paintedImage: null }; tileEls.set(ts.tileId, tileEl); return tileEl; } @@ -684,8 +691,13 @@ export async function renderDashboard(app: DashboardApp): Promise { // Paint an ordinary (non-KPI) tile's result once per new result. Only ever // called for a 'ready' tile, so columns/rows/meta/panel are all present. function paintPanel(ts: ViewerTileState, tileEl: TileEl): void { - if (ts.rows === tileEl.paintedRows) return; - destroyChart(tileEl); + // The rows-reference gate alone can't detect a fresh Image result (its + // `rows` is always an empty array — see `paintedImage`'s doc comment), so + // an Image tile is also gated on `ts.image` identity; this ALSO means a + // tile-body resize (CSS-only fit, no `syncDocument`/re-run) never repaints + // an Image tile, since neither identity changes. + if (ts.rows === tileEl.paintedRows && ts.image === tileEl.paintedImage) return; + destroyChart(tileEl); // also revokes the previously painted Image's object URL (#307) const panel = (ts.panel || {}) as Record; const columns = ts.columns as Column[]; const rows = ts.rows as unknown[][]; @@ -695,20 +707,25 @@ export async function renderDashboard(app: DashboardApp): Promise { tileEl.card.classList.toggle('is-kpi', resolved.cfg.type === 'kpi'); const key = JSON.stringify(columns.map((c) => c.name + ':' + c.type)); if (!tileEl.panelState || tileEl.panelState.key !== key) tileEl.panelState = { key }; - const result = { columns, rows } as Parameters[2]; + const result = { columns, rows, image: ts.image } as Parameters[2]; const out = renderResolvedPanel(app as unknown as App, resolved, result, { surface: 'dashboard', state: tileEl.panelState, rerender: () => paintForce(ts, tileEl), - readonly: true, cap: DASH_TABLE_DISPLAY_CAP, onCell: () => {}, + readonly: true, cap: DASH_TABLE_DISPLAY_CAP, onCell: () => {}, title: ts.title, }); tileEl.destroy = out.destroy || null; tileEl.body.replaceChildren(out.node); tileEl.foot.replaceChildren(...tileFooter(ts.meta as NonNullable)); tileEl.paintedRows = ts.rows; + tileEl.paintedImage = ts.image; } - // A local re-paint (header-click sort) — force even when the rows ref is - // unchanged (the sort mutated the panel state, not the data). - function paintForce(ts: ViewerTileState, tileEl: TileEl): void { tileEl.paintedRows = null; paintPanel(ts, tileEl); } + // A local re-paint (header-click sort) — force even when the rows/image ref + // is unchanged (the sort mutated the panel state, not the data). + function paintForce(ts: ViewerTileState, tileEl: TileEl): void { + tileEl.paintedRows = null; + tileEl.paintedImage = null; + paintPanel(ts, tileEl); + } // The ordinary (non-KPI) tile body: painted result, or an error/unfilled/ // loading state card — shared by BOTH engines' reconciliation (flow's @@ -719,6 +736,7 @@ export async function renderDashboard(app: DashboardApp): Promise { if (ts.status === 'ready') { paintPanel(ts, tileEl); return; } destroyChart(tileEl); tileEl.paintedRows = null; + tileEl.paintedImage = null; tileEl.foot.replaceChildren(); if (ts.status === 'error') { tileEl.body.replaceChildren(h('div', { class: 'dash-tile-error' }, ts.error || 'Error')); diff --git a/src/ui/icons.ts b/src/ui/icons.ts index f060bfa5..c37117b6 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -77,6 +77,9 @@ export const Icon = { code: () => svg('M5 3L2 7l3 4M9 3l3 4-3 4', 14, 14, { stroke: 1.6 }), copy: () => iconEl('', 12, 12), download: () => iconEl('', 12, 12), + // A FORMAT PNG result's own tab/toolbar glyph (#307) — a picture frame with + // a small sun + mountain, distinct from `table2`/`json`'s glyphs. + image: () => iconEl('', 12, 12), upload: () => iconEl('', 12, 12), logout: () => iconEl('', 12, 12), // Login-screen glyphs (SSO shield, password eye, host server, connect arrow). diff --git a/src/ui/panels.ts b/src/ui/panels.ts index 8c625625..52be63b4 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -39,6 +39,7 @@ import { } from '../core/result-choice.js'; import type { ResultChoice } from '../core/result-choice.js'; import type { FieldConfig, PanelCfg } from '../generated/json-schema.types.js'; +import type { ImageResultPayload } from '../core/png.js'; // ── Typed wrappers over still-untyped .js dependencies ────────────────────── // Each const pins exactly the signature this module relies on; the runtime @@ -247,6 +248,9 @@ interface PanelResult { error: unknown; rawText: string | null; panelState?: Record; + /** A validated `FORMAT PNG` image result (#307), when the run's format was + * PNG (only the Image arm ever reads this). */ + image?: ImageResultPayload | null; } /** Shared render-arm result shape — every `renderPanel` (and @@ -279,6 +283,9 @@ interface PanelRenderArgs { onCell?: (name: string, type: string, value: unknown) => void; onCfgChange?: (cfg: PanelCfg) => void; setChart?: (chart: PanelChartInstance) => void; + /** Tile/query title — only the Image arm's `alt`-text fallback reads this + * (#307: `cfg.alt || title`). */ + title?: string; } interface PanelArm { @@ -413,11 +420,44 @@ const textArm: PanelArm = { }, }; +// The Image arm (#307): a single FORMAT PNG result rendered as an , no +// column-role fields. `fit`/`background`/`alt` are authored on the cfg (Spec +// editor / presentation variants) — this arm has no inline controls, matching +// KPI's authoring-happens-in-Spec convention rather than Logs' inline pickers. +// The object URL is minted through the injected `app.createObjectUrl` seam +// (real browser: `URL.createObjectURL`) and freed via the returned `destroy` +// — the SAME `out.destroy` slot every other arm's chart/instance cleanup uses +// (dashboard.ts's `destroyChart` calls it before every repaint and whenever a +// tile leaves 'ready', so a stale blob: URL is never left dangling). +const imageArm: PanelArm = { + controls: () => null, + renderPanel({ app, result, cfg, title }: { + app: App; result: PanelResult | null; cfg: PanelCfg; title?: string; + }): PanelRenderResult { + const image = result && result.image; + if (!image) return { node: panelEmpty('Run the query (with a trailing FORMAT PNG) to preview this image.') }; + const url = app.createObjectUrl(image.bytes, image.mimeType); + const fit = (cfg.fit as string | undefined) || 'contain'; + const background = (cfg.background as string | undefined) || 'theme'; + const alt = (cfg.alt as string | undefined) || title || ''; + const img = h('img', { + class: 'panel-image panel-image-fit-' + fit, + src: url, + alt, + width: image.width, + height: image.height, + }); + const box = h('div', { class: 'panel-image-box panel-image-bg-' + background }, img); + return { node: box, destroy: () => { app.revokeObjectUrl(url); } }; + }, +}; + const PANEL_TYPES: Record = { kpi: kpiArm, table: tableArm, logs: logsArm, text: textArm, + image: imageArm, }; for (const t of CHART_FAMILY) PANEL_TYPES[t] = chartArm; export { PANEL_TYPES }; @@ -431,6 +471,7 @@ export const PANEL_PICKER_OPTIONS: { value: string; label: string }[] = [ ...CHART_TYPES, { value: 'logs', label: 'Logs' }, { value: 'text', label: 'Text' }, + { value: 'image', label: 'Image' }, ]; // ── The workbench Panel drawer tab ─────────────────────────────────────────── @@ -444,6 +485,9 @@ interface RenderResolvedPanelOpts { onCell?: (name: string, type: string, value: unknown) => void; onCfgChange?: (cfg: PanelCfg) => void; setChart?: (chart: PanelChartInstance) => void; + /** Tile/query title, threaded through to `PanelRenderArgs.title` (see its + * doc comment) — the Image arm's `alt`-text fallback. */ + title?: string; } /** diff --git a/src/ui/results.ts b/src/ui/results.ts index c274f9fc..893d4587 100644 --- a/src/ui/results.ts +++ b/src/ui/results.ts @@ -22,6 +22,7 @@ import { import type { ParameterAnalysis, PreparedFieldState, ValidationMode } from '../core/param-pipeline.js'; import { newResult } from '../core/stream.js'; import type { StreamResult } from '../core/stream.js'; +import type { ImageResultPayload } from '../core/png.js'; import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph } from './explain-graph.js'; import type { DetachedGraphApp, SchemaLineageGraph } from './explain-graph.js'; import { openInDetachedTab } from './detached-view.js'; @@ -174,6 +175,13 @@ export interface ResultsApp { updateSaveBtn(): void; updateEditorModeUi?(): void; openWindow?(url: string, target: string): DetachedWindowLike | null; + /** The Image (PNG) result view's Download button (#307) — same seam + * file-menu.ts already calls. */ + downloadFile(filename: string, mime: string, content: BlobPart): void; + /** Object-URL seam for the Image (PNG) result view (#307) — mints/frees the + * `blob:` URL an `` displays a validated PNG payload through. */ + createObjectUrl(bytes: Uint8Array, mime: string): string; + revokeObjectUrl(url: string): void; } /** The Chart.js instance shape a readonly panel's `setChart` ever receives — @@ -197,6 +205,64 @@ const EXPLAIN_ICONS: Record SVGElement> = { pipeline: Icon.share, estimate: Icon.rows, }; +// ── FORMAT PNG image result view (#307) ───────────────────────────────────── +// One `blob:` object URL per `ImageResultPayload`, keyed by the payload object +// itself (a fresh run always allocates a fresh `ImageResultPayload`, so this +// never confuses two different results) — a re-render of the SAME completed +// result (a resize, a drawer toggle, an unrelated repaint) reuses the cached +// URL instead of minting (and leaking) a new one every paint. Freed by +// `revokeResultImageUrl` below, called from the two places a result object is +// actually discarded: workbench-session.ts's `run()` (a rerun replaces +// `tab.result` wholesale) and tabs.ts's `closeTab` (the tab itself is gone). +// Never revoked from inside `renderResults` — a render never knows whether +// its result is about to be replaced. +const imageResultUrls = new WeakMap(); + +/** Return `image`'s cached object URL, minting one via `app.createObjectUrl` + * on first paint. */ +function getOrCreateImageUrl(app: Pick, image: ImageResultPayload): string { + const cached = imageResultUrls.get(image); + if (cached != null) return cached; + const url = app.createObjectUrl(image.bytes, image.mimeType); + imageResultUrls.set(image, url); + return url; +} + +/** Free `result`'s own image object URL, if it has one cached — a no-op for + * every other result shape (script/scriptExport/no-image QueryResult) and + * for an image whose URL was never actually painted (nothing to free). */ +export function revokeResultImageUrl(app: Pick, result: unknown): void { + const image = (result as { image?: ImageResultPayload | null } | null | undefined)?.image; + if (!image) return; + const url = imageResultUrls.get(image); + if (url == null) return; + app.revokeObjectUrl(url); + imageResultUrls.delete(image); +} + +/** The image view body: a scrollable, centered pane holding the `` for a + * validated PNG result. `width`/`height` attributes come straight off the + * IHDR-derived payload (no natural-size flash) — CSS caps display size at + * the pane's bounds without upscaling past the intrinsic size. Alt text is + * the active tab's name (already the saved query's name once a tab is + * linked — see `loadIntoNewTab`), falling back when a tab is somehow + * unnamed. */ +function renderImageView(app: ResultsApp, r: QueryResult): HTMLDivElement { + const image = r.image!; + const url = getOrCreateImageUrl(app, image); + const alt = app.activeTab().name || 'PNG query result'; + return h('div', { class: 'image-result-view' }, + h('img', { src: url, width: String(image.width), height: String(image.height), alt })); +} + +/** The `Download PNG` button's filename base — the active tab's name, + * sanitized like every other export filename in this codebase + * (file-menu.ts's own local `fileBase`, not shared/exported — same + * character class, kept in lockstep by convention, not an import). */ +function imageFileName(app: ResultsApp): string { + return (app.activeTab().name || 'query-result').replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, ' ').trim() || 'query-result'; +} + export function renderResults(app: ResultsApp): void { const region = app.dom.resultsRegion; if (!region) return; @@ -241,7 +307,14 @@ export function renderResults(app: ResultsApp): void { // Beyond this point `r` is narrowed to `QueryResult | null`. const view = app.state.resultView.value; const streamingBlank = app.state.running.value && (!r || (r.rows.length === 0 && r.rawText == null)); - if (streamingBlank && view !== 'filter') { + // A validated FORMAT PNG image result (#307): checked BEFORE streamingBlank + // (which reads `r.rows.length` — an image result's `rows` is always `[]`, so + // a finished image would otherwise misclassify as still-streaming/blank). + // `!r.error` is defensive: executeRead only ever sets `image` alongside a + // clean (non-error) result in practice. + if (r && r.image && !r.error) { + inner.appendChild(renderImageView(app, r)); + } else if (streamingBlank && view !== 'filter') { inner.appendChild(loadingPlaceholder('Starting query…')); } else if (!r && view !== 'panel' && view !== 'filter') { // The Panel tab renders even with no result at all (#166): a text panel @@ -625,6 +698,12 @@ function buildToolbar(app: ResultsApp, r: Result | null): HTMLDivElement { onclick: () => app.actions.setExplainView(v.id), }, icon ? icon() : null, h('span', null, v.label))); } + } else if (r && r.image) { + // A single, always-active locked tab (#307), same shape as the raw-text + // one below — an image result has no other view to switch to. + tabs = h('div', { class: 'result-view-tabs' }, + h('button', { class: 'result-view-tab active' }, + Icon.image(), h('span', null, 'Image (PNG)'))); } else if (r && r.rawText != null) { // A single, always-active tab naming the raw format (TSV/JSON) — nothing to switch to. tabs = h('div', { class: 'result-view-tabs' }, @@ -636,8 +715,8 @@ function buildToolbar(app: ResultsApp, r: Result | null): HTMLDivElement { } toolbar.appendChild(tabs); // Row-cap selector after the view tabs, for normal result queries only — - // EXPLAIN views are exempt (small output a cap would truncate oddly). - if (!(r && r.explainView)) toolbar.appendChild(rowLimitSelect(app)); + // EXPLAIN views and images (#307 — a single PNG, no row concept) are exempt. + if (!(r && (r.explainView || r.image))) toolbar.appendChild(rowLimitSelect(app)); toolbar.appendChild(h('div', { style: { flex: '1' } })); // EXPLAIN views suppress the ms/rows/bytes stats — they're not meaningful for a // plan and the freed space lets the five tabs breathe. @@ -665,7 +744,7 @@ function buildToolbar(app: ResultsApp, r: Result | null): HTMLDivElement { const ms = (r.progress.elapsed_ns / 1e6).toFixed(0); toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.clock()), h('span', { class: 'v' }, ms + ' ms'))); toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.rows()), - h('span', { class: 'v' }, (r.rawText != null ? '—' : r.rows.length) + ' rows'))); + h('span', { class: 'v' }, (r.rawText != null || r.image ? '—' : r.rows.length) + ' rows'))); toolbar.appendChild(h('div', { class: 'stat', title: r.progress.rows + ' rows scanned' }, h('span', { class: 'ic' }, Icon.bytes()), h('span', { class: 'v' }, formatBytes(r.progress.bytes)))); // The result hit the row cap: say so (the fetch stopped at the limit, more @@ -695,10 +774,26 @@ function buildToolbar(app: ResultsApp, r: Result | null): HTMLDivElement { onclick: () => expandDataPane(app, r), }, Icon.expand(), h('span', null, 'Expand'))); } - toolbar.appendChild(h('button', { - class: 'res-act', title: 'Copy results to clipboard', - onclick: () => app.actions.copyResult(), - }, Icon.copy(), h('span', null, 'Copy'))); + if (r.image) { + // Image results have no tabular/clipboard-text form (#307) — Copy is + // replaced outright by a Download button handing back the exact + // validated bytes, never re-derived from the ``/object URL. + toolbar.appendChild(h('button', { + class: 'res-act', title: 'Download the PNG result', + // `Uint8Array`'s default `ArrayBufferLike` type param (this TS/lib + // combo) doesn't structurally satisfy `BlobPart`'s `ArrayBufferView + // ` (excludes `SharedArrayBuffer`) — the runtime value + // is always a real, non-shared `Uint8Array` (ch-client.ts's own + // `new Uint8Array(buf)` off a fetch `ArrayBuffer`), so this is a + // type-only bridge, not a behavior change. + onclick: () => app.downloadFile(imageFileName(app) + '.png', 'image/png', r.image!.bytes as unknown as BlobPart), + }, Icon.download(), h('span', null, 'Download PNG'))); + } else { + toolbar.appendChild(h('button', { + class: 'res-act', title: 'Copy results to clipboard', + onclick: () => app.actions.copyResult(), + }, Icon.copy(), h('span', null, 'Copy'))); + } } } return toolbar; diff --git a/src/ui/tabs.ts b/src/ui/tabs.ts index 48b948c2..d58d05db 100644 --- a/src/ui/tabs.ts +++ b/src/ui/tabs.ts @@ -11,6 +11,7 @@ import type { AppDom, ActionsRegistry } from './app.types.js'; import type { AppState, QueryTab } from '../state.js'; import type { EditorPort } from '../editor/editor-port.types.js'; import type { SpecEditorPort } from '../editor/spec-editor.types.js'; +import { revokeResultImageUrl } from './results.js'; /** The narrow slice of the real `app` controller this module reads — not the * full ~50-member `App` contract (app.types.ts). `state` is the real @@ -25,6 +26,10 @@ export interface TabsApp { actions: Pick; specEditor: Pick; sqlEditor: Pick; + /** Object-URL seam for the Image (PNG) result view (#307) — `closeTab` + * revokes a closed tab's own result image URL through it (nothing else + * in this module reads/writes a result's image). */ + revokeObjectUrl(url: string): void; } /** The shape `onOpen()` (filterRoleBadge's caller) must hand back — only @@ -139,6 +144,12 @@ export function loadIntoNewTab(app: TabsApp, queryOrName: QueryOrName, sql = '') export function closeTab(app: TabsApp, id: string): void { if (app.state.tabs.value.length <= 1) return; const idx = app.state.tabs.value.findIndex((t) => t.id === id); + // Free the closed tab's own Image (PNG) result URL (#307) — its blob would + // otherwise leak for the life of the page (nothing else revokes it once the + // tab itself is gone; `run()`'s own rerun-revocation only frees a tab's + // PREVIOUS result, never the current one). + const closed = app.state.tabs.value[idx]; + if (closed) revokeResultImageUrl(app, closed.result); batch(() => { app.state.tabs.value = app.state.tabs.value.filter((t) => t.id !== id); if (id === app.state.activeTabId.value) { diff --git a/src/ui/workbench/workbench-session.ts b/src/ui/workbench/workbench-session.ts index 050d9f89..d54bf629 100644 --- a/src/ui/workbench/workbench-session.ts +++ b/src/ui/workbench/workbench-session.ts @@ -124,6 +124,14 @@ export interface WorkbenchHooks { * ConnectionSession sign-out flow (e.g. `() => chCtx.onSignedOut()`); the * session stays ignorant of `chCtx`. */ onAuthFailed(): void; + /** Frees a discarded result's own Image (PNG) object URL (#307) — called + * by `run()` right before a rerun overwrites `tab.result` wholesale (the + * filter-error/KPI-error early-return branches included). A no-op for + * every non-image result, so every call site can invoke it unconditionally + * rather than checking first. The session stays ignorant of `app. + * revokeObjectUrl`/results.ts's URL cache, same seam shape as + * `onAuthFailed`. */ + revokeResultImage(result: unknown): void; } // ── Construction deps ──────────────────────────────────────────────────────── @@ -238,6 +246,7 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes if (filterRun?.error) { const filterErrorResult: QueryResult = newResult('Filter', filterRun.rowLimit); filterErrorResult.error = filterRun.error; + hooks.revokeResultImage(tab.result); Object.assign(tab, { result: filterErrorResult }); tab.filterPreview = { status: 'error', error: filterRun.error }; state.resultView.value = 'filter'; @@ -280,6 +289,7 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes if (kpiExecution.error) { const kpiErrorResult: QueryResult = newResult('KPI', 2); kpiErrorResult.error = kpiExecution.error; + hooks.revokeResultImage(tab.result); Object.assign(tab, { result: kpiErrorResult }); state.resultView.value = 'panel'; hooks.renderResults(); @@ -324,6 +334,7 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes const rowLimit = isFilter ? filterRun!.rowLimit : explainMode ? 0 : panelIsKpi ? kpiExecution.rowLimit! : state.resultRowLimit; const t0 = deps.now(); const result: QueryResult = newResult(fmt, rowLimit); + hooks.revokeResultImage(tab.result); Object.assign(tab, { result }); if (isFilter) tab.filterPreview = { status: 'running' }; if (explainView) result.explainView = explainView; @@ -448,6 +459,7 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes const t0 = deps.now(); const entries: ScriptEntry[] = []; const scriptResult: ScriptResult = { script: entries }; + hooks.revokeResultImage(tab.result); Object.assign(tab, { result: scriptResult }); state.resultSort = { col: null, dir: 'asc' }; runT0 = t0; diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index ddc04fca..d4c585f6 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -343,6 +343,8 @@ const appDefaults: App = { saveVarRecent: () => {}, recordHistory: () => {}, downloadFile: () => {}, + createObjectUrl: () => 'blob:fake', + revokeObjectUrl: () => {}, editingLibrary: false, updateBanner: () => {}, wallNow: () => 0, @@ -538,6 +540,8 @@ export function makeApp>(override saveJSON: vi.fn(), saveStr: vi.fn(), downloadFile: vi.fn(), + createObjectUrl: vi.fn(() => 'blob:fake'), + revokeObjectUrl: vi.fn(), updateSaveBtn: vi.fn(), updateEditorModeUi: vi.fn(), activateInvalidSpecDraft: vi.fn(), diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index 32799558..43e5079a 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -3582,6 +3582,21 @@ describe('exhaustive controller coverage', () => { app.actions.copyResult(); expect(qs(document, '.share-toast').textContent).toBe('Nothing to copy'); }); + it('copyResult: a FORMAT PNG image result (#307) is never exportable, even if `rows`/`rawText` somehow carried data', async () => { + // exportableResult()'s `!r.image` guard is explicit (not just implied by + // rows.length/rawText being empty on a real image result) — this proves it + // actually excludes, rather than being a no-op given today's data shape. + const writeText = vi.fn(async () => {}); + const app = createApp(env({ window: fakeWin(), navigator: { clipboard: asClipboard({ writeText }) } })); + app.renderApp(); + app.activeTab().result = { + error: null, rawText: null, columns: [{ name: 'a' }], rows: [['1']], + image: { kind: 'image', format: 'PNG', mimeType: 'image/png', bytes: new Uint8Array([1]), width: 1, height: 1 }, + }; + app.actions.copyResult(); + expect(writeText).not.toHaveBeenCalled(); + expect(qs(document, '.share-toast').textContent).toBe('Nothing to copy'); + }); it('copyResult: no clipboard → not-supported; rejection → failed', async () => { const app = createApp(env({ window: fakeWin(), navigator: {} })); app.renderApp(); @@ -3615,6 +3630,32 @@ describe('exhaustive controller coverage', () => { expect(createObjectURL).toHaveBeenCalled(); expect(revokeObjectURL).toHaveBeenCalledWith('blob:u'); }); + it('createObjectUrl/revokeObjectUrl (#307): injected env seam wins; native path uses Blob + URL.createObjectURL/revokeObjectURL', () => { + // The Image (PNG) result view's own object-URL seam (results.ts) — mirrors + // downloadFile's injected-seam-with-real-fallback shape one test above. + const createObjectUrl = vi.fn(() => 'blob:injected'); + const revokeObjectUrl = vi.fn(); + const app = createApp(env({ window: fakeWin(), createObjectUrl, revokeObjectUrl })); + app.renderApp(); + const bytes = new Uint8Array([1, 2, 3]); + expect(app.createObjectUrl(bytes, 'image/png')).toBe('blob:injected'); + expect(createObjectUrl).toHaveBeenCalledWith(bytes, 'image/png'); + app.revokeObjectUrl('blob:injected'); + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:injected'); + + const createObjectURL = vi.fn(() => 'blob:native'); + const revokeObjectURL = vi.fn(); + const app2 = createApp(env({ window: asWindow({ + ...fakeWin(), + URL: { createObjectURL, revokeObjectURL }, + Blob: class { parts: unknown; opts: unknown; constructor(parts: unknown, opts: unknown) { this.parts = parts; this.opts = opts; } }, + }) })); + app2.renderApp(); + expect(app2.createObjectUrl(bytes, 'image/png')).toBe('blob:native'); + expect(createObjectURL).toHaveBeenCalled(); + app2.revokeObjectUrl('blob:native'); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:native'); + }); it('shows and dismisses the auth-failure banner', () => { const app = createApp(env()); diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 400223b5..4c0d1a43 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -8,9 +8,14 @@ import type { import type { DashboardDocumentV1, DashboardFilterDefinitionV1, DashboardTileV1, SavedQueryV2, } from '../../src/generated/json-schema.types.js'; +import type { ImageResultPayload } from '../../src/core/png.js'; // ── Fixtures ──────────────────────────────────────────────────────────────── +const fakeImage = (): ImageResultPayload => ({ + kind: 'image', format: 'PNG', mimeType: 'image/png', bytes: new Uint8Array([1, 2, 3]), width: 4, height: 5, +}); + interface Resp { columns?: { name: string; type?: string }[]; rows?: unknown[][]; @@ -18,6 +23,7 @@ interface Resp { cancelled?: boolean; bytes?: number; progressRows?: number; + image?: ImageResultPayload | null; } type Responder = (sql: string, req: ViewerReadRequest) => Resp | Promise; @@ -33,6 +39,7 @@ function makeExec(responder: Responder = () => ({})) { result.progress.bytes = resp.bytes ?? 10; result.error = resp.error ?? null; result.cancelled = resp.cancelled ?? false; + result.image = resp.image ?? null; }, }; return { exec, calls }; @@ -178,6 +185,83 @@ describe('createDashboardViewerSession', () => { expect(s2.state.value.tiles[0].error).toContain('KPI'); }); + it('runs an Image panel through the owned PNG transport and publishes its image state', async () => { + const image = fakeImage(); + // A real binary (PNG) response never populates columns/rows (executeRead's + // binary branch only ever sets `result.image`) — pin that explicitly here + // since the fake responder otherwise defaults `columns` to `[{name:'n'}]`. + const { exec, calls } = makeExec(() => ({ image, columns: [], rows: [] })); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('q1', 'SELECT plot() FORMAT PNG', { panel: { cfg: { type: 'image' } } })], + })); + await session.start(); + expect(calls[0].format).toBe('PNG'); + const ts = session.state.value.tiles[0]; + expect(ts.isImage).toBe(true); + expect(ts.status).toBe('ready'); + expect(ts.image).toEqual(image); + expect(ts.columns).toEqual([]); + expect(ts.rows).toEqual([]); + }); + + it('rejects an Image panel whose SQL has no FORMAT clause, issuing no request', async () => { + const { exec, calls } = makeExec(); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('q1', 'SELECT plot()', { panel: { cfg: { type: 'image' } } })], + })); + await session.start(); + expect(calls.length).toBe(0); + expect(session.state.value.tiles[0].status).toBe('error'); + expect(session.state.value.tiles[0].error).toContain('FORMAT PNG'); + }); + + it('rejects an Image panel whose authored FORMAT is not PNG, issuing no request', async () => { + const { exec, calls } = makeExec(); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('q1', 'SELECT plot() FORMAT CSV', { panel: { cfg: { type: 'image' } } })], + })); + await session.start(); + expect(calls.length).toBe(0); + expect(session.state.value.tiles[0].error).toContain('FORMAT PNG'); + }); + + it('a non-image tile with an authored FORMAT PNG is still rejected by the blanket gate', async () => { + const { exec, calls } = makeExec(); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('q1', 'SELECT 1 FORMAT PNG')], + })); + await session.start(); + expect(calls.length).toBe(0); + expect(session.state.value.tiles[0].error).toContain('FORMAT'); + }); + + it('a stale generation cannot replace a newer Image result (stale-wave suppression)', async () => { + let resolveFirst!: () => void; + const first = new Promise((resolve) => { resolveFirst = resolve; }); + let call = 0; + const document = doc({ tiles: [tile('t1', 'q1')] }); + const exec: ViewerExecutor = { + async executeRead(result, req) { + call += 1; + if (call === 1) { await first; result.image = fakeImage(); } // slow, stale + else result.image = { ...fakeImage(), width: 999 }; // fast, fresh + void req; + }, + }; + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('q1', 'SELECT plot() FORMAT PNG', { panel: { cfg: { type: 'image' } } })], + })); + const firstRun = session.refreshTile('t1'); + await session.refreshTile('t1'); // supersedes the first (still pending) run + resolveFirst(); + await firstRun; + expect(session.state.value.tiles[0].image?.width).toBe(999); // the newer result wins + }); + it('runs an optional-block tile and surfaces a query error / progress', async () => { const seenProgress: number[] = []; const { exec } = makeExec((sql) => (sql.includes('boom') diff --git a/tests/unit/panel-cfg.test.ts b/tests/unit/panel-cfg.test.ts index 3edb07ac..2fa57e26 100644 --- a/tests/unit/panel-cfg.test.ts +++ b/tests/unit/panel-cfg.test.ts @@ -24,6 +24,7 @@ describe('type sets', () => { expect(PANEL_TYPE_IDS).toContain('table'); expect(PANEL_TYPE_IDS).toContain('logs'); expect(PANEL_TYPE_IDS).toContain('text'); + expect(PANEL_TYPE_IDS).toContain('image'); expect(isChartFamily('pie')).toBe(true); expect(isChartFamily('table')).toBe(false); expect(isKnownPanelType('logs')).toBe(true); @@ -110,9 +111,11 @@ describe('panelCfgValid', () => { expect(panelCfgValid({ type: 'logs' }, logCols)).toBe(true); expect(panelCfgValid({ type: 'logs', time: 'gone' }, logCols)).toBe(false); }); - it('table and text carry no schema-bound fields (always valid); unknown/absent type is not', () => { + it('table, text, and image carry no schema-bound fields (always valid); unknown/absent type is not', () => { expect(panelCfgValid({ type: 'table' }, strCols)).toBe(true); expect(panelCfgValid({ type: 'text', content: 'hi' }, [])).toBe(true); + expect(panelCfgValid({ type: 'image' }, [])).toBe(true); + expect(panelCfgValid({ type: 'image', fit: 'cover' }, strCols)).toBe(true); expect(panelCfgValid({ type: 'gauge' }, strCols)).toBe(false); expect(panelCfgValid({}, strCols)).toBe(false); expect(panelCfgValid(null, strCols)).toBe(false); @@ -137,6 +140,10 @@ describe('normalizePanelCfg', () => { expect(normalizePanelCfg(cfg)).toBe(cfg); expect(normalizePanelCfg(null)).toBeNull(); }); + it('image passes through untouched (no cross-field invariants)', () => { + const cfg = { type: 'image', fit: 'cover', background: 'checkerboard', alt: 'chart' }; + expect(normalizePanelCfg(cfg)).toBe(cfg); + }); }); describe('autoPanel', () => { @@ -216,6 +223,15 @@ describe('switchPanelType', () => { expect(switchPanelType(null, 'text', []).cfg).toEqual({ type: 'text', content: '' }); expect(switchPanelType({ cfg: null }, 'table', []).cfg).toEqual({ type: 'table' }); }); + it('switching to image is a plain type swap; fit/background/alt ride along through a round-trip', () => { + const toImage = switchPanelType({ cfg: { type: 'table' } }, 'image', []); + expect(toImage.cfg).toEqual({ type: 'image' }); + const authored = { cfg: { type: 'image', fit: 'cover', background: 'checkerboard', alt: 'x' } }; + const away = switchPanelType(authored, 'table', []); + expect(away.cfg).toMatchObject({ type: 'table', fit: 'cover', background: 'checkerboard', alt: 'x' }); + const back = switchPanelType({ cfg: away.cfg, key: away.key }, 'image', []); + expect(back.cfg).toEqual({ type: 'image', fit: 'cover', background: 'checkerboard', alt: 'x' }); + }); }); describe('resolvePanel', () => { @@ -321,6 +337,13 @@ describe('resolvePanel', () => { expect(text.cfg.content).toBe('# hi'); expect(text.fallback).toBe(false); }); + it('image passes through for any result shape (no column-role fields)', () => { + const noCols = resolvePanel({ cfg: { type: 'image', fit: 'cover' } }, []); + expect(noCols).toMatchObject({ rederived: false, fallback: false }); + expect(noCols.cfg).toEqual({ type: 'image', fit: 'cover' }); + const withCols = resolvePanel({ cfg: { type: 'image' } }, chartCols); + expect(withCols).toMatchObject({ rederived: false, fallback: false }); + }); it('an unknown type (newer build) falls back with a diagnostic naming it', () => { const out = resolvePanel({ cfg: { type: 'gauge', max: 100 } }, chartCols); expect(out.fallback).toBe(true); diff --git a/tests/unit/panel-execution.test.ts b/tests/unit/panel-execution.test.ts index 1358ddd7..67aae0fe 100644 --- a/tests/unit/panel-execution.test.ts +++ b/tests/unit/panel-execution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { explicitPanel, isKpiPanel, panelExecution } from '../../src/core/panel-execution.js'; +import { explicitPanel, isImagePanel, isKpiPanel, panelExecution } from '../../src/core/panel-execution.js'; describe('panel execution ownership', () => { it('leaves non-KPI execution unchanged', () => { @@ -21,6 +21,37 @@ describe('panel execution ownership', () => { }); }); +describe('image panel ownership (#307)', () => { + it('isImagePanel narrows on cfg.type', () => { + expect(isImagePanel(null)).toBe(false); + expect(isImagePanel({ cfg: { type: 'table' } })).toBe(false); + expect(isImagePanel({ cfg: { type: 'image' } })).toBe(true); + }); + it('accepts an authored FORMAT PNG (case-insensitive) with rowLimit 0', () => { + const out = panelExecution({ cfg: { type: 'image' } }, 'SELECT plot() FORMAT png', { params: { readonly: 2 } }); + expect(out).toEqual({ owned: true, error: null, format: 'PNG', rowLimit: 0, params: { readonly: 2 } }); + }); + it('rejects a missing FORMAT clause (leaving the caller-owned format default untouched)', () => { + const out = panelExecution({ cfg: { type: 'image' } }, 'SELECT plot()', { format: 'Table' }); + expect(out.owned).toBe(true); + expect(out.error).toBe('Image panel requires an explicit FORMAT PNG clause.'); + expect(out.format).toBe('Table'); // an error result never overrides the caller's default + }); + it('rejects an authored FORMAT other than PNG', () => { + const out = panelExecution({ cfg: { type: 'image' } }, 'SELECT plot() FORMAT CSV'); + expect(out.owned).toBe(true); + expect(out.error).toBe('Image panel requires FORMAT PNG. Remove FORMAT CSV from the SQL.'); + }); + it('KPI (and any other panel) with an authored FORMAT PNG is still rejected exactly as any other FORMAT', () => { + const kpi = panelExecution({ cfg: { type: 'kpi' } }, 'SELECT 1 FORMAT PNG'); + expect(kpi.error).toBe('KPI panel owns the result format. Remove FORMAT PNG from the SQL.'); + // A non-KPI, non-image panel is a pass-through — its caller (the Dashboard + // viewer session) is the one that blanket-rejects an authored FORMAT. + const table = panelExecution({ cfg: { type: 'table' } }, 'SELECT 1 FORMAT PNG'); + expect(table.owned).toBe(false); + }); +}); + describe('explicitPanel', () => { it('returns the saved panel when its cfg is a plain object', () => { const query = { spec: { panel: { cfg: { type: 'kpi' } } } }; diff --git a/tests/unit/panels.test.ts b/tests/unit/panels.test.ts index 2c4dba3e..77302f23 100644 --- a/tests/unit/panels.test.ts +++ b/tests/unit/panels.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { renderMarkdown, renderResolvedPanel, PANEL_TYPES } from '../../src/ui/panels.js'; import { renderResults } from '../../src/ui/results.js'; import { parseMarkdown } from '../../src/core/markdown-lite.js'; @@ -10,6 +10,7 @@ import { DASHBOARD_ROLE_RESULT_CHOICES, PANEL_RESULT_CHOICES } from '../../src/c import type { App, Tab } from '../../src/ui/app.types.js'; import type { PanelCfg } from '../../src/generated/json-schema.types.js'; import type { Column } from '../../src/core/panel-cfg.js'; +import type { ImageResultPayload } from '../../src/core/png.js'; // core/stream.js is plain JS; without an explicit return-type annotation TS // infers the empty-array initializers of `columns`/`rows` as `never[]`, @@ -28,7 +29,7 @@ interface StreamResult { pct: number; rowLimit: number; capped: boolean; - image: unknown; + image: ImageResultPayload | null; // QueryTab.result (state.ts) holds this as an opaque Record // (only results.js knows the concrete shape); the index signature lets a // StreamResult assign straight into `tab.result` without a further cast. @@ -706,3 +707,68 @@ describe('renderResolvedPanel', () => { expect(() => out.destroy!()).not.toThrow(); // idempotent }); }); + +// ── Image arm (#307) ────────────────────────────────────────────────────────── +describe('image panel arm', () => { + const fakeImage = () => ({ + kind: 'image' as const, format: 'PNG' as const, mimeType: 'image/png' as const, + bytes: new Uint8Array([1, 2, 3]), width: 4, height: 5, + }); + + it('has no inline controls — authoring happens on the cfg (Spec editor)', () => { + expect(PANEL_TYPES.image.controls({ + app: makeApp(), result: null, cfg: { type: 'image' }, onChange: () => {}, + })).toBeNull(); + }); + + it('shows the empty hint before any image result is available', () => { + const out = PANEL_TYPES.image.renderPanel({ app: makeApp(), result: null, cfg: { type: 'image' } }); + expect(out.node.textContent).toContain('FORMAT PNG'); + }); + + it('mints an object URL through the injected seam, sets fit/background/alt, and frees it on destroy', () => { + const image = fakeImage(); + const created: [Uint8Array, string][] = []; + const revoked: string[] = []; + const app = makeApp({ + createObjectUrl: vi.fn((bytes: Uint8Array, mime: string) => { created.push([bytes, mime]); return 'blob:test-1'; }), + revokeObjectUrl: vi.fn((url: string) => { revoked.push(url); }), + }); + const out = PANEL_TYPES.image.renderPanel({ + app, result: { columns: [], rows: [], error: null, rawText: null, image }, + cfg: { type: 'image', fit: 'cover', background: 'checkerboard', alt: 'a plot' }, + title: 'My tile', + }); + const img = qs(out.node, 'img'); + expect(img.src).toBe('blob:test-1'); + expect(img.alt).toBe('a plot'); // explicit alt wins over the title fallback + expect(img.className).toContain('panel-image-fit-cover'); + expect(out.node.className).toContain('panel-image-bg-checkerboard'); + expect(created).toEqual([[image.bytes, image.mimeType]]); + out.destroy!(); + expect(revoked).toEqual(['blob:test-1']); + }); + + it('defaults fit=contain, background=theme, and alt falls back to the tile/query title', () => { + const app = makeApp(); + const image = fakeImage(); + const out = PANEL_TYPES.image.renderPanel({ + app, result: { columns: [], rows: [], error: null, rawText: null, image }, + cfg: { type: 'image' }, title: 'Fallback title', + }); + const img = qs(out.node, 'img'); + expect(img.className).toContain('panel-image-fit-contain'); + expect(out.node.className).toContain('panel-image-bg-theme'); + expect(img.alt).toBe('Fallback title'); + }); + + it('renders through renderResolvedPanel end to end (resolvePanel → registry dispatch)', () => { + const app = makeApp(); + const image = fakeImage(); + const resolved = resolvePanel({ cfg: { type: 'image', fit: 'actual' } }, []); + const { node } = renderResolvedPanel(app, resolved, { + columns: [], rows: [], error: null, rawText: null, image, + }, { surface: 'dashboard', state: {}, rerender: () => {}, readonly: true, onCell: () => {} }); + expect(qs(node, '.panel-image-fit-actual')).not.toBeNull(); + }); +}); diff --git a/tests/unit/result-choice.test.ts b/tests/unit/result-choice.test.ts index b72646ab..55b7f027 100644 --- a/tests/unit/result-choice.test.ts +++ b/tests/unit/result-choice.test.ts @@ -14,6 +14,8 @@ describe('result choices', () => { expect(resultChoiceForSpec({})).toBe('panel:auto'); expect(resultChoiceForSpec({ dashboard: { role: 'filter' }, panel: { cfg: { type: 'line' } } })).toBe('role:filter'); expect(PANEL_RESULT_CHOICES.some((c) => c.id === 'panel:kpi')).toBe(true); + expect(PANEL_RESULT_CHOICES.some((c) => c.id === 'panel:image')).toBe(true); + expect(resultChoiceForSpec({ panel: { cfg: { type: 'image' } } })).toBe('panel:image'); expect(DASHBOARD_ROLE_RESULT_CHOICES).toEqual([{ id: 'role:filter', kind: 'role', role: 'filter', label: 'Filter' }]); }); it('maps a table (or unknown) panel to panel:auto, since Table is not a picker option', () => { diff --git a/tests/unit/results.test.ts b/tests/unit/results.test.ts index d09434f0..aee7d083 100644 --- a/tests/unit/results.test.ts +++ b/tests/unit/results.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { - renderResults, renderJson, renderTable, openCellDetail, openRowsViewer, expandDataPane, + renderResults, renderJson, renderTable, openCellDetail, openRowsViewer, expandDataPane, revokeResultImageUrl, } from '../../src/ui/results.js'; import type { QueryResult, ScriptResult, ScriptExportResult, ScriptEntry, ScriptExportEntry, @@ -8,6 +8,7 @@ import type { import { makeApp } from '../helpers/fake-app.js'; import type { FakeChart } from '../helpers/fake-app.js'; import { newResult as newResultUntyped } from '../../src/core/stream.js'; +import type { ImageResultPayload } from '../../src/core/png.js'; import { formatRows } from '../../src/core/format.js'; import { queryPanel } from '../../src/core/saved-query.js'; import type { AppState, ResultSort } from '../../src/state.js'; @@ -93,6 +94,19 @@ function tableResult(): Indexed { return r; } +// A validated FORMAT PNG image result (#307) — same `newResult()` shape as +// every other fixture here, with `image` populated the way executeRead only +// ever sets it (a real result never carries both `image` and `rows`/`rawText`). +function imageResult(overrides: Partial = {}): Indexed { + const r = newResult('PNG'); + r.image = { + kind: 'image', format: 'PNG', mimeType: 'image/png', + bytes: new Uint8Array([1, 2, 3, 4]), width: 10, height: 20, + ...overrides, + }; + return r; +} + describe('renderResults states', () => { it('no-ops without a region', () => { const app = makeApp(); @@ -154,6 +168,76 @@ describe('renderResults states', () => { renderResults(app); expect(qs(app.dom.resultsRegion, '.result-view-tab').textContent).toContain('JSON'); }); + it('image result (#307): a centered from a minted object URL, sized from the payload, alt = tab name', () => { + const r = imageResult(); + const app = appWithResult(r); + renderResults(app); + const img = qs(app.dom.resultsRegion, '.image-result-view img'); + expect(img.getAttribute('src')).toBe('blob:fake'); + expect(img.getAttribute('width')).toBe('10'); + expect(img.getAttribute('height')).toBe('20'); + expect(img.getAttribute('alt')).toBe('Untitled'); + expect(app.createObjectUrl).toHaveBeenCalledTimes(1); + expect(app.createObjectUrl).toHaveBeenCalledWith(r.image!.bytes, 'image/png'); + }); + it('image result: falls back to a generic alt when the tab is somehow unnamed', () => { + const r = imageResult(); + const app = appWithResult(r); + app.activeTab().name = ''; + renderResults(app); + const img = qs(app.dom.resultsRegion, '.image-result-view img'); + expect(img.getAttribute('alt')).toBe('PNG query result'); + }); + it('image result: a re-render of the SAME payload reuses the cached object URL', () => { + const r = imageResult(); + const app = appWithResult(r); + renderResults(app); + renderResults(app); + expect(app.createObjectUrl).toHaveBeenCalledTimes(1); + }); + it('image result: a locked "Image (PNG)" tab, row-limit selector hidden, Copy replaced by Download PNG (exact bytes, no Copy)', () => { + const r = imageResult(); + const app = appWithResult(r); + renderResults(app); + const region = app.dom.resultsRegion; + expect(qsa(region, '.result-view-tab')).toHaveLength(1); + expect(qs(region, '.result-view-tab').textContent).toContain('Image (PNG)'); + expect(region.querySelector('.row-limit')).toBeNull(); + expect([...qsa(region, '.res-act')].some((b) => /Copy/.test(b.textContent))).toBe(false); + const dl = [...qsa(region, '.res-act')].find((b) => /Download PNG/.test(b.textContent)); + expect(dl).not.toBeUndefined(); + click(dl); + expect(app.downloadFile).toHaveBeenCalledWith('Untitled.png', 'image/png', r.image!.bytes); + }); + it('image result: streamingBlank never misclassifies a finished image (rows is always [])', () => { + const r = imageResult(); + const app = appWithResult(r, { running: false }); + renderResults(app); + expect(qs(app.dom.resultsRegion, '.image-result-view')).not.toBeNull(); + expect(app.dom.resultsRegion.textContent).not.toContain('Starting query…'); + }); + it('revokeResultImageUrl (#307): frees a painted image\'s URL, once, and is a no-op otherwise', () => { + const r = imageResult(); + const app = appWithResult(r); + // Never painted (no createObjectUrl call yet) → nothing to free. + revokeResultImageUrl(app, r); + expect(app.revokeObjectUrl).not.toHaveBeenCalled(); + renderResults(app); // mints + caches the URL + revokeResultImageUrl(app, r); + expect(app.revokeObjectUrl).toHaveBeenCalledWith('blob:fake'); + expect(app.revokeObjectUrl).toHaveBeenCalledTimes(1); + // A second revoke of the same (already-freed) result is a no-op — no + // double-free, and a later re-render would mint a genuinely fresh URL. + revokeResultImageUrl(app, r); + expect(app.revokeObjectUrl).toHaveBeenCalledTimes(1); + }); + it('revokeResultImageUrl: no-ops for null/scriptResult/non-image QueryResult', () => { + const app = appWithResult(null); + expect(() => revokeResultImageUrl(app, null)).not.toThrow(); + expect(() => revokeResultImageUrl(app, { script: [] })).not.toThrow(); + expect(() => revokeResultImageUrl(app, tableResult())).not.toThrow(); + expect(app.revokeObjectUrl).not.toHaveBeenCalled(); + }); it('reports 0 rows', () => { const r = newResult('Table'); renderResults(appWithResult(r)); diff --git a/tests/unit/spec-completion.test.ts b/tests/unit/spec-completion.test.ts index a99a69f3..f113465f 100644 --- a/tests/unit/spec-completion.test.ts +++ b/tests/unit/spec-completion.test.ts @@ -60,7 +60,7 @@ describe('pure Spec completion', () => { positionKind: 'property-value', }); expect(items.filter((item) => item.kind === 'variant').map((item) => item.label)).toEqual([ - 'bar', 'hbar', 'line', 'area', 'pie', 'kpi', 'table', 'logs', 'text', + 'bar', 'hbar', 'line', 'area', 'pie', 'kpi', 'table', 'logs', 'text', 'image', ]); expect(items.map((item) => item.label)).not.toContain('future-panel'); expect(items.find((item) => item.label === 'line')!.documentation).toContain('Line series'); diff --git a/tests/unit/spec-schema.test.ts b/tests/unit/spec-schema.test.ts index 794b86e3..3420e4e3 100644 --- a/tests/unit/spec-schema.test.ts +++ b/tests/unit/spec-schema.test.ts @@ -165,11 +165,11 @@ describe('schema lookup', () => { it('retains ordered candidates while a discriminator is incomplete', () => { const root = { panel: { cfg: {} } }; const schema = querySpecSchemaService.schemaAtPath({ root, path: ['panel', 'cfg'] }); - expect(schema.candidates).toHaveLength(10); + expect(schema.candidates).toHaveLength(11); expect(schema.common['x-altinity-discriminator']).toBe('type'); const typeProperty = querySpecSchemaService.propertiesAtPath({ root, path: ['panel', 'cfg'] })[0]; expect(typeProperty.name).toBe('type'); - expect(typeProperty.schemas).toHaveLength(10); + expect(typeProperty.schemas).toHaveLength(11); expect(typeProperty.schemas.every((candidate) => candidate.title === 'Panel type' && candidate.type === 'string' && candidate.minLength === 1)).toBe(true); const unknown = querySpecSchemaService.annotationsAtPath({ @@ -179,7 +179,7 @@ describe('schema lookup', () => { expect(querySpecSchemaService.variantsAtPath({ root: { panel: { cfg: { type: 'line' } } }, path: ['panel', 'cfg', 'type'], }).map((variant) => variant.value)).toEqual([ - 'bar', 'hbar', 'line', 'area', 'pie', 'kpi', 'table', 'logs', 'text', + 'bar', 'hbar', 'line', 'area', 'pie', 'kpi', 'table', 'logs', 'text', 'image', ]); expect(querySpecSchemaService.variantsAtPath({ root: {}, path: [] })).toEqual([]); expect(querySpecSchemaService.variantsAtPath({ root: {}, path: [0] })).toEqual([]); diff --git a/tests/unit/tabs.test.ts b/tests/unit/tabs.test.ts index 11da9b05..05d12b62 100644 --- a/tests/unit/tabs.test.ts +++ b/tests/unit/tabs.test.ts @@ -155,4 +155,26 @@ describe('closeTab', () => { closeTab(app, 't2'); expect(app.state.activeTabId.value).toBe('t1'); }); + it('closing a tab holding a FORMAT PNG image result frees its object URL (#307)', () => { + const app = makeApp(); + const image = { kind: 'image', format: 'PNG', mimeType: 'image/png', bytes: new Uint8Array([1]), width: 1, height: 1 }; + app.state.tabs.value = [ + { id: 't1', name: 'A' } as QueryTab, + { id: 't2', name: 'B', result: { image } } as unknown as QueryTab, + ]; + app.state.activeTabId.value = 't1'; + closeTab(app, 't2'); + // `revokeResultImageUrl` (results.ts) is a no-op unless the URL was + // actually minted (cached) — the closed tab's result never went through + // renderResults here, so nothing was cached; the seam itself, and the + // "no cached URL" no-op path, are exercised directly in results.test.ts. + expect(app.revokeObjectUrl).not.toHaveBeenCalled(); + expect(app.state.tabs.value.map((t) => t.id)).toEqual(['t1']); + }); + it('closing a tab with no result at all is a no-op for revokeObjectUrl', () => { + const app = makeApp(); + app.state.tabs.value = [{ id: 't1', name: 'A' } as QueryTab, { id: 't2', name: 'B', result: null } as QueryTab]; + closeTab(app, 't2'); + expect(app.revokeObjectUrl).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/workbench-session.test.ts b/tests/unit/workbench-session.test.ts index 90bf62da..d8b33054 100644 --- a/tests/unit/workbench-session.test.ts +++ b/tests/unit/workbench-session.test.ts @@ -98,6 +98,7 @@ function makeHooks(over: Partial = {}): WorkbenchHooks { tickElapsed: vi.fn(), saveJSON: vi.fn(), onAuthFailed: vi.fn(), + revokeResultImage: vi.fn(), ...over, }; } From e328ca03d3c3578a31ea0b6704bf4affd3072660 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 19 Jul 2026 11:08:38 +0000 Subject: [PATCH 3/9] feat(#307): image-panels demo library + real-browser e2e for PNG results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/image-panels.json ships four FORMAT PNG demos (spiral, RGB gradient, RGBA checkerboard glow, original ray-traced sphere credited to ClickHouse's RayTracer demos) — all validated live against ClickHouse 26.6. New e2e fixture proves the Workbench Image view and Dashboard tile aspect-preserving fit with real pixel sampling in chromium+webkit. README notes the 26.6+ requirement. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB --- README.md | 5 ++ examples/image-panels.json | 84 +++++++++++++++++++++++ tests/e2e/image-result.html | 118 +++++++++++++++++++++++++++++++++ tests/e2e/image-result.spec.js | 110 ++++++++++++++++++++++++++++++ 4 files changed, 317 insertions(+) create mode 100644 examples/image-panels.json create mode 100644 tests/e2e/image-result.html create mode 100644 tests/e2e/image-result.spec.js diff --git a/README.md b/README.md index d17c21f0..2263af95 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,11 @@ 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) 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 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/tests/e2e/image-result.html b/tests/e2e/image-result.html new file mode 100644 index 00000000..057238a0 --- /dev/null +++ b/tests/e2e/image-result.html @@ -0,0 +1,118 @@ + + + + + FORMAT PNG image result harness (#307) + + + + + + +

Workbench result: FORMAT PNG image

+
+
+
+ +

Dashboard Image panel tile (contain fit)

+
+
+
+
+
+
+ + + + diff --git a/tests/e2e/image-result.spec.js b/tests/e2e/image-result.spec.js new file mode 100644 index 00000000..6fb78168 --- /dev/null +++ b/tests/e2e/image-result.spec.js @@ -0,0 +1,110 @@ +import { test, expect } from '@playwright/test'; + +// Real-browser coverage for the FORMAT PNG image result (#307): a validated +// PNG payload renders as a visible behind the locked "Image (PNG)" +// result-view tab in the Workbench, and a Dashboard Image panel tile paints +// the same bytes filling the tile body with its aspect ratio preserved +// (never stretched to the tile's box) — both invisible to the happy-dom unit +// suite (real image decode + CSS object-fit layout). +test.describe('FORMAT PNG image result (#307)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/e2e/image-result.html'); + await page.waitForFunction(() => window.__ready === true); + }); + + test('Workbench: a completed PNG run shows a locked Image (PNG) tab and a visible ', async ({ page }) => { + const tabs = page.locator('#results-region .result-view-tab'); + await expect(tabs).toHaveCount(1); + await expect(tabs.first()).toHaveClass(/active/); + await expect(tabs.first()).toContainText('Image (PNG)'); + + const img = page.locator('#results-region .image-result-view img'); + await expect(img).toBeVisible(); + await expect(img).toHaveAttribute('src', /^blob:/); + await expect(img).toHaveAttribute('width', '2'); + await expect(img).toHaveAttribute('height', '2'); + // A real decode — happy-dom can't do this — proves the blob: URL the + // seam minted is the actual validated PNG bytes, not a placeholder. + const natural = await img.evaluate((el) => new Promise((resolveNat) => { + if (el.complete && el.naturalWidth) resolveNat({ w: el.naturalWidth, h: el.naturalHeight }); + else el.addEventListener('load', () => resolveNat({ w: el.naturalWidth, h: el.naturalHeight }), { once: true }); + })); + expect(natural).toEqual({ w: 2, h: 2 }); + + // Row-cap selector is exempt for an image result (no row concept). + await expect(page.locator('#results-region .row-limit-select')).toHaveCount(0); + }); + + test('Workbench: Download PNG hands back the exact validated bytes, no Copy button', async ({ page }) => { + await expect(page.locator('#results-region .res-act', { hasText: 'Copy' })).toHaveCount(0); + await page.locator('#results-region .res-act', { hasText: 'Download PNG' }).click(); + const downloaded = await page.evaluate(() => window.__downloaded); + expect(downloaded).toMatchObject({ mime: 'image/png', size: 77 }); + expect(downloaded.filename).toContain('.png'); + }); + + // `getBoundingClientRect` on an `object-fit` always reports the CSS + // box (100%/100% of the tile) regardless of fit — that's not proof of + // no-distortion. Only the actual rendered pixels are: screenshot the tile + // body and sample real pixel colour at several fractional points. `contain` + // on a 1:1 source inside a 3:1 (or 1:3) box must letterbox — the tile's own + // opaque background (`.dash-tile { background: var(--bg-side) }`) visible + // at the long axis's edges, the PNG's saturated red/green/blue/yellow + // quadrants only in the centered square — never stretched to fill the box. + async function samplePixels(page, locator, points) { + const buf = await locator.screenshot(); + return page.evaluate(async ({ base64, points: pts }) => { + const img = new Image(); + img.src = 'data:image/png;base64,' + base64; + await new Promise((res) => { img.onload = res; }); + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + return pts.map(([xf, yf]) => { + const x = Math.min(img.naturalWidth - 1, Math.floor(img.naturalWidth * xf)); + const y = Math.min(img.naturalHeight - 1, Math.floor(img.naturalHeight * yf)); + return [...ctx.getImageData(x, y, 1, 1).data]; + }); + }, { base64: buf.toString('base64'), points }); + } + function colourDist([r1, g1, b1], [r2, g2, b2]) { + return Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2); + } + + test('Dashboard Image tile: a wide (3:1) tile letterboxes a 1:1 source instead of stretching it', async ({ page }) => { + const img = page.locator('#wide-tile-body img.panel-image'); + await expect(img).toBeVisible(); + await expect(img).toHaveClass(/panel-image-fit-contain/); + expect(await img.evaluate((el) => getComputedStyle(el).objectFit)).toBe('contain'); + + const tile = page.locator('#wide-tile-body'); + // Top-left corner is always outside the centered square in a 3:1 tile — + // the tile's own background colour, whatever it is. + const [bg, leftEdge, rightEdge, center] = await samplePixels(page, tile, [ + [0.02, 0.05], [0.03, 0.5], [0.97, 0.5], [0.5, 0.5], + ]); + // The 1:1 image, height-limited to a centered square, cannot reach the + // 600px-wide tile's left/right thirds — a stretched/fill render would + // paint image colour there instead of background. + expect(colourDist(leftEdge, bg)).toBeLessThan(20); + expect(colourDist(rightEdge, bg)).toBeLessThan(20); + // Center must be the image itself (a saturated quadrant colour, distinct from background). + expect(colourDist(center, bg)).toBeGreaterThan(60); + }); + + test('Dashboard Image tile: a tall (1:3) tile letterboxes a 1:1 source instead of stretching it', async ({ page }) => { + const img = page.locator('#tall-tile-body img.panel-image'); + await expect(img).toBeVisible(); + + const tile = page.locator('#tall-tile-body'); + const [bg, topEdge, bottomEdge, center] = await samplePixels(page, tile, [ + [0.05, 0.02], [0.5, 0.03], [0.5, 0.97], [0.5, 0.5], + ]); + // Same 1:1 source, now width-limited to a centered square — the + // 600px-tall tile's top/bottom thirds must still show background. + expect(colourDist(topEdge, bg)).toBeLessThan(20); + expect(colourDist(bottomEdge, bg)).toBeLessThan(20); + expect(colourDist(center, bg)).toBeGreaterThan(60); + }); +}); From 7493b7a1e7ce5c8f069a056137182cd9b79b4df1 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 19 Jul 2026 11:23:47 +0000 Subject: [PATCH 4/9] =?UTF-8?q?fix(#307):=20review=20pass=20=E2=80=94=20sc?= =?UTF-8?q?hema-graph=20image=20URL=20revocation,=20alt=20fallbacks,=20gat?= =?UTF-8?q?e=20tests=20+=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema-lineage view now revokes a displayed image result's blob URL before replacing tab.result (new revokeResultImage hook, mirroring the workbench-session pattern). The Panel-drawer/readonly image arms thread the active tab name into alt with a 'PNG query result' final fallback. Dashboard blanket-gate rejection now explicitly tested for line and table panel types with authored FORMAT PNG. CHANGELOG [Unreleased] documents the feature. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB --- CHANGELOG.md | 21 +++++++++++++++++ src/application/schema-graph-session.ts | 8 +++++++ src/ui/app.ts | 1 + src/ui/panels.ts | 10 +++++++- src/ui/results.ts | 7 ++++++ tests/unit/dashboard-viewer-session.test.ts | 26 +++++++++++++++++++++ tests/unit/panels.test.ts | 11 +++++++++ tests/unit/schema-graph-session.test.ts | 18 ++++++++++++-- 8 files changed, 99 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0594d776..ad2abf74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ 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+. + ### 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/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/ui/app.ts b/src/ui/app.ts index 53914427..45141d84 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -954,6 +954,7 @@ export function createApp(env: CreateAppEnv = {}): App { hooks: { renderResults: () => renderResults(app), onAuthFailed: () => chCtx.onSignedOut(), + revokeResultImage: (result) => revokeResultImageUrl(app, result), }, }); app.graph = graph; diff --git a/src/ui/panels.ts b/src/ui/panels.ts index 52be63b4..6055563b 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -439,7 +439,7 @@ const imageArm: PanelArm = { const url = app.createObjectUrl(image.bytes, image.mimeType); const fit = (cfg.fit as string | undefined) || 'contain'; const background = (cfg.background as string | undefined) || 'theme'; - const alt = (cfg.alt as string | undefined) || title || ''; + const alt = (cfg.alt as string | undefined) || title || 'PNG query result'; const img = h('img', { class: 'panel-image panel-image-fit-' + fit, src: url, @@ -698,6 +698,13 @@ export function renderPanelView(app: App, r: PanelResult | null, hooks: PanelHoo ? 'Panel renders when the query completes.' : 'Run the query (⌘↵) to preview this panel.')); } else { + // `destroy` is unreachable here today: this call site always has + // `hasGrid ? r : null` — a completed/live result — while the Image arm's + // `result.image` (the only arm whose `destroy` does anything, #307) only + // ever arrives already-rendered through renderResults' own dedicated + // image branch, never through this Panel-drawer path. Comment only, no + // behavior change — a future reorder that lets an image result reach here + // must wire `destroy` or it'll leak the object URL. const { node } = renderResolvedPanel(app, resolved, hasGrid ? r : null, { surface: 'workbench', state: r ? (r.panelState = r.panelState || {}) : {}, @@ -710,6 +717,7 @@ export function renderPanelView(app: App, r: PanelResult | null, hooks: PanelHoo // the fallback preview must never be able to replace the saved Logs cfg. onCfgChange: rescueLogs ? undefined : onCfgChange, setChart: (c: PanelChartInstance) => { app.chart = c; }, // renderResults' destroy-before-rebuild slot + title: app.activeTab().name || undefined, // #307: Image arm's alt-text source }); body.appendChild(node); } diff --git a/src/ui/results.ts b/src/ui/results.ts index 893d4587..510a486d 100644 --- a/src/ui/results.ts +++ b/src/ui/results.ts @@ -899,6 +899,12 @@ export function renderResultView({ app, view, result, sort, setSort, widths, rer const r = result; if (view === 'panel') { if (panel.mode === 'readonly') { + // `destroy` is unreachable here today: the Image arm's `destroy` only + // ever matters for a real `result.image` (#307), and an image result + // always renders via renderResults' own dedicated image branch before + // reaching this readonly Panel path. Comment only, no behavior change — + // a future reorder that lets an image result reach here must wire + // `destroy` or it'll leak the object URL. const { node } = renderResolvedPanel(app, panel.resolved, r, { surface: 'workbench', state: panel.state, @@ -907,6 +913,7 @@ export function renderResultView({ app, view, result, sort, setSort, widths, rer cap, onCell, setChart: panel.setChart, + title: app.activeTab().name || undefined, // #307: Image arm's alt-text source }); return node; } diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 4c0d1a43..3765e0b2 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -239,6 +239,32 @@ describe('createDashboardViewerSession', () => { expect(session.state.value.tiles[0].error).toContain('FORMAT'); }); + it('a chart (line) panel with an authored FORMAT PNG is rejected by the blanket gate', async () => { + const { exec, calls } = makeExec(); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [query('q1', 'SELECT 1 FORMAT PNG', { panel: { cfg: { type: 'line', x: 0, y: [1] } } })], + })); + await session.start(); + expect(calls.length).toBe(0); + expect(session.state.value.tiles[0].status).toBe('error'); + expect(session.state.value.tiles[0].error).toContain('FORMAT'); + }); + + it('a table panel with an authored FORMAT PNG is rejected by the blanket gate', async () => { + const { exec, calls } = makeExec(); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [query('q1', 'SELECT 1 FORMAT PNG', { panel: { cfg: { type: 'table' } } })], + })); + await session.start(); + expect(calls.length).toBe(0); + expect(session.state.value.tiles[0].status).toBe('error'); + expect(session.state.value.tiles[0].error).toContain('FORMAT'); + }); + it('a stale generation cannot replace a newer Image result (stale-wave suppression)', async () => { let resolveFirst!: () => void; const first = new Promise((resolve) => { resolveFirst = resolve; }); diff --git a/tests/unit/panels.test.ts b/tests/unit/panels.test.ts index 77302f23..fb562aa8 100644 --- a/tests/unit/panels.test.ts +++ b/tests/unit/panels.test.ts @@ -762,6 +762,17 @@ describe('image panel arm', () => { expect(img.alt).toBe('Fallback title'); }); + it('falls back to "PNG query result" when no cfg.alt and no title are given', () => { + const app = makeApp(); + const image = fakeImage(); + const out = PANEL_TYPES.image.renderPanel({ + app, result: { columns: [], rows: [], error: null, rawText: null, image }, + cfg: { type: 'image' }, + }); + const img = qs(out.node, 'img'); + expect(img.alt).toBe('PNG query result'); + }); + it('renders through renderResolvedPanel end to end (resolvePanel → registry dispatch)', () => { const app = makeApp(); const image = fakeImage(); diff --git a/tests/unit/schema-graph-session.test.ts b/tests/unit/schema-graph-session.test.ts index 56a1d60d..d2a09d49 100644 --- a/tests/unit/schema-graph-session.test.ts +++ b/tests/unit/schema-graph-session.test.ts @@ -43,8 +43,11 @@ function makeTab(initial: Record | null = null): SchemaGraphTab return { result: initial }; } -function makeHooks(): SchemaGraphHooks & { renderResults: ReturnType; onAuthFailed: ReturnType } { - return { renderResults: vi.fn(), onAuthFailed: vi.fn() }; +function makeHooks(): SchemaGraphHooks & { + renderResults: ReturnType; onAuthFailed: ReturnType; + revokeResultImage: ReturnType; +} { + return { renderResults: vi.fn(), onAuthFailed: vi.fn(), revokeResultImage: vi.fn() }; } /** One minimal `system.tables` row — every field `buildSchemaGraph` actually @@ -143,6 +146,17 @@ describe('show()', () => { expect(tab.result).toBeNull(); }); + it('calls hooks.revokeResultImage with the old result before overwriting tab.result (#307)', async () => { + const oldResult = { image: { bytes: new Uint8Array(), mimeType: 'image/png', width: 1, height: 1 } }; + const tab = makeTab(oldResult); + const hooks = makeHooks(); + const deps = makeDeps({ hooks, activeTab: () => tab }); + const svc = createSchemaGraphSession(deps); + await svc.show({ db: 'd' }); + expect(hooks.revokeResultImage).toHaveBeenCalledWith(oldResult); + expect(tab.result).not.toBe(oldResult); // the placeholder really did overwrite it + }); + it('draws a Phase-A-only graph (no progressive callbacks) and reports tableCount', async () => { const tab = makeTab(); const hooks = makeHooks(); From e8cb399fda1a1b4f1baa5c225d63fbc4ec0b736f Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 19 Jul 2026 12:41:05 +0000 Subject: [PATCH 5/9] =?UTF-8?q?fix(#307):=20merge-gate=20feedback=20?= =?UTF-8?q?=E2=80=94=20favorite/import=20tile=20membership=20+=20uncapped?= =?UTF-8?q?=20binary=20formats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starring a panel-role query with no dashboard document now creates the document (shared createEmptyDashboardDocument helper) instead of silently doing nothing, and Import queries runs the new additive-only syncFavoriteTileMembership so imported favorited panel-role queries get tiles — the documented kpi-panel.json/image-panels.json import flows show tiles again (pre-existing Phase-8 regression exposed by the #307 demos). runQuery no longer sends max_result_rows/result_overflow_mode for binary formats: the source-row cap was truncating large PNGs to their first rows; PNG dimension/byte limits remain the guards. Both verified live against ClickHouse 26.6: image-panels import yields 4 rendering tiles, and the 1024x1024 spiral repro renders full-size. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB --- CHANGELOG.md | 18 +++ .../dashboard-authoring-session.ts | 14 +-- src/dashboard/application/tile-membership.ts | 82 +++++++++++-- src/net/ch-client.ts | 8 +- src/workspace/import-planner.ts | 14 ++- src/workspace/workspace-operations.ts | 22 ++-- tests/unit/ch-client.test.ts | 12 ++ tests/unit/import-planner.test.ts | 52 ++++++++ tests/unit/state.test.ts | 25 +++- tests/unit/tile-membership.test.ts | 112 +++++++++++++++++- 10 files changed, 330 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2abf74..93d93fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,24 @@ auto-generated per-PR notes; this file is the curated, human-readable history. gradient, RGBA checkerboard glow, and an original ray-traced sphere crediting ClickHouse's RayTracer demos) — requires ClickHouse 26.6+. +### Fixed +- **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. +- **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/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/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/net/ch-client.ts b/src/net/ch-client.ts index ff6a2a92..6cdf08d9 100644 --- a/src/net/ch-client.ts +++ b/src/net/ch-client.ts @@ -890,7 +890,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, { diff --git a/src/workspace/import-planner.ts b/src/workspace/import-planner.ts index f65c81e5..8772397f 100644 --- a/src/workspace/import-planner.ts +++ b/src/workspace/import-planner.ts @@ -23,6 +23,7 @@ import { dashboardDependencyQueryIds } from '../dashboard/model/bundle-order.js' import { diagnostic, sortDiagnostics } from '../dashboard/model/workspace-diagnostics.js'; import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnostics.js'; import { cloneJson } from '../core/saved-query.js'; +import { syncFavoriteTileMembership } from '../dashboard/application/tile-membership.js'; import { importQueries, replaceWorkspaceContents, } from './workspace-operations.js'; @@ -345,8 +346,14 @@ function invalidatedDashboardPlan( ); } -/** Queries-only import (Dashboard untouched): merge the bundle's queries into - * the workspace's query catalog per `decisions`, and validate the result. */ +/** Queries-only import: merge the bundle's queries into the workspace's query + * catalog per `decisions`, then sync Dashboard tile membership for every + * favorited panel-role query in the MERGED catalog (#307 — "File → Import + * queries has no membership sync" left favorited panel-role imports + * invisible on the Dashboard; `syncFavoriteTileMembership` is additive-only, + * so an existing Dashboard's other tiles/layout/filters are untouched, and a + * Dashboard-less workspace gets a fresh one only when at least one imported + * or pre-existing favorite actually needs a tile). Validate the result. */ export function planImportQueries( workspace: StoredWorkspaceV1, bundle: PortableBundleV1, decisions: readonly QueryDecision[], genId: WorkspaceIdGen, @@ -355,7 +362,8 @@ export function planImportQueries( const mapping = buildQueryIdMapping(bundle.queries, workspace.queries, decisions, genId); const nextQueries = mergeIncomingQueries(bundle.queries, workspace.queries, mapping); const candidate = importQueries(workspace, nextQueries); - return validatedPlan(candidate, mapping, options); + const syncedDashboard = syncFavoriteTileMembership(candidate.dashboard, nextQueries, genId); + return validatedPlan({ ...candidate, dashboard: syncedDashboard }, mapping, options); } /** Import one bundled Dashboard plus its dependency closure of queries. diff --git a/src/workspace/workspace-operations.ts b/src/workspace/workspace-operations.ts index 6c3553f4..23a54c6d 100644 --- a/src/workspace/workspace-operations.ts +++ b/src/workspace/workspace-operations.ts @@ -50,13 +50,21 @@ export function createNewWorkspace(genId: WorkspaceIdGen, name?: unknown): Store }; } -/** Import a query collection into the workspace: modifies `queries` ONLY. The - * Dashboard is left byte-for-byte unchanged — imported `spec.favorite` flags - * never add Dashboard tiles (#280 "Import queries modifies the query - * collection only; imported favorite flags do not add Dashboard tiles"). The - * incoming collection replaces the workspace's queries; conflict resolution - * (use-existing/copy/replace/skip) and ID remapping are the Phase-5 import - * planner's job, applied before a candidate reaches here. */ +/** Import a query collection into the workspace: modifies `queries` ONLY, at + * THIS layer — the Dashboard passed through is byte-for-byte unchanged here. + * (#280's original rule — "imported favorite flags do not add Dashboard + * tiles" — turned out to be a bug, not a decision: a fresh workspace's + * Dashboard is `null` and `toggleTileMembership` also returned `null` for + * it, so a favorited panel-role query could never get a tile through EITHER + * path. #307 fixes both; for imports the fix is one layer up, in + * `import-planner.ts`'s `planImportQueries`, which calls + * `syncFavoriteTileMembership` on this function's result before returning + * its plan — kept there, not here, so this repository-level primitive stays + * a pure "assemble the candidate" step with no Dashboard-semantics + * knowledge.) The incoming collection replaces the workspace's queries; + * conflict resolution (use-existing/copy/replace/skip) and ID remapping are + * the Phase-5 import planner's job, applied before a candidate reaches + * here. */ export function importQueries( workspace: StoredWorkspaceV1, queries: readonly SavedQueryV2[], ): StoredWorkspaceV1 { diff --git a/tests/unit/ch-client.test.ts b/tests/unit/ch-client.test.ts index 8c017756..dc6fd8a0 100644 --- a/tests/unit/ch-client.test.ts +++ b/tests/unit/ch-client.test.ts @@ -585,6 +585,18 @@ describe('runQuery', () => { await runQuery(uncapped, 'x', { format: 'Table' }); // no limit → no cap params expect(uncapped.fetchMock.mock.calls[0][0]).not.toContain('max_result_rows'); }); + it('never caps a binary (PNG) result even when resultRowLimit is set — a source-row cap truncates the pixel grid itself (#307)', async () => { + const bytes = new Uint8Array([137, 80, 78, 71]); + const png = ctxWith(async () => binResp(bytes)); + await runQuery(png, 'x', { format: 'PNG', resultRowLimit: 500 }); + const url = png.fetchMock.mock.calls[0][0]; + expect(url).not.toContain('max_result_rows'); + expect(url).not.toContain('result_overflow_mode'); + // A same-shaped text raw format is unaffected — it still gets capped. + const tsv = ctxWith(async () => textResp('a\tb')); + await runQuery(tsv, 'x', { format: 'TSV', resultRowLimit: 500 }); + expect(tsv.fetchMock.mock.calls[0][0]).toContain('max_result_rows=500'); + }); }); describe('killQuery', () => { diff --git a/tests/unit/import-planner.test.ts b/tests/unit/import-planner.test.ts index 5fbbde9b..386a43f0 100644 --- a/tests/unit/import-planner.test.ts +++ b/tests/unit/import-planner.test.ts @@ -281,6 +281,58 @@ describe('planImportQueries', () => { expect(plan.diagnostics.length).toBeGreaterThan(0); expect(plan.diagnostics.some((d) => d.code === 'spec-version-unsupported')).toBe(true); }); + + // #307: importing favorited panel-role queries must land matching Dashboard + // tiles — the bridge that was entirely missing before this fix. + it('creates a fresh Dashboard and a tile for an imported favorited panel-role query when the workspace has none', () => { + const ws = workspace({ queries: [], dashboard: null }); + const favoritedPanel: SavedQueryV2 = { + ...panelQuery('p1'), spec: { ...panelQuery('p1').spec, favorite: true }, + }; + const plan = planImportQueries(ws, bundle({ queries: [favoritedPanel] }), [], counter()); + expect(plan.candidateWorkspace).not.toBeNull(); + const dash = plan.candidateWorkspace!.dashboard!; + expect(dash).not.toBeNull(); + expect(dash.tiles).toEqual([{ id: 'id-2', queryId: 'p1' }]); + expect(dash.id).toBe('id-1'); // genId() called once for the Dashboard, then once per tile + }); + + it('adds a tile ONLY for the favorited panel-role query, leaving a favorited filter-role import tile-less', () => { + const ws = workspace({ queries: [], dashboard: null }); + const favoritedPanel: SavedQueryV2 = { + ...panelQuery('p1'), spec: { ...panelQuery('p1').spec, favorite: true }, + }; + const favoritedFilter: SavedQueryV2 = { + ...filterQuery('f1'), spec: { ...filterQuery('f1').spec, favorite: true }, + }; + const plan = planImportQueries(ws, bundle({ queries: [favoritedPanel, favoritedFilter] }), [], counter()); + const dash = plan.candidateWorkspace!.dashboard!; + expect(dash.tiles).toEqual([{ id: 'id-2', queryId: 'p1' }]); + }); + + it('is additive-only: an existing Dashboard keeps its other tiles/layout/filters and gains only the missing favorite tile', () => { + const existingDash = dashboardDoc({ + title: 'My dash', + layout: { type: 'flow', version: 1, preset: 'report', items: { keep: { span: 2, height: 'large' } } }, + tiles: [{ id: 'keep', queryId: 'existing-panel' }], + filters: [{ id: 'flt1', parameter: 'x' }], + }); + const ws = workspace({ + queries: [{ ...panelQuery('existing-panel'), spec: { ...panelQuery('existing-panel').spec, favorite: true } }], + dashboard: existingDash, + }); + const favoritedPanel: SavedQueryV2 = { + ...panelQuery('p1'), spec: { ...panelQuery('p1').spec, favorite: true }, + }; + const plan = planImportQueries(ws, bundle({ queries: [favoritedPanel] }), [], counter()); + const dash = plan.candidateWorkspace!.dashboard!; + expect(dash.title).toBe('My dash'); + expect(dash.filters).toEqual([{ id: 'flt1', parameter: 'x' }]); + expect(dash.tiles).toEqual([ + { id: 'keep', queryId: 'existing-panel' }, + { id: 'id-1', queryId: 'p1' }, + ]); + }); }); // --- planImportDashboard -------------------------------------------------------- diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index 79fea5ae..a0b6ba6a 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -420,7 +420,7 @@ describe('saved queries', () => { expect(commit).toHaveBeenCalledTimes(1); }); - it('a null state.dashboard means favorite flip only — no tile change, no crash', async () => { + it('#307: a null state.dashboard + favorite ON on a panel-role query creates a fresh Dashboard with one tile', async () => { const s = savedTestState(); s.savedQueries = [savedQuery({ id: 'p1', sql: 'SELECT 1', dashboard: { role: 'panel' } })]; expect(s.dashboard).toBeNull(); @@ -428,6 +428,29 @@ describe('saved queries', () => { const result = await toggleFavorite(s, 'p1', commit, genTileId()); expect(result).toMatchObject({ ok: true }); expect(queryFavorite(s.savedQueries[0])).toBe(true); + expect(s.dashboard).not.toBeNull(); + expect(s.dashboard!.tiles).toEqual([{ id: 'tile-2', queryId: 'p1' }]); + }); + + it('a null state.dashboard + favorite ON on a filter-role query stays null (favorite flip only)', async () => { + const s = savedTestState(); + s.savedQueries = [savedQuery({ id: 'f1', sql: "SELECT ['a'] AS x", dashboard: { role: 'filter' } })]; + expect(s.dashboard).toBeNull(); + const commit = fakeWorkspaceCommit(); + const result = await toggleFavorite(s, 'f1', commit, genTileId()); + expect(result).toMatchObject({ ok: true }); + expect(queryFavorite(s.savedQueries[0])).toBe(true); + expect(s.dashboard).toBeNull(); + }); + + it('a null state.dashboard + favorite OFF stays null (no crash)', async () => { + const s = savedTestState(); + s.savedQueries = [savedQuery({ id: 'p1', sql: 'SELECT 1', favorite: true, dashboard: { role: 'panel' } })]; + expect(s.dashboard).toBeNull(); + const commit = fakeWorkspaceCommit(); + const result = await toggleFavorite(s, 'p1', commit, genTileId()); + expect(result).toMatchObject({ ok: true }); + expect(queryFavorite(s.savedQueries[0])).toBe(false); expect(s.dashboard).toBeNull(); }); }); diff --git a/tests/unit/tile-membership.test.ts b/tests/unit/tile-membership.test.ts index 5971cec0..d37baedb 100644 --- a/tests/unit/tile-membership.test.ts +++ b/tests/unit/tile-membership.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { toggleTileMembership } from '../../src/dashboard/application/tile-membership.js'; +import { + createEmptyDashboardDocument, syncFavoriteTileMembership, toggleTileMembership, +} from '../../src/dashboard/application/tile-membership.js'; import type { DashboardDocumentV1, SavedQueryV2 } from '../../src/generated/json-schema.types.js'; const panelQuery = (id: string): SavedQueryV2 => ({ @@ -24,8 +26,31 @@ const genTileId = (): (() => string) => { }; describe('toggleTileMembership', () => { - it('null dashboard in → null out (no Dashboard yet, favorite flip only)', () => { - expect(toggleTileMembership(null, panelQuery('p1'), true, genTileId())).toBeNull(); + it('#307: star ON on a panel-role query with no Dashboard creates a fresh Dashboard with one tile', () => { + const next = toggleTileMembership(null, panelQuery('p1'), true, genTileId())!; + expect(next).not.toBeNull(); + expect(next.tiles).toEqual([{ id: 'tile-2', queryId: 'p1' }]); + expect(next.id).toBe('tile-1'); // genDashboardId defaults to genTileId — called once for the doc + expect(next.documentVersion).toBe(1); + expect(next.layout).toEqual({ type: 'flow', version: 1, preset: 'full-width', items: {} }); + expect(next.filters).toEqual([]); + }); + + it('#307: a caller-supplied genDashboardId mints the Dashboard id, separate from genTileId', () => { + const genDashboardId = (): (() => string) => { + let n = 0; + return () => 'dash-' + (++n); + }; + const next = toggleTileMembership(null, panelQuery('p1'), true, genTileId(), genDashboardId())!; + expect(next.id).toBe('dash-1'); + expect(next.tiles).toEqual([{ id: 'tile-1', queryId: 'p1' }]); + }); + + it('null dashboard in + star ON on a filter/setup-role query → null out (nothing to create)', () => { + expect(toggleTileMembership(null, filterQuery('f1'), true, genTileId())).toBeNull(); + }); + + it('null dashboard in + star OFF → null out (favorite flip only, nothing to remove)', () => { expect(toggleTileMembership(null, panelQuery('p1'), false, genTileId())).toBeNull(); }); @@ -131,3 +156,84 @@ describe('toggleTileMembership — grafana-grid@1 engine awareness (#291)', () = }); }); }); + +describe('createEmptyDashboardDocument', () => { + it('builds a fresh empty flow@1 Dashboard at revision 1 with the given id', () => { + expect(createEmptyDashboardDocument('d1')).toEqual({ + documentVersion: 1, id: 'd1', title: 'Dashboard', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + filters: [], tiles: [], + }); + }); +}); + +describe('syncFavoriteTileMembership (#307)', () => { + const favorite = (query: SavedQueryV2): SavedQueryV2 => ({ + ...query, spec: { ...query.spec, favorite: true }, + }); + + it('null dashboard + no favorited panel-role queries → stays null', () => { + expect(syncFavoriteTileMembership(null, [filterQuery('f1'), noRoleQuery('p1')], genTileId())).toBeNull(); + }); + + it('null dashboard + a favorited panel-role query → creates a Dashboard with one tile', () => { + const next = syncFavoriteTileMembership(null, [favorite(panelQuery('p1'))], genTileId())!; + expect(next).not.toBeNull(); + expect(next.tiles).toEqual([{ id: 'tile-2', queryId: 'p1' }]); + expect(next.id).toBe('tile-1'); + }); + + it('adds tiles only for favorited panel-role queries missing one, in queries order', () => { + const next = syncFavoriteTileMembership(dashboard(), [ + favorite(panelQuery('p1')), + favorite(filterQuery('f1')), // favorited but filter-role — never gets a tile + panelQuery('p2'), // panel-role but not favorited — no tile + favorite(panelQuery('p3')), + ], genTileId())!; + expect(next.tiles).toEqual([ + { id: 'tile-1', queryId: 'p1' }, + { id: 'tile-2', queryId: 'p3' }, + ]); + }); + + it('is idempotent — a second call with the same inputs is a no-op', () => { + const first = syncFavoriteTileMembership(dashboard(), [favorite(panelQuery('p1'))], genTileId())!; + const second = syncFavoriteTileMembership(first, [favorite(panelQuery('p1'))], genTileId())!; + expect(second.tiles).toEqual(first.tiles); + }); + + it('never removes an existing tile, even for a now-unfavorited or now-missing query', () => { + const d = dashboard({ tiles: [{ id: 'keep', queryId: 'gone' }] }); + const next = syncFavoriteTileMembership(d, [favorite(panelQuery('p1'))], genTileId())!; + expect(next.tiles).toEqual([ + { id: 'keep', queryId: 'gone' }, + { id: 'tile-1', queryId: 'p1' }, + ]); + }); + + it('preserves an existing Dashboard\'s layout/filters/title when adding a tile', () => { + const d = dashboard({ + title: 'My dash', + layout: { type: 'flow', version: 1, preset: 'report', items: { keep: { span: 2, height: 'large' } } }, + tiles: [{ id: 'keep', queryId: 'other' }], + filters: [{ id: 'flt1', parameter: 'x' }], + }); + const next = syncFavoriteTileMembership(d, [favorite(panelQuery('p1'))], genTileId())!; + expect(next.title).toBe('My dash'); + expect(next.filters).toEqual([{ id: 'flt1', parameter: 'x' }]); + expect((next.layout as { items: Record }).items).toEqual({ keep: { span: 2, height: 'large' } }); + }); + + it('never mutates the input dashboard or queries', () => { + const d = dashboard({ tiles: [{ id: 't1', queryId: 'other' }] }); + const snapshot = JSON.parse(JSON.stringify(d)); + syncFavoriteTileMembership(d, [favorite(panelQuery('p1'))], genTileId()); + expect(d).toEqual(snapshot); + }); + + it('a no-op call (nothing missing) returns the SAME dashboard reference', () => { + const d = dashboard({ tiles: [{ id: 't1', queryId: 'p1' }] }); + const next = syncFavoriteTileMembership(d, [favorite(panelQuery('p1'))], genTileId()); + expect(next).toBe(d); + }); +}); From 8b893942284c4038c1c45a561254c0dc0634ffc6 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 19 Jul 2026 12:56:48 +0000 Subject: [PATCH 6/9] feat(#307): auto-infer the Image panel for unconfigured FORMAT PNG queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A panel-role tile whose query ends in FORMAT PNG but has no explicit panel cfg is now treated as an Image panel (shouldInferImagePanel, pure core helper) instead of hitting the blanket 'remove the explicit FORMAT clause' rejection — starring a PNG query just works. Explicitly-typed non-image panels with FORMAT PNG stay rejected, now with a guided message naming the Image panel type. Owner decision at the merge gate; verified live (save -> star -> dashboard tile renders the PNG). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB --- CHANGELOG.md | 6 ++- src/core/panel-execution.ts | 14 +++++ .../application/dashboard-viewer-session.ts | 28 ++++++++-- tests/unit/dashboard-viewer-session.test.ts | 53 ++++++++++++++++--- tests/unit/panel-execution.test.ts | 21 +++++++- 5 files changed, 107 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d93fdc..adaa91dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,11 @@ auto-generated per-PR notes; this file is the curated, human-readable history. 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. + 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 diff --git a/src/core/panel-execution.ts b/src/core/panel-execution.ts index c02eaae1..d1466335 100644 --- a/src/core/panel-execution.ts +++ b/src/core/panel-execution.ts @@ -18,6 +18,20 @@ 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 diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index daeea701..46ca2e63 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -34,7 +34,7 @@ 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'; @@ -327,8 +327,19 @@ 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'; @@ -496,10 +507,17 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // 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; - if (execution.error || (checkFormat && detectSqlFormat(execSql))) { + 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; } diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 3765e0b2..3ae38b80 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -228,18 +228,39 @@ describe('createDashboardViewerSession', () => { expect(session.state.value.tiles[0].error).toContain('FORMAT PNG'); }); - it('a non-image tile with an authored FORMAT PNG is still rejected by the blanket gate', async () => { - const { exec, calls } = makeExec(); + it('#307: an unconfigured panel-role tile with authored FORMAT PNG is inferred as an Image panel and runs', async () => { + const image = fakeImage(); + const { exec, calls } = makeExec(() => ({ image, columns: [], rows: [] })); const document = doc({ tiles: [tile('t1', 'q1')] }); const session = createDashboardViewerSession(makeDeps({ document, exec, queries: [query('q1', 'SELECT 1 FORMAT PNG')], })); await session.start(); - expect(calls.length).toBe(0); - expect(session.state.value.tiles[0].error).toContain('FORMAT'); + expect(calls.length).toBe(1); + expect(calls[0].format).toBe('PNG'); + const ts = session.state.value.tiles[0]; + expect(ts.isImage).toBe(true); + expect(ts.status).toBe('ready'); + expect(ts.error).toBeNull(); + expect(ts.image).toEqual(image); + expect(ts.panel?.cfg).toEqual({ type: 'image' }); }); - it('a chart (line) panel with an authored FORMAT PNG is rejected by the blanket gate', async () => { + it('#307: an unconfigured panel-role tile with no FORMAT clause is unaffected (ordinary auto panel)', async () => { + const { exec, calls } = makeExec((sql) => ({ columns: [{ name: 'n' }], rows: [[sql.length]] })); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('q1', 'SELECT 1')], + })); + await session.start(); + expect(calls.length).toBe(1); + const ts = session.state.value.tiles[0]; + expect(ts.isImage).toBe(false); + expect(ts.status).toBe('ready'); + expect(ts.panel?.cfg).toBeUndefined(); + }); + + it('a chart (line) panel with an authored FORMAT PNG is rejected with the guided Image message', async () => { const { exec, calls } = makeExec(); const document = doc({ tiles: [tile('t1', 'q1')] }); const session = createDashboardViewerSession(makeDeps({ @@ -249,10 +270,11 @@ describe('createDashboardViewerSession', () => { await session.start(); expect(calls.length).toBe(0); expect(session.state.value.tiles[0].status).toBe('error'); - expect(session.state.value.tiles[0].error).toContain('FORMAT'); + expect(session.state.value.tiles[0].error) + .toBe('This query returns FORMAT PNG — set its panel type to Image to show it on the Dashboard.'); }); - it('a table panel with an authored FORMAT PNG is rejected by the blanket gate', async () => { + it('#307: an explicit table panel with an authored FORMAT PNG is rejected with the guided Image message', async () => { const { exec, calls } = makeExec(); const document = doc({ tiles: [tile('t1', 'q1')] }); const session = createDashboardViewerSession(makeDeps({ @@ -262,7 +284,22 @@ describe('createDashboardViewerSession', () => { await session.start(); expect(calls.length).toBe(0); expect(session.state.value.tiles[0].status).toBe('error'); - expect(session.state.value.tiles[0].error).toContain('FORMAT'); + expect(session.state.value.tiles[0].error) + .toBe('This query returns FORMAT PNG — set its panel type to Image to show it on the Dashboard.'); + }); + + it('#307: an explicit table panel with an authored FORMAT CSV keeps the original blanket-gate message', async () => { + const { exec, calls } = makeExec(); + const document = doc({ tiles: [tile('t1', 'q1')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [query('q1', 'SELECT 1 FORMAT CSV', { panel: { cfg: { type: 'table' } } })], + })); + await session.start(); + expect(calls.length).toBe(0); + expect(session.state.value.tiles[0].status).toBe('error'); + expect(session.state.value.tiles[0].error) + .toBe('Dashboard panels require structured streaming results. Remove the explicit FORMAT clause.'); }); it('a stale generation cannot replace a newer Image result (stale-wave suppression)', async () => { diff --git a/tests/unit/panel-execution.test.ts b/tests/unit/panel-execution.test.ts index 67aae0fe..ee9ee988 100644 --- a/tests/unit/panel-execution.test.ts +++ b/tests/unit/panel-execution.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { explicitPanel, isImagePanel, isKpiPanel, panelExecution } from '../../src/core/panel-execution.js'; +import { + explicitPanel, isImagePanel, isKpiPanel, panelExecution, shouldInferImagePanel, +} from '../../src/core/panel-execution.js'; describe('panel execution ownership', () => { it('leaves non-KPI execution unchanged', () => { @@ -52,6 +54,23 @@ describe('image panel ownership (#307)', () => { }); }); +describe('shouldInferImagePanel (#307)', () => { + it('fires for an unconfigured panel (null or no cfg) with a trailing FORMAT PNG (case-insensitive)', () => { + expect(shouldInferImagePanel(null, 'SELECT 1 FORMAT PNG')).toBe(true); + expect(shouldInferImagePanel(undefined, 'SELECT 1 FORMAT png')).toBe(true); + expect(shouldInferImagePanel({}, 'SELECT 1 FORMAT PNG')).toBe(true); + expect(shouldInferImagePanel({ cfg: null }, 'SELECT 1 FORMAT PNG')).toBe(true); + }); + it('never fires without a trailing FORMAT PNG', () => { + expect(shouldInferImagePanel(null, 'SELECT 1')).toBe(false); + expect(shouldInferImagePanel(null, 'SELECT 1 FORMAT CSV')).toBe(false); + }); + it('never fires when the panel already has ANY explicit cfg, even a non-image one', () => { + expect(shouldInferImagePanel({ cfg: { type: 'table' } }, 'SELECT 1 FORMAT PNG')).toBe(false); + expect(shouldInferImagePanel({ cfg: { type: 'image' } }, 'SELECT 1 FORMAT PNG')).toBe(false); + }); +}); + describe('explicitPanel', () => { it('returns the saved panel when its cfg is a plain object', () => { const query = { spec: { panel: { cfg: { type: 'kpi' } } } }; From adc35ddb5f4c6efff1c109080b320182fcaf453d Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 19 Jul 2026 13:08:40 +0000 Subject: [PATCH 7/9] docs(#307): link ClickHouse/RayTracer from the image-panels README entry Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2263af95..e799dab3 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ 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) that +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`. From c65c1b57c51c509b9736f356dd0159dc79b12955 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 19 Jul 2026 13:09:40 +0000 Subject: [PATCH 8/9] feat(local-serve): opt an OAuth connection into ch_auth basic via tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build/local.py only ever emitted the default bearer auth for OAuth connections from ~/.clickhouse-client/config.xml, so the ch-jwt-verify path (stock/OSS ClickHouse behind a Basic-password JWT verifier) could not be exercised locally — the browser always sent Authorization: Bearer, which such a server rejects. A basic tag on the connection now mirrors install.sh --ch-auth basic into the generated config.json IdP entry. Docs + README updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB --- CHANGELOG.md | 9 +++++++++ README.md | 7 +++++++ build/local.py | 19 ++++++++++++++++--- docs/CLICKHOUSE-OSS-OAUTH.md | 5 +++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adaa91dd..d4497680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,15 @@ auto-generated per-PR notes; this file is the curated, human-readable history. 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 diff --git a/README.md b/README.md index e799dab3..abb2c571 100644 --- a/README.md +++ b/README.md @@ -652,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. From f6c1f3230acbe7d4deb50178d96b5ed640a0b3fe Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 19 Jul 2026 14:09:43 +0000 Subject: [PATCH 9/9] =?UTF-8?q?fix(#307):=20merge-blocking=20review=20?= =?UTF-8?q?=E2=80=94=20bounded=20binary=20read,=20strict=20PNG,=20full=20d?= =?UTF-8?q?ashboard=20disposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: runQuery's binary branch enforces MAX_PNG_BYTES during transport — oversized Content-Length rejects without reading, chunked reads cancel the reader the moment the cap is crossed, and only an in-bounds payload is assembled (single allocation). P2s: validatePng requires IHDR length exactly 13 with in-bounds CRC and a structurally complete terminal IEND; a 2xx body that fails PNG validation but resembles a ClickHouse exception surfaces via parseExceptionText instead of a structural error; renderDashboard gains a route-level disposer (effect, viewer session, every retained tile renderer, resize listener) and removed tiles destroy their renderer before pruning (implements #318, closes with the PR); exportScript revokes a displayed image result's URL via a new ExportHooks.revokeResultImage before replacing tab.result; decode failures show a result/tile error, revoke the URL, and clear the cache so re-renders mint fresh URLs; query-import tile sync bumps an existing Dashboard's revision exactly once when it actually changes (new doc stays revision 1, no-op imports keep the reference). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X8d8DK5DegshRDZ8xfajWB --- src/application/export-service.ts | 6 + src/application/query-execution-service.ts | 17 +- src/core/png.ts | 36 +++- src/core/stream.ts | 13 ++ src/net/ch-client.ts | 68 +++++++- src/ui/app.ts | 1 + src/ui/dashboard.ts | 84 +++++++--- src/ui/panels.ts | 18 +- src/ui/results.ts | 19 ++- src/workspace/import-planner.ts | 43 ++++- tests/unit/ch-client.test.ts | 183 ++++++++++++++++++++- tests/unit/dashboard.test.ts | 50 ++++++ tests/unit/export-service.test.ts | 21 +++ tests/unit/import-planner.test.ts | 65 ++++++++ tests/unit/panels.test.ts | 21 +++ tests/unit/png.test.ts | 52 +++++- tests/unit/query-execution-service.test.ts | 34 +++- tests/unit/results.test.ts | 17 ++ tests/unit/stream.test.ts | 20 ++- 19 files changed, 710 insertions(+), 58 deletions(-) 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 9391fe47..a77d137d 100644 --- a/src/application/query-execution-service.ts +++ b/src/application/query-execution-service.ts @@ -16,7 +16,7 @@ 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'; @@ -123,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; @@ -191,7 +197,14 @@ export function createQueryExecutionService(deps: QueryExecutionDeps): QueryExec }; result.progress.bytes = out.binary.bytes.byteLength; } else { - result.error = 'Invalid PNG result: ' + check.reason; + // 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; diff --git a/src/core/png.ts b/src/core/png.ts index 18a75a2b..fe66fa83 100644 --- a/src/core/png.ts +++ b/src/core/png.ts @@ -31,13 +31,21 @@ 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 PNG: non-empty, under - * `MAX_PNG_BYTES`, the 8-byte PNG signature, a readable IHDR chunk (first - * chunk at offset 8, length >= 13, type 'IHDR') with positive width/height + * 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`. Never decodes pixel data — only the signature + IHDR - * header. Pure. + * 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' }; @@ -54,7 +62,11 @@ export function validatePng(bytes: Uint8Array): ValidatePngResult { 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)' }; + 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)' }; @@ -64,5 +76,17 @@ export function validatePng(bytes: Uint8Array): ValidatePngResult { 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/stream.ts b/src/core/stream.ts index c2124cc9..6bb10f96 100644 --- a/src/core/stream.ts +++ b/src/core/stream.ts @@ -140,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/net/ch-client.ts b/src/net/ch-client.ts index 6cdf08d9..2c98caad 100644 --- a/src/net/ch-client.ts +++ b/src/net/ch-client.ts @@ -12,6 +12,7 @@ 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, isBinaryFormat } from '../core/format.js'; +import { MAX_PNG_BYTES } from '../core/png.js'; // ── Injected ctx seam ──────────────────────────────────────────────────────── @@ -859,6 +860,70 @@ export interface RunQueryResult { 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' } }; +} + /** * Run a query in streaming mode (JSONStringsEachRowWithProgress) or raw mode * (TSV/JSON). `onLine(parsedObj)` is called per stream object in streaming @@ -919,8 +984,7 @@ export async function runQuery(ctx: ChCtx, sql: string, o: RunQueryOptions = {}) } if (!isStreaming) { if (isBinaryFormat(fmt)) { - const buf = await resp.arrayBuffer(); - return { binary: { bytes: new Uint8Array(buf), contentType: 'image/png' } }; + return readBinaryBody(resp); } return { raw: await resp.text() }; } diff --git a/src/ui/app.ts b/src/ui/app.ts index 45141d84..46316eb1 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -596,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; diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 6768c26f..a47f8ea6 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -125,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 `