From efefeb07a09f5811e7db4f05ef2d783a9dd3630e Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:30:01 +0200 Subject: [PATCH 01/18] build(examples): add portable bundle authoring helper --- examples/mjs/example-bundle.mjs | 233 ++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 examples/mjs/example-bundle.mjs diff --git a/examples/mjs/example-bundle.mjs b/examples/mjs/example-bundle.mjs new file mode 100644 index 00000000..7cd3e566 --- /dev/null +++ b/examples/mjs/example-bundle.mjs @@ -0,0 +1,233 @@ +// Shared authoring helpers for checked-in examples. +// +// Examples are canonical portable bundles, not legacy Library v2 documents. +// This module intentionally has no dependency on the TypeScript application +// graph so the generators remain directly runnable with Node.js. The complete +// application codec validates every generated artifact in +// tests/unit/spec-examples.test.js. + +import { writeFileSync } from 'node:fs'; + +export const PORTABLE_BUNDLE_SCHEMA = + 'https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json'; +export const PORTABLE_BUNDLE_FORMAT = 'altinity-sql-browser/portable-bundle'; + +const clone = (value) => JSON.parse(JSON.stringify(value)); +const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value); + +export function queryDashboardRole(query) { + const role = query?.spec?.dashboard?.role; + return typeof role === 'string' ? role : 'panel'; +} + +function queryPanelType(query) { + const type = query?.spec?.panel?.cfg?.type; + return typeof type === 'string' ? type : null; +} + +function sizeHintsFor(query) { + const type = queryPanelType(query); + if (type === 'kpi') return { preferred: 'compact', minimum: 'compact', aspectRatio: 2 }; + if (type === 'text') return { preferred: 'wide', minimum: 'medium', aspectRatio: 2 }; + if (type === 'table' || type === 'logs') { + return { preferred: 'wide', minimum: 'medium', aspectRatio: 1.6 }; + } + return { preferred: 'medium', minimum: 'compact', aspectRatio: 1.5 }; +} + +function placementFor(query, preset) { + const type = queryPanelType(query); + if (preset === 'report') { + return { span: 1, height: type === 'kpi' ? 'compact' : 'large' }; + } + if (type === 'text' || type === 'table' || type === 'logs') { + return { span: 2, height: 'large' }; + } + if (type === 'kpi') return { span: 1, height: 'compact' }; + return { span: 1, height: 'large' }; +} + +function scanParameterNames(sql) { + const names = []; + const seen = new Set(); + const re = /\{([A-Za-z_][A-Za-z0-9_]*):/g; + for (const match of String(sql || '').matchAll(re)) { + if (!seen.has(match[1])) { + seen.add(match[1]); + names.push(match[1]); + } + } + return names; +} + +export function buildDashboard({ + id, + title, + description, + queries, + tileQueryIds, + sourceByParameter = {}, + preset = 'columns-2', +}) { + if (!id || !title) throw new Error('Dashboard id and title are required'); + if (!['report', 'columns-2', 'columns-3'].includes(preset)) { + throw new Error(`Unsupported flow preset ${JSON.stringify(preset)}`); + } + + const byId = new Map(queries.map((query) => [query.id, query])); + const selected = tileQueryIds.map((queryId) => { + const query = byId.get(queryId); + if (!query) throw new Error(`Dashboard ${id} references unknown query ${JSON.stringify(queryId)}`); + if (queryDashboardRole(query) !== 'panel') { + throw new Error(`Dashboard ${id} tile query ${JSON.stringify(queryId)} is not a Panel query`); + } + return query; + }); + + const tiles = selected.map((query) => ({ id: `tile-${query.id}`, queryId: query.id })); + const items = Object.fromEntries(selected.map((query, index) => [ + tiles[index].id, + placementFor(query, preset), + ])); + + const parameterNames = []; + const seenParameters = new Set(); + for (const query of selected) { + for (const name of scanParameterNames(query.sql)) { + if (!seenParameters.has(name)) { + seenParameters.add(name); + parameterNames.push(name); + } + } + } + + const filters = parameterNames.map((parameter) => ({ + id: `filter-${parameter}`, + parameter, + ...(sourceByParameter[parameter] ? { sourceQueryId: sourceByParameter[parameter] } : {}), + })); + + return { + documentVersion: 1, + id, + title, + ...(description ? { description } : {}), + revision: 1, + layout: { type: 'flow', version: 1, preset, items }, + filters, + tiles, + }; +} + +function normalizeQueriesForDashboards(queries, dashboards) { + const tileQueryIds = new Set(); + const filterSourceIds = new Set(); + for (const dashboard of dashboards) { + for (const tile of dashboard.tiles || []) tileQueryIds.add(tile.queryId); + for (const filter of dashboard.filters || []) { + if (filter.sourceQueryId) filterSourceIds.add(filter.sourceQueryId); + } + } + + return queries.map((raw) => { + const query = clone(raw); + query.spec = isObject(query.spec) ? query.spec : {}; + const dashboard = isObject(query.spec.dashboard) ? query.spec.dashboard : {}; + + if (tileQueryIds.has(query.id)) { + query.spec.favorite = true; + query.spec.dashboard = { + ...dashboard, + role: 'panel', + sizeHints: isObject(dashboard.sizeHints) ? dashboard.sizeHints : sizeHintsFor(query), + }; + } else if (filterSourceIds.has(query.id) || dashboard.role === 'filter') { + query.spec.favorite = false; + query.spec.dashboard = { ...dashboard, role: 'filter' }; + } else { + query.spec.favorite = false; + if (Object.keys(dashboard).length) query.spec.dashboard = dashboard; + } + return query; + }); +} + +export function assertValidExampleBundle(document) { + if (!isObject(document)) throw new Error('Example bundle must be an object'); + if (document.$schema !== PORTABLE_BUNDLE_SCHEMA) throw new Error('Example bundle has the wrong $schema'); + if (document.format !== PORTABLE_BUNDLE_FORMAT || document.version !== 1) { + throw new Error('Example bundle must use portable-bundle v1'); + } + if (typeof document.exportedAt !== 'string' || !document.exportedAt) { + throw new Error('Example bundle exportedAt is required'); + } + if (!Array.isArray(document.queries) || !Array.isArray(document.dashboards)) { + throw new Error('Example bundle requires queries and dashboards arrays'); + } + + const queryIds = new Set(); + for (const query of document.queries) { + if (!isObject(query) || typeof query.id !== 'string' || !query.id) { + throw new Error('Every example query requires a non-empty id'); + } + if (queryIds.has(query.id)) throw new Error(`Duplicate query id ${JSON.stringify(query.id)}`); + queryIds.add(query.id); + if (typeof query.sql !== 'string' || query.specVersion !== 1 || !isObject(query.spec)) { + throw new Error(`Query ${JSON.stringify(query.id)} must use saved-query Spec v1`); + } + if (query.spec.panel && !query.spec.panel?.cfg?.type) { + throw new Error(`Query ${JSON.stringify(query.id)} panel requires cfg.type`); + } + } + + const dashboardIds = new Set(); + for (const dashboard of document.dashboards) { + if (!isObject(dashboard) || dashboard.documentVersion !== 1 || !dashboard.id) { + throw new Error('Every example dashboard must use documentVersion 1 and a non-empty id'); + } + if (dashboardIds.has(dashboard.id)) throw new Error(`Duplicate dashboard id ${JSON.stringify(dashboard.id)}`); + dashboardIds.add(dashboard.id); + if (!Array.isArray(dashboard.tiles) || !Array.isArray(dashboard.filters)) { + throw new Error(`Dashboard ${JSON.stringify(dashboard.id)} requires tiles and filters arrays`); + } + if (dashboard.layout?.type !== 'flow' || dashboard.layout?.version !== 1) { + throw new Error(`Dashboard ${JSON.stringify(dashboard.id)} must use flow@1`); + } + for (const tile of dashboard.tiles) { + if (!queryIds.has(tile.queryId)) { + throw new Error(`Dashboard ${JSON.stringify(dashboard.id)} references unknown query ${JSON.stringify(tile.queryId)}`); + } + const query = document.queries.find((item) => item.id === tile.queryId); + if (queryDashboardRole(query) !== 'panel') { + throw new Error(`Dashboard tile ${JSON.stringify(tile.id)} must reference a Panel query`); + } + } + for (const filter of dashboard.filters) { + if (filter.sourceQueryId && !queryIds.has(filter.sourceQueryId)) { + throw new Error(`Filter ${JSON.stringify(filter.id)} references unknown source query`); + } + } + } + return document; +} + +export function serializeExampleBundle({ exportedAt, metadata, queries, dashboards }) { + const normalizedQueries = normalizeQueriesForDashboards(queries, dashboards); + const document = { + $schema: PORTABLE_BUNDLE_SCHEMA, + format: PORTABLE_BUNDLE_FORMAT, + version: 1, + exportedAt, + ...(metadata ? { metadata } : {}), + queries: normalizedQueries, + dashboards: clone(dashboards), + }; + assertValidExampleBundle(document); + return JSON.stringify(document, null, 2) + '\n'; +} + +export function writeExampleBundle(path, input) { + const encoded = serializeExampleBundle(input); + writeFileSync(path, encoded); + return encoded; +} From 42e425d44fbc301ba48a893c769db82b17697da1 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:30:23 +0200 Subject: [PATCH 02/18] build(examples): add canonical example normalizer --- examples/mjs/normalize-examples.mjs | 180 ++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 examples/mjs/normalize-examples.mjs diff --git a/examples/mjs/normalize-examples.mjs b/examples/mjs/normalize-examples.mjs new file mode 100644 index 00000000..4d72897c --- /dev/null +++ b/examples/mjs/normalize-examples.mjs @@ -0,0 +1,180 @@ +// Normalize every checked-in JSON example to the current portable bundle, +// saved-query Spec v1, and Dashboard document v1 contracts. +// +// Run: +// node examples/mjs/normalize-examples.mjs +// node examples/mjs/normalize-examples.mjs --check + +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + buildDashboard, + queryDashboardRole, + serializeExampleBundle, +} from './example-bundle.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const examples = resolve(here, '..'); +const checkOnly = process.argv.includes('--check'); + +const CONFIG = { + 'kpi-panel.json': { + id: 'kpi-panel-example', title: 'KPI panel example', preset: 'report', + description: 'Scalar and named-tuple KPI presentation example.', + }, + 'text-log-panel.json': { + id: 'text-log-panel-example', title: 'Server log panel example', preset: 'report', + description: 'Parameterized system.text_log dashboard example.', + }, + 'shop-charts.json': { + id: 'shop-charts', title: 'Shop analytics', preset: 'columns-2', + description: 'Chart examples over the sample shop dataset.', + }, + 'query-log-explorer.json': { + id: 'query-log-explorer', title: 'Query log explorer', preset: 'columns-2', + description: 'ClickHouse query-log health, performance, and usage dashboard.', + sourceByParameter: { user: 'qle-filter', query_kind: 'qle-filter' }, + }, + 'grafana-clickhouse-ops-enhanced.json': { + id: 'clickhouse-ops-enhanced', title: 'ClickHouse operations', preset: 'columns-2', + description: 'Operational ClickHouse dashboard adapted from the Grafana dashboard.', + sourceByParameter: { + is_initial_query: 'gco-filter', + query_kind: 'gco-filter', + user: 'gco-filter', + exception_code: 'gco-filter', + query_hash: 'gco-filter', + metric: 'gco-filter', + }, + }, + 'ontime-charts.json': { + id: 'ontime-chart-gallery', title: 'On-time chart gallery', preset: 'columns-2', allPanels: true, + description: 'Chart gallery over the public ontime flight dataset.', + }, + 'system-explorer-charts.json': { + id: 'system-explorer', title: 'ClickHouse system explorer', preset: 'columns-2', + description: 'Operational views over ClickHouse system tables.', + }, + 'iceberg-catalog-dashboard.json': { + id: 'iceberg-catalog-explorer', title: 'Iceberg catalog explorer — BI', preset: 'columns-2', + description: 'Business intelligence view of Iceberg catalog metadata.', + }, + 'iceberg-dba-dashboard.json': { + id: 'iceberg-dba-explorer', title: 'Iceberg catalog explorer — DBA', preset: 'columns-2', + description: 'Maintenance and health view of Iceberg catalog metadata.', + }, + 'iceberg-install.json': { + dashboard: false, title: 'Iceberg Catalog Explorer installer', + description: 'Administrative setup and generation queries for the Iceberg examples.', + }, +}; + +function queriesOf(document, name) { + if (!document || !Array.isArray(document.queries)) { + throw new Error(`${name}: expected a queries array`); + } + return document.queries; +} + +function hasPanel(query) { + return !!query?.spec?.panel?.cfg?.type; +} + +function cleanStaleWording(value) { + if (typeof value === 'string') { + return value + .replaceAll('saved-queries Library JSON', 'portable bundle JSON') + .replaceAll('saved-queries Library', 'portable bundle') + .replaceAll('drill-down Library', 'drill-down portable bundle') + .replaceAll('per-catalog drill-down Library', 'per-catalog drill-down portable bundle') + .replaceAll('File ▸ Append it', 'File → Replace workspace…') + .replaceAll('then File > Append it', 'then File > Replace workspace…') + .replace( + 'Every favorite below is a Panel, except one **Filter**-role source', + 'Every bundled Dashboard tile below is a Panel; one separate **Filter**-role source', + ); + } + if (Array.isArray(value)) return value.map(cleanStaleWording); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cleanStaleWording(child)])); + } + return value; +} + +function tileQueryIds(queries, config) { + const panelQueries = queries.filter((query) => queryDashboardRole(query) === 'panel' && hasPanel(query)); + if (config.allPanels) return panelQueries.map((query) => query.id); + return panelQueries.filter((query) => query.spec?.favorite === true).map((query) => query.id); +} + +function normalizeDocument(name, document, config) { + const queries = cleanStaleWording(queriesOf(document, name)); + const selectedIds = config.dashboard === false ? [] : tileQueryIds(queries, config); + const dashboards = selectedIds.length ? [buildDashboard({ + id: config.id, + title: config.title, + description: config.description, + queries, + tileQueryIds: selectedIds, + sourceByParameter: config.sourceByParameter, + preset: config.preset, + })] : []; + + return serializeExampleBundle({ + exportedAt: typeof document.exportedAt === 'string' ? document.exportedAt : '2026-01-01T00:00:00.000Z', + metadata: { name: config.title, description: config.description }, + queries, + dashboards, + }); +} + +const changed = []; +for (const name of readdirSync(examples).filter((item) => item.endsWith('.json')).sort()) { + const path = resolve(examples, name); + const source = readFileSync(path, 'utf8'); + const document = JSON.parse(source); + const config = CONFIG[name]; + if (!config) throw new Error(`${name}: add an explicit example normalization configuration`); + const normalized = normalizeDocument(name, document, config); + if (normalized !== source) { + changed.push(name); + if (!checkOnly) writeFileSync(path, normalized); + } +} + +const templateName = 'iceberg-templates/ice_meta_drilldown.json.tmpl'; +const templatePath = resolve(examples, templateName); +const templateSource = readFileSync(templatePath, 'utf8'); +const templateDocument = JSON.parse(templateSource); +const templateQueries = cleanStaleWording(queriesOf(templateDocument, templateName)); +const templateTileIds = tileQueryIds(templateQueries, {}); +const templateDashboard = buildDashboard({ + id: 'iceberg-__CATALOG__-drilldown', + title: 'Iceberg __CATALOG__ drill-down', + description: 'Per-table data-file, size, and partition analysis.', + queries: templateQueries, + tileQueryIds: templateTileIds, + preset: 'columns-2', +}); +const normalizedTemplate = serializeExampleBundle({ + exportedAt: templateDocument.exportedAt || '2026-01-01T00:00:00.000Z', + metadata: { + name: 'Iceberg __CATALOG__ drill-down', + description: 'Generated per-catalog Iceberg drill-down bundle.', + }, + queries: templateQueries, + dashboards: [templateDashboard], +}); +if (normalizedTemplate !== templateSource) { + changed.push(templateName); + if (!checkOnly) writeFileSync(templatePath, normalizedTemplate); +} + +if (checkOnly && changed.length) { + throw new Error(`Examples are not normalized: ${changed.join(', ')}`); +} + +console.log(checkOnly + ? `checked ${Object.keys(CONFIG).length} example bundles and the drill-down template` + : `normalized ${changed.length} example artifact${changed.length === 1 ? '' : 's'}`); From 7bda394cf8b984b37becc437660479a541398b96 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:31:23 +0200 Subject: [PATCH 03/18] build(examples): add one-time generator migration --- examples/mjs/rewrite-example-generators.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 examples/mjs/rewrite-example-generators.mjs diff --git a/examples/mjs/rewrite-example-generators.mjs b/examples/mjs/rewrite-example-generators.mjs new file mode 100644 index 00000000..7c0be887 --- /dev/null +++ b/examples/mjs/rewrite-example-generators.mjs @@ -0,0 +1,11 @@ +// One-time source migration used by the rework-examples workflow. It updates +// the existing generators and regression tests in the same commit as the +// generated example artifacts, then the workflow removes this file. + +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (path) => readFileSync(resolve(root, path), 'utf8'); +const write = \ No newline at end of file From 741feb2955b36b45b21ea5f72d08b3995daa3bc3 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:33:01 +0200 Subject: [PATCH 04/18] build(examples): complete generator migration script --- examples/mjs/rewrite-example-generators.mjs | 269 +++++++++++++++++++- 1 file changed, 264 insertions(+), 5 deletions(-) diff --git a/examples/mjs/rewrite-example-generators.mjs b/examples/mjs/rewrite-example-generators.mjs index 7c0be887..fc60282e 100644 --- a/examples/mjs/rewrite-example-generators.mjs +++ b/examples/mjs/rewrite-example-generators.mjs @@ -1,11 +1,270 @@ // One-time source migration used by the rework-examples workflow. It updates -// the existing generators and regression tests in the same commit as the -// generated example artifacts, then the workflow removes this file. +// generators, validation, tests, and documentation before normalizing the +// checked-in artifacts. The workflow removes this file after it runs. -import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const read = (path) => readFileSync(resolve(root, path), 'utf8'); -const write = \ No newline at end of file +const pathOf = (path) => resolve(root, path); +const read = (path) => readFileSync(pathOf(path), 'utf8'); +const write = (path, content) => writeFileSync(pathOf(path), content); + +function replace(path, before, after) { + const source = read(path); + if (!source.includes(before)) throw new Error(`${path}: expected source fragment was not found`); + write(path, source.replace(before, after)); +} + +function replaceRegex(path, pattern, after) { + const source = read(path); + if (!pattern.test(source)) throw new Error(`${path}: expected source pattern was not found`); + write(path, source.replace(pattern, after)); +} + +// On-time gallery: every query is deliberately represented as a Dashboard tile. +replace('examples/mjs/build-ontime-charts.mjs', + "import { assertValidLibraryDocument } from './validate-library.mjs';", + "import { buildDashboard, writeExampleBundle } from './example-bundle.mjs';"); +replaceRegex('examples/mjs/build-ontime-charts.mjs', + /const doc = \{[\s\S]*?assertValidLibraryDocument\(doc\);\n\nconst outPath = resolve\(here, 'ontime-charts\.json'\);\nwriteFileSync\(outPath, JSON\.stringify\(doc, null, 2\) \+ '\\n'\);/, + `const dashboard = buildDashboard({ + id: 'ontime-chart-gallery', + title: 'On-time chart gallery', + description: 'Chart gallery over the public ontime flight dataset.', + queries, + tileQueryIds: queries.map((query) => query.id), + preset: 'columns-2', +}); + +const outPath = resolve(here, 'ontime-charts.json'); +writeExampleBundle(outPath, { + exportedAt: new Date().toISOString(), + metadata: { name: dashboard.title, description: dashboard.description }, + queries, + dashboards: [dashboard], +});`); +replace('examples/mjs/build-ontime-charts.mjs', "import { writeFileSync } from 'node:fs';\n", ''); + +// System explorer: chartable favorites are the explicit Dashboard membership. +replace('examples/mjs/build-system-explorer-charts.mjs', + "import { assertValidLibraryDocument } from './validate-library.mjs';", + "import { buildDashboard, writeExampleBundle } from './example-bundle.mjs';"); +replaceRegex('examples/mjs/build-system-explorer-charts.mjs', + /const doc = \{[\s\S]*?assertValidLibraryDocument\(doc\);\n\nconst outPath = resolve\(here, 'system-explorer-charts\.json'\);\nwriteFileSync\(outPath, JSON\.stringify\(doc, null, 2\) \+ '\\n'\);/, + `const dashboard = buildDashboard({ + id: 'system-explorer', + title: 'ClickHouse system explorer', + description: 'Operational views over ClickHouse system tables.', + queries, + tileQueryIds: queries.filter((query) => query.spec.favorite).map((query) => query.id), + preset: 'columns-2', +}); + +const outPath = resolve(here, 'system-explorer-charts.json'); +writeExampleBundle(outPath, { + exportedAt: new Date().toISOString(), + metadata: { name: dashboard.title, description: dashboard.description }, + queries, + dashboards: [dashboard], +});`); +replace('examples/mjs/build-system-explorer-charts.mjs', "import { writeFileSync } from 'node:fs';\n", ''); + +// Iceberg persona dashboards: explicit tile membership and catalog/time filters. +replace('examples/mjs/build-iceberg-dashboards.mjs', + "import { assertValidLibraryDocument } from './validate-library.mjs';", + "import { buildDashboard, writeExampleBundle } from './example-bundle.mjs';"); +replaceRegex('examples/mjs/build-iceberg-dashboards.mjs', + /const stamp = new Date\(\)\.toISOString\(\);[\s\S]*?console\.log\(`wrote \$\{out\} \(\$\{queries\.length\} entries, \$\{queries\.filter\(\(q\) => q\.spec\.favorite\)\.length\} on the dashboard\)`\);\n\}/, + `const stamp = new Date().toISOString(); +for (const [file, specs, prefix, dashboardMeta] of [ + ['iceberg-catalog-dashboard.json', BI, 'iceb', { + id: 'iceberg-catalog-explorer', title: 'Iceberg catalog explorer — BI', + description: 'Business intelligence view of Iceberg catalog metadata.', + }], + ['iceberg-dba-dashboard.json', DBA, 'iced', { + id: 'iceberg-dba-explorer', title: 'Iceberg catalog explorer — DBA', + description: 'Maintenance and health view of Iceberg catalog metadata.', + }], +]) { + const queries = buildEntries(specs, prefix); + const dashboard = buildDashboard({ + ...dashboardMeta, + queries, + tileQueryIds: queries.filter((query) => query.spec.favorite).map((query) => query.id), + preset: 'columns-2', + }); + const out = resolve(here, file); + writeExampleBundle(out, { + exportedAt: stamp, + metadata: { name: dashboard.title, description: dashboard.description }, + queries, + dashboards: [dashboard], + }); + console.log(\`wrote \${out} (\${queries.length} entries, \${dashboard.tiles.length} on the dashboard)\`); +}`); +replace('examples/mjs/build-iceberg-dashboards.mjs', "import { writeFileSync } from 'node:fs';\n", ''); + +// Iceberg installer remains a query-only portable bundle. +replace('examples/mjs/build-iceberg-install.mjs', + "import { assertValidLibraryDocument } from './validate-library.mjs';", + "import { writeExampleBundle } from './example-bundle.mjs';"); +replaceRegex('examples/mjs/build-iceberg-install.mjs', + /const doc = \{[\s\S]*?assertValidLibraryDocument\(doc\);\n\nconst out = resolve\(here, 'iceberg-install\.json'\);\nwriteFileSync\(out, JSON\.stringify\(doc, null, 2\) \+ '\\n'\);/, + `const normalizedQueries = queries.map(({ id, sql, name, favorite, description, panel, view }) => ({ + id, + sql, + specVersion: 1, + spec: { + name, + favorite, + ...(description ? { description } : {}), + ...(panel ? { panel } : {}), + ...(view ? { view } : {}), + }, +})); + +const out = resolve(here, 'iceberg-install.json'); +writeExampleBundle(out, { + exportedAt: new Date().toISOString(), + metadata: { + name: 'Iceberg Catalog Explorer installer', + description: 'Administrative setup and generation queries for the Iceberg examples.', + }, + queries: normalizedQueries, + dashboards: [], +});`); +replace('examples/mjs/build-iceberg-install.mjs', + "import { readFileSync, writeFileSync } from 'node:fs';", + "import { readFileSync } from 'node:fs';"); + +// Keep the old module path as a fail-closed compatibility alias for external +// maintenance scripts; all in-repo generators now import example-bundle.mjs. +write('examples/mjs/validate-library.mjs', `// Deprecated compatibility alias. Checked-in examples are portable bundles.\nexport { assertValidExampleBundle as assertValidLibraryDocument } from './example-bundle.mjs';\n`); + +write('examples/mjs/README.md', `# Example Bundles and Generators + +The checked-in JSON files under \`examples/\` are canonical **portable bundle +v1** documents. Query definitions use saved-query **Spec v1** and every +Dashboard example includes an explicit **Dashboard document v1** with tile +membership, flow-layout placement, and filter definitions. + +Legacy Library v1/v2 JSON remains importable for compatibility, but it is not an +authoring format for new or regenerated examples. + +## Maintenance commands + +- \`node examples/mjs/normalize-examples.mjs --check\` verifies that every + checked-in example and the Iceberg drill-down template use the canonical + envelope and explicit Dashboard model. +- \`node examples/mjs/normalize-examples.mjs\` migrates/normalizes existing + checked-in artifacts without changing their SQL or panel schema keys. + +## Generators + +- \`build-ontime-charts.mjs\` regenerates \`ontime-charts.json\`. +- \`build-system-explorer-charts.mjs\` regenerates + \`system-explorer-charts.json\`. +- \`build-iceberg-install.mjs\` regenerates \`iceberg-install.json\`. +- \`build-iceberg-dashboards.mjs\` regenerates + \`iceberg-catalog-dashboard.json\` and \`iceberg-dba-dashboard.json\`. +- \`example-bundle.mjs\` owns the shared portable-bundle and Dashboard authoring + helpers used by those generators. + +The dashboard generators that derive live result schema keys require an +appropriately privileged ClickHouse client connection. The install generator +uses the templates in \`examples/iceberg-templates/\`. +`); + +write('tests/unit/spec-examples.test.js', `import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { assertValidExampleBundle } from '../../examples/mjs/example-bundle.mjs'; +import { decodePortableBundleJson } from '../../src/dashboard/model/portable-bundle-codec.js'; +import { querySpecSchemaService } from '../../src/core/spec-schema.js'; +import { filterExecution } from '../../src/core/filter-execution.js'; +import { effectiveDashboardRole } from '../../src/core/result-choice.js'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +function decodeExample(text, name) { + const result = decodePortableBundleJson(text); + expect(result.ok, result.ok ? name : \`${name}: \${result.diagnostics.map((d) => d.message).join('; ')}\`).toBe(true); + if (!result.ok) throw new Error(name); + return result.value; +} + +describe('schema artifacts and examples', () => { + it('keeps generated schema artifacts deterministic and current', () => { + expect(() => execFileSync(process.execPath, ['build/compile-json-schemas.mjs', '--check'], { + cwd: root, stdio: 'pipe', + })).not.toThrow(); + }); + + it('keeps every checked-in JSON example on portable bundle v1 with explicit Dashboard v1 documents', () => { + const examples = resolve(root, 'examples'); + for (const name of readdirSync(examples).filter((item) => item.endsWith('.json'))) { + const text = readFileSync(resolve(examples, name), 'utf8'); + const bundle = decodeExample(text, name); + expect(bundle.format, name).toBe('altinity-sql-browser/portable-bundle'); + expect(bundle.version, name).toBe(1); + expect(bundle.queries.length, name).toBeGreaterThan(0); + expect(() => assertValidExampleBundle(bundle), name).not.toThrow(); + for (const dashboard of bundle.dashboards) { + expect(dashboard.documentVersion, name).toBe(1); + expect(dashboard.layout.type, name).toBe('flow'); + expect(dashboard.tiles.length, name).toBeGreaterThan(0); + } + } + expect(() => execFileSync(process.execPath, ['examples/mjs/normalize-examples.mjs', '--check'], { + cwd: root, stdio: 'pipe', + })).not.toThrow(); + }); + + it('validates the generated Iceberg drilldown portable-bundle template', () => { + const template = readFileSync(resolve(root, 'examples/iceberg-templates/ice_meta_drilldown.json.tmpl'), 'utf8') + .replaceAll('__CATALOG__', 'demo'); + const bundle = decodeExample(template, 'ice_meta_drilldown.json.tmpl'); + expect(bundle.dashboards).toHaveLength(1); + expect(bundle.dashboards[0].tiles.length).toBeGreaterThan(0); + }); + + it('every Filter-role example query is a valid Filter source', () => { + const examples = resolve(root, 'examples'); + for (const name of readdirSync(examples).filter((item) => item.endsWith('.json'))) { + const bundle = decodeExample(readFileSync(resolve(examples, name), 'utf8'), name); + for (const query of bundle.queries) { + if (effectiveDashboardRole(query.spec) !== 'filter') continue; + expect(filterExecution(query.sql).diagnostics, \`${name}:\${query.id}\`).toEqual([]); + } + } + }); + + it('validates every JSON Spec example used by the authoring documentation', () => { + for (const name of ['saved-query-spec-json-schema.md', 'visualization-spec-authoring-guide.md']) { + const source = readFileSync(resolve(root, 'docs/drafts', name), 'utf8'); + const snippets = [...source.matchAll(/\`\`\`json\n([\s\S]*?)\`\`\`/g)].map((match) => JSON.parse(match[1])); + expect(snippets.length, name).toBeGreaterThan(0); + for (const spec of snippets) expect(querySpecSchemaService.validate(spec), name).toEqual([]); + } + }); + + it('makes example validation fail before an invalid document can be written', () => { + const valid = { + $schema: 'https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json', + format: 'altinity-sql-browser/portable-bundle', version: 1, + exportedAt: '2026-07-14T00:00:00.000Z', dashboards: [], + queries: [{ id: 'q', sql: 'SELECT 1', specVersion: 1, spec: { panel: { cfg: { type: 'table' } } } }], + }; + expect(assertValidExampleBundle(valid).queries).toHaveLength(1); + valid.queries[0].spec.panel = {}; + expect(() => assertValidExampleBundle(valid)).toThrow('panel requires cfg.type'); + }); +}); +`); + +// Remove this one-time script from the resulting commit. +unlinkSync(fileURLToPath(import.meta.url)); From cd70338944efda114b41830f3c8aa83e1a8024ba Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:33:09 +0200 Subject: [PATCH 05/18] ci(examples): run one-time example migration --- .github/workflows/rework-examples.yml | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/rework-examples.yml diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml new file mode 100644 index 00000000..8f939711 --- /dev/null +++ b/.github/workflows/rework-examples.yml @@ -0,0 +1,44 @@ +name: Rework examples + +on: + push: + branches: [examples-rework-bootstrap] + +permissions: + contents: write + +jobs: + migrate-and-verify: + if: ${{ !contains(github.event.head_commit.message, '[examples migrated]') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: examples-rework-bootstrap + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + - run: npm ci --no-audit --no-fund + - name: Rewrite generators and tests + run: node examples/mjs/rewrite-example-generators.mjs + - name: Normalize checked-in examples + run: node examples/mjs/normalize-examples.mjs + - name: Verify normalization + run: node examples/mjs/normalize-examples.mjs --check + - name: Test + run: npm test + - name: Typecheck + run: npx tsc --noEmit + - name: Boundaries + run: npm run check-boundaries + - name: Build + run: npm run build + - name: Commit migrated examples + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add examples tests/unit/spec-examples.test.js + git commit -m "docs(examples): migrate to portable dashboard bundles [examples migrated]" + git push origin HEAD:examples-rework-bootstrap From 4fe4394fbc794cd41ae00b7020c9f03cc850c581 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:34:05 +0200 Subject: [PATCH 06/18] ci(examples): allow PR-triggered migration verification --- .github/workflows/rework-examples.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml index 8f939711..d49b356a 100644 --- a/.github/workflows/rework-examples.yml +++ b/.github/workflows/rework-examples.yml @@ -3,13 +3,15 @@ name: Rework examples on: push: branches: [examples-rework-bootstrap] + pull_request: + branches: [main] permissions: contents: write jobs: migrate-and-verify: - if: ${{ !contains(github.event.head_commit.message, '[examples migrated]') }} + if: ${{ github.event_name == 'pull_request' || !contains(github.event.head_commit.message, '[examples migrated]') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -36,6 +38,7 @@ jobs: - name: Build run: npm run build - name: Commit migrated examples + if: ${{ github.event_name == 'push' }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" From 2990f2fc7c7ceba6eec7a2bbb888423d319db04e Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:35:17 +0200 Subject: [PATCH 07/18] ci(examples): persist migration failure diagnostics --- .github/workflows/rework-examples.yml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml index d49b356a..aca836eb 100644 --- a/.github/workflows/rework-examples.yml +++ b/.github/workflows/rework-examples.yml @@ -11,7 +11,7 @@ permissions: jobs: migrate-and-verify: - if: ${{ github.event_name == 'pull_request' || !contains(github.event.head_commit.message, '[examples migrated]') }} + if: ${{ github.event_name == 'pull_request' || (!contains(github.event.head_commit.message, '[examples migrated]') && !contains(github.event.head_commit.message, '[migration debug]')) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -24,7 +24,12 @@ jobs: cache: npm - run: npm ci --no-audit --no-fund - name: Rewrite generators and tests - run: node examples/mjs/rewrite-example-generators.mjs + run: | + if ! node examples/mjs/rewrite-example-generators.mjs > /tmp/rewrite.log 2>&1; then + cat /tmp/rewrite.log + cp /tmp/rewrite.log examples/rewrite-error.log + exit 1 + fi - name: Normalize checked-in examples run: node examples/mjs/normalize-examples.mjs - name: Verify normalization @@ -38,10 +43,20 @@ jobs: - name: Build run: npm run build - name: Commit migrated examples - if: ${{ github.event_name == 'push' }} + if: ${{ success() && github.event_name == 'push' }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add examples tests/unit/spec-examples.test.js git commit -m "docs(examples): migrate to portable dashboard bundles [examples migrated]" git push origin HEAD:examples-rework-bootstrap + - name: Persist rewrite failure + if: ${{ failure() && github.event_name == 'push' }} + run: | + if [ -f examples/rewrite-error.log ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add examples/rewrite-error.log + git commit -m "chore(examples): capture migration failure [migration debug]" + git push origin HEAD:examples-rework-bootstrap + fi From dffe7ce17b760f182063b7f9389f600e2d59d532 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:36:01 +0200 Subject: [PATCH 08/18] ci(examples): always persist rewrite diagnostics --- .github/workflows/rework-examples.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml index aca836eb..84c0ba6a 100644 --- a/.github/workflows/rework-examples.yml +++ b/.github/workflows/rework-examples.yml @@ -51,7 +51,7 @@ jobs: git commit -m "docs(examples): migrate to portable dashboard bundles [examples migrated]" git push origin HEAD:examples-rework-bootstrap - name: Persist rewrite failure - if: ${{ failure() && github.event_name == 'push' }} + if: ${{ failure() }} run: | if [ -f examples/rewrite-error.log ]; then git config user.name "github-actions[bot]" From 4bd51517c2e590ac1647d7c83d8ba1ac637ef47b Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:37:07 +0200 Subject: [PATCH 09/18] ci(examples): upload migration diagnostics --- .github/workflows/rework-examples.yml | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml index 84c0ba6a..481d98e7 100644 --- a/.github/workflows/rework-examples.yml +++ b/.github/workflows/rework-examples.yml @@ -11,7 +11,7 @@ permissions: jobs: migrate-and-verify: - if: ${{ github.event_name == 'pull_request' || (!contains(github.event.head_commit.message, '[examples migrated]') && !contains(github.event.head_commit.message, '[migration debug]')) }} + if: ${{ github.event_name == 'pull_request' || !contains(github.event.head_commit.message, '[examples migrated]') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -27,9 +27,14 @@ jobs: run: | if ! node examples/mjs/rewrite-example-generators.mjs > /tmp/rewrite.log 2>&1; then cat /tmp/rewrite.log - cp /tmp/rewrite.log examples/rewrite-error.log exit 1 fi + - name: Upload rewrite diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: rewrite-diagnostics + path: /tmp/rewrite.log - name: Normalize checked-in examples run: node examples/mjs/normalize-examples.mjs - name: Verify normalization @@ -50,13 +55,3 @@ jobs: git add examples tests/unit/spec-examples.test.js git commit -m "docs(examples): migrate to portable dashboard bundles [examples migrated]" git push origin HEAD:examples-rework-bootstrap - - name: Persist rewrite failure - if: ${{ failure() }} - run: | - if [ -f examples/rewrite-error.log ]; then - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add examples/rewrite-error.log - git commit -m "chore(examples): capture migration failure [migration debug]" - git push origin HEAD:examples-rework-bootstrap - fi From ee5e610cec7c60b116d2b89b2fcec5831c423a04 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:38:36 +0200 Subject: [PATCH 10/18] ci(examples): patch generated test interpolation --- .github/workflows/rework-examples.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml index 481d98e7..ca3e08ca 100644 --- a/.github/workflows/rework-examples.yml +++ b/.github/workflows/rework-examples.yml @@ -23,6 +23,22 @@ jobs: node-version: 22 cache: npm - run: npm ci --no-audit --no-fund + - name: Patch one-time generator rewrite + run: | + python3 - <<'PY' + from pathlib import Path + path = Path('examples/mjs/rewrite-example-generators.mjs') + text = path.read_text() + text = text.replace( + "expect(result.ok, result.ok ? name : \\`${name}: \\${result.diagnostics.map((d) => d.message).join('; ')}\\`).toBe(true);", + "expect(result.ok, result.ok ? name : name + ': ' + result.diagnostics.map((d) => d.message).join('; ')).toBe(true);", + ) + text = text.replace( + "expect(filterExecution(query.sql).diagnostics, \\`${name}:\\${query.id}\\`).toEqual([]);", + "expect(filterExecution(query.sql).diagnostics, name + ':' + query.id).toEqual([]);", + ) + path.write_text(text) + PY - name: Rewrite generators and tests run: | if ! node examples/mjs/rewrite-example-generators.mjs > /tmp/rewrite.log 2>&1; then From aec6c7840050fdbcf91cf99ef7432107b9c0b568 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:40:03 +0200 Subject: [PATCH 11/18] ci(examples): upload test diagnostics --- .github/workflows/rework-examples.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml index ca3e08ca..aa5a6850 100644 --- a/.github/workflows/rework-examples.yml +++ b/.github/workflows/rework-examples.yml @@ -56,7 +56,17 @@ jobs: - name: Verify normalization run: node examples/mjs/normalize-examples.mjs --check - name: Test - run: npm test + run: | + if ! npm test > /tmp/test.log 2>&1; then + cat /tmp/test.log + exit 1 + fi + - name: Upload test diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: test-diagnostics + path: /tmp/test.log - name: Typecheck run: npx tsc --noEmit - name: Boundaries From 29d4b8f55b661fe0864e5add0ec41d16a5c8c069 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:42:01 +0200 Subject: [PATCH 12/18] ci(examples): repair generated markdown regex --- .github/workflows/rework-examples.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml index aa5a6850..60f7fd95 100644 --- a/.github/workflows/rework-examples.yml +++ b/.github/workflows/rework-examples.yml @@ -45,6 +45,23 @@ jobs: cat /tmp/rewrite.log exit 1 fi + - name: Patch generated test regex + run: | + python3 - <<'PY' + from pathlib import Path + import re + path = Path('tests/unit/spec-examples.test.js') + text = path.read_text() + text, count = re.subn( + r"const snippets = \[\.\.\.source\.matchAll\(/```json[\s\S]*?g\)\]\.map\(\(match\) => JSON\.parse\(match\[1\]\)\);", + "const snippets = [...source.matchAll(new RegExp('```json\\\\n([\\\\s\\\\S]*?)```', 'g'))].map((match) => JSON.parse(match[1]));", + text, + count=1, + ) + if count != 1: + raise SystemExit('generated markdown regex was not found') + path.write_text(text) + PY - name: Upload rewrite diagnostics if: ${{ failure() }} uses: actions/upload-artifact@v7 From ba2717223110fba1e7c9aee8c9d8287f47946225 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:44:20 +0200 Subject: [PATCH 13/18] ci(examples): preserve markdown regex escapes --- .github/workflows/rework-examples.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml index 60f7fd95..c7ebc18c 100644 --- a/.github/workflows/rework-examples.yml +++ b/.github/workflows/rework-examples.yml @@ -49,18 +49,19 @@ jobs: run: | python3 - <<'PY' from pathlib import Path - import re path = Path('tests/unit/spec-examples.test.js') - text = path.read_text() - text, count = re.subn( - r"const snippets = \[\.\.\.source\.matchAll\(/```json[\s\S]*?g\)\]\.map\(\(match\) => JSON\.parse\(match\[1\]\)\);", - "const snippets = [...source.matchAll(new RegExp('```json\\\\n([\\\\s\\\\S]*?)```', 'g'))].map((match) => JSON.parse(match[1]));", - text, - count=1, - ) - if count != 1: + lines = path.read_text().splitlines() + start = next((i for i, line in enumerate(lines) if 'const snippets = [...source.matchAll(' in line), None) + if start is None: raise SystemExit('generated markdown regex was not found') - path.write_text(text) + end = start + while end < len(lines) and 'JSON.parse(match[1]));' not in lines[end]: + end += 1 + if end >= len(lines): + raise SystemExit('generated markdown regex end was not found') + replacement = r" const snippets = [...source.matchAll(/```json\n([\s\S]*?)```/g)].map((match) => JSON.parse(match[1]));" + lines[start:end + 1] = [replacement] + path.write_text('\n'.join(lines) + '\n') PY - name: Upload rewrite diagnostics if: ${{ failure() }} From 3fe7528b3e89d7ec35a770c31922800e290229e0 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:46:39 +0200 Subject: [PATCH 14/18] ci(examples): use repository architecture gate --- .github/workflows/rework-examples.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml index c7ebc18c..454adc38 100644 --- a/.github/workflows/rework-examples.yml +++ b/.github/workflows/rework-examples.yml @@ -88,7 +88,7 @@ jobs: - name: Typecheck run: npx tsc --noEmit - name: Boundaries - run: npm run check-boundaries + run: npm run check:arch - name: Build run: npm run build - name: Commit migrated examples From 6741c340fc8396ef0178e83250ea88e406660ef6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:47:54 +0000 Subject: [PATCH 15/18] docs(examples): migrate to portable dashboard bundles [examples migrated] --- examples/grafana-clickhouse-ops-enhanced.json | 981 +++++++++++++++++- examples/iceberg-catalog-dashboard.json | 195 +++- examples/iceberg-dba-dashboard.json | 171 ++- examples/iceberg-install.json | 18 +- .../ice_meta_drilldown.json.tmpl | 97 +- examples/kpi-panel.json | 64 +- examples/mjs/README.md | 43 +- examples/mjs/build-iceberg-dashboards.mjs | 32 +- examples/mjs/build-iceberg-install.mjs | 45 +- examples/mjs/build-ontime-charts.mjs | 24 +- examples/mjs/build-system-explorer-charts.mjs | 24 +- examples/mjs/rewrite-example-generators.mjs | 270 ----- examples/mjs/validate-library.mjs | 11 +- examples/ontime-charts.json | 208 +++- examples/query-log-explorer.json | 155 ++- examples/shop-charts.json | 76 +- examples/system-explorer-charts.json | 165 ++- examples/text-log-panel.json | 65 +- tests/unit/spec-examples.test.js | 60 +- 19 files changed, 2294 insertions(+), 410 deletions(-) delete mode 100644 examples/mjs/rewrite-example-generators.mjs diff --git a/examples/grafana-clickhouse-ops-enhanced.json b/examples/grafana-clickhouse-ops-enhanced.json index 94e9a11c..95ffdce4 100644 --- a/examples/grafana-clickhouse-ops-enhanced.json +++ b/examples/grafana-clickhouse-ops-enhanced.json @@ -1,7 +1,12 @@ { - "format": "altinity-sql-browser/saved-queries", - "version": 2, + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 1, "exportedAt": "2026-07-16T00:00:00.000Z", + "metadata": { + "name": "ClickHouse operations", + "description": "Operational ClickHouse dashboard adapted from the Grafana dashboard." + }, "queries": [ { "id": "gco-filter", @@ -9,7 +14,7 @@ "specVersion": 1, "spec": { "name": "Grafana port filters", - "favorite": true, + "favorite": false, "description": "Filter source for is_initial_query, query_kind, user, exception_code, query_hash, and metric. Dynamic query-log options use a fixed last-seven-days window. Controls are single-select; blank means Grafana All. Query hashes are capped at the 80 most frequent.", "dashboard": { "role": "filter" @@ -52,6 +57,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -91,6 +104,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -130,6 +151,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -169,6 +198,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -208,6 +245,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -247,6 +292,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -286,6 +339,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -325,6 +386,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -364,6 +433,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -403,6 +480,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -442,6 +527,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -481,6 +574,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -520,6 +621,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -559,6 +668,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -598,6 +715,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -637,6 +762,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -676,6 +809,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -715,6 +856,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -754,6 +903,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -793,6 +950,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -832,6 +997,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -872,6 +1045,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -911,6 +1092,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -950,6 +1139,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -989,6 +1186,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1028,6 +1233,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1067,6 +1280,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1106,6 +1327,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1146,6 +1375,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1184,6 +1421,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1224,6 +1469,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1263,6 +1516,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1302,6 +1563,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1341,6 +1610,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1380,6 +1657,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1419,6 +1704,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1457,6 +1750,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1496,6 +1797,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1535,6 +1844,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1575,6 +1892,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1614,6 +1939,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1651,6 +1984,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1667,6 +2008,14 @@ "cfg": { "type": "table" } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 1.6 + } } } }, @@ -1704,6 +2053,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1741,6 +2098,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1778,6 +2143,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1815,6 +2188,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1852,6 +2233,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1889,6 +2278,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1926,6 +2323,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -1963,6 +2368,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -2000,6 +2413,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -2037,6 +2458,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -2092,6 +2521,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "compact", + "minimum": "compact", + "aspectRatio": 2 + } } } }, @@ -2127,6 +2564,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -2165,6 +2610,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -2203,8 +2656,530 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } } + ], + "dashboards": [ + { + "documentVersion": 1, + "id": "clickhouse-ops-enhanced", + "title": "ClickHouse operations", + "description": "Operational ClickHouse dashboard adapted from the Grafana dashboard.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "columns-2", + "items": { + "tile-gco-029-running-queries": { + "span": 1, + "height": "large" + }, + "tile-gco-047-merges": { + "span": 1, + "height": "large" + }, + "tile-gco-048-mutations": { + "span": 1, + "height": "large" + }, + "tile-gco-049-moves": { + "span": 1, + "height": "large" + }, + "tile-gco-050-distributed-send": { + "span": 1, + "height": "large" + }, + "tile-gco-064-replicated-checks": { + "span": 1, + "height": "large" + }, + "tile-gco-061-replicated-fetch": { + "span": 1, + "height": "large" + }, + "tile-gco-062-replicated-send": { + "span": 1, + "height": "large" + }, + "tile-gco-063-kafka-background-reads": { + "span": 1, + "height": "large" + }, + "tile-gco-065-refreshing-views": { + "span": 1, + "height": "large" + }, + "tile-gco-077-kafka-writes": { + "span": 1, + "height": "large" + }, + "tile-gco-071-background-schedule": { + "span": 1, + "height": "large" + }, + "tile-gco-068-background-common": { + "span": 1, + "height": "large" + }, + "tile-gco-069-background-move": { + "span": 1, + "height": "large" + }, + "tile-gco-067-background-fetches": { + "span": 1, + "height": "large" + }, + "tile-gco-066-background-merges-mutations": { + "span": 1, + "height": "large" + }, + "tile-gco-074-background-distributed": { + "span": 1, + "height": "large" + }, + "tile-gco-075-background-message-broker": { + "span": 1, + "height": "large" + }, + "tile-gco-073-background-buffer-flush": { + "span": 1, + "height": "large" + }, + "tile-gco-078-global-thread-active": { + "span": 1, + "height": "large" + }, + "tile-gco-094-accounted-time": { + "span": 1, + "height": "large" + }, + "tile-gco-076-active-pools": { + "span": 1, + "height": "large" + }, + "tile-gco-095-zk-transactions": { + "span": 1, + "height": "large" + }, + "tile-gco-096-zk-latency": { + "span": 1, + "height": "large" + }, + "tile-gco-097-zk-inflight": { + "span": 1, + "height": "large" + }, + "tile-gco-098-zk-traffic": { + "span": 1, + "height": "large" + }, + "tile-gco-041-cpus-loaded": { + "span": 1, + "height": "large" + }, + "tile-gco-053-memory": { + "span": 1, + "height": "large" + }, + "tile-gco-093-cpu-system": { + "span": 1, + "height": "large" + }, + "tile-gco-028-load-average": { + "span": 1, + "height": "large" + }, + "tile-gco-058-io-util": { + "span": 1, + "height": "large" + }, + "tile-gco-046-network": { + "span": 1, + "height": "large" + }, + "tile-gco-042-io-wait": { + "span": 1, + "height": "large" + }, + "tile-gco-043-cpu-wait": { + "span": 1, + "height": "large" + }, + "tile-gco-044-disk-io": { + "span": 1, + "height": "large" + }, + "tile-gco-045-page-cache-io": { + "span": 1, + "height": "large" + }, + "tile-gco-059-io-bytes": { + "span": 1, + "height": "large" + }, + "tile-gco-060-iops": { + "span": 1, + "height": "large" + }, + "tile-gco-040-queries-started": { + "span": 1, + "height": "large" + }, + "tile-gco-055-connections": { + "span": 1, + "height": "large" + }, + "tile-gco-099-error-log": { + "span": 1, + "height": "large" + }, + "tile-gco-025-query-hash": { + "span": 1, + "height": "large" + }, + "tile-gco-030-query-hash-details": { + "span": 2, + "height": "large" + }, + "tile-gco-056-query-table": { + "span": 1, + "height": "large" + }, + "tile-gco-037-query-user": { + "span": 1, + "height": "large" + }, + "tile-gco-026-query-host": { + "span": 1, + "height": "large" + }, + "tile-gco-079-query-views": { + "span": 1, + "height": "large" + }, + "tile-gco-part-newpart": { + "span": 1, + "height": "large" + }, + "tile-gco-part-downloadpart": { + "span": 1, + "height": "large" + }, + "tile-gco-part-mergeparts": { + "span": 1, + "height": "large" + }, + "tile-gco-part-mutatepart": { + "span": 1, + "height": "large" + }, + "tile-gco-part-movepart": { + "span": 1, + "height": "large" + }, + "tile-gco-part-removepart": { + "span": 1, + "height": "large" + }, + "tile-gco-kpi-overview": { + "span": 1, + "height": "compact" + }, + "tile-gco-donut-query-kind": { + "span": 1, + "height": "large" + }, + "tile-gco-spark-memory": { + "span": 1, + "height": "large" + }, + "tile-gco-smooth-load": { + "span": 1, + "height": "large" + } + } + }, + "filters": [ + { + "id": "filter-from", + "parameter": "from" + }, + { + "id": "filter-to", + "parameter": "to" + }, + { + "id": "filter-exception_code", + "parameter": "exception_code", + "sourceQueryId": "gco-filter" + }, + { + "id": "filter-metric", + "parameter": "metric", + "sourceQueryId": "gco-filter" + }, + { + "id": "filter-is_initial_query", + "parameter": "is_initial_query", + "sourceQueryId": "gco-filter" + }, + { + "id": "filter-query_kind", + "parameter": "query_kind", + "sourceQueryId": "gco-filter" + }, + { + "id": "filter-user", + "parameter": "user", + "sourceQueryId": "gco-filter" + }, + { + "id": "filter-query_hash", + "parameter": "query_hash", + "sourceQueryId": "gco-filter" + } + ], + "tiles": [ + { + "id": "tile-gco-029-running-queries", + "queryId": "gco-029-running-queries" + }, + { + "id": "tile-gco-047-merges", + "queryId": "gco-047-merges" + }, + { + "id": "tile-gco-048-mutations", + "queryId": "gco-048-mutations" + }, + { + "id": "tile-gco-049-moves", + "queryId": "gco-049-moves" + }, + { + "id": "tile-gco-050-distributed-send", + "queryId": "gco-050-distributed-send" + }, + { + "id": "tile-gco-064-replicated-checks", + "queryId": "gco-064-replicated-checks" + }, + { + "id": "tile-gco-061-replicated-fetch", + "queryId": "gco-061-replicated-fetch" + }, + { + "id": "tile-gco-062-replicated-send", + "queryId": "gco-062-replicated-send" + }, + { + "id": "tile-gco-063-kafka-background-reads", + "queryId": "gco-063-kafka-background-reads" + }, + { + "id": "tile-gco-065-refreshing-views", + "queryId": "gco-065-refreshing-views" + }, + { + "id": "tile-gco-077-kafka-writes", + "queryId": "gco-077-kafka-writes" + }, + { + "id": "tile-gco-071-background-schedule", + "queryId": "gco-071-background-schedule" + }, + { + "id": "tile-gco-068-background-common", + "queryId": "gco-068-background-common" + }, + { + "id": "tile-gco-069-background-move", + "queryId": "gco-069-background-move" + }, + { + "id": "tile-gco-067-background-fetches", + "queryId": "gco-067-background-fetches" + }, + { + "id": "tile-gco-066-background-merges-mutations", + "queryId": "gco-066-background-merges-mutations" + }, + { + "id": "tile-gco-074-background-distributed", + "queryId": "gco-074-background-distributed" + }, + { + "id": "tile-gco-075-background-message-broker", + "queryId": "gco-075-background-message-broker" + }, + { + "id": "tile-gco-073-background-buffer-flush", + "queryId": "gco-073-background-buffer-flush" + }, + { + "id": "tile-gco-078-global-thread-active", + "queryId": "gco-078-global-thread-active" + }, + { + "id": "tile-gco-094-accounted-time", + "queryId": "gco-094-accounted-time" + }, + { + "id": "tile-gco-076-active-pools", + "queryId": "gco-076-active-pools" + }, + { + "id": "tile-gco-095-zk-transactions", + "queryId": "gco-095-zk-transactions" + }, + { + "id": "tile-gco-096-zk-latency", + "queryId": "gco-096-zk-latency" + }, + { + "id": "tile-gco-097-zk-inflight", + "queryId": "gco-097-zk-inflight" + }, + { + "id": "tile-gco-098-zk-traffic", + "queryId": "gco-098-zk-traffic" + }, + { + "id": "tile-gco-041-cpus-loaded", + "queryId": "gco-041-cpus-loaded" + }, + { + "id": "tile-gco-053-memory", + "queryId": "gco-053-memory" + }, + { + "id": "tile-gco-093-cpu-system", + "queryId": "gco-093-cpu-system" + }, + { + "id": "tile-gco-028-load-average", + "queryId": "gco-028-load-average" + }, + { + "id": "tile-gco-058-io-util", + "queryId": "gco-058-io-util" + }, + { + "id": "tile-gco-046-network", + "queryId": "gco-046-network" + }, + { + "id": "tile-gco-042-io-wait", + "queryId": "gco-042-io-wait" + }, + { + "id": "tile-gco-043-cpu-wait", + "queryId": "gco-043-cpu-wait" + }, + { + "id": "tile-gco-044-disk-io", + "queryId": "gco-044-disk-io" + }, + { + "id": "tile-gco-045-page-cache-io", + "queryId": "gco-045-page-cache-io" + }, + { + "id": "tile-gco-059-io-bytes", + "queryId": "gco-059-io-bytes" + }, + { + "id": "tile-gco-060-iops", + "queryId": "gco-060-iops" + }, + { + "id": "tile-gco-040-queries-started", + "queryId": "gco-040-queries-started" + }, + { + "id": "tile-gco-055-connections", + "queryId": "gco-055-connections" + }, + { + "id": "tile-gco-099-error-log", + "queryId": "gco-099-error-log" + }, + { + "id": "tile-gco-025-query-hash", + "queryId": "gco-025-query-hash" + }, + { + "id": "tile-gco-030-query-hash-details", + "queryId": "gco-030-query-hash-details" + }, + { + "id": "tile-gco-056-query-table", + "queryId": "gco-056-query-table" + }, + { + "id": "tile-gco-037-query-user", + "queryId": "gco-037-query-user" + }, + { + "id": "tile-gco-026-query-host", + "queryId": "gco-026-query-host" + }, + { + "id": "tile-gco-079-query-views", + "queryId": "gco-079-query-views" + }, + { + "id": "tile-gco-part-newpart", + "queryId": "gco-part-newpart" + }, + { + "id": "tile-gco-part-downloadpart", + "queryId": "gco-part-downloadpart" + }, + { + "id": "tile-gco-part-mergeparts", + "queryId": "gco-part-mergeparts" + }, + { + "id": "tile-gco-part-mutatepart", + "queryId": "gco-part-mutatepart" + }, + { + "id": "tile-gco-part-movepart", + "queryId": "gco-part-movepart" + }, + { + "id": "tile-gco-part-removepart", + "queryId": "gco-part-removepart" + }, + { + "id": "tile-gco-kpi-overview", + "queryId": "gco-kpi-overview" + }, + { + "id": "tile-gco-donut-query-kind", + "queryId": "gco-donut-query-kind" + }, + { + "id": "tile-gco-spark-memory", + "queryId": "gco-spark-memory" + }, + { + "id": "tile-gco-smooth-load", + "queryId": "gco-smooth-load" + } + ] + } ] } diff --git a/examples/iceberg-catalog-dashboard.json b/examples/iceberg-catalog-dashboard.json index 9bab735e..1221202c 100644 --- a/examples/iceberg-catalog-dashboard.json +++ b/examples/iceberg-catalog-dashboard.json @@ -1,7 +1,12 @@ { - "format": "altinity-sql-browser/saved-queries", - "version": 2, + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 1, "exportedAt": "2026-07-13T09:08:20.678Z", + "metadata": { + "name": "Iceberg catalog explorer — BI", + "description": "Business intelligence view of Iceberg catalog metadata." + }, "queries": [ { "id": "iceb-0", @@ -15,7 +20,15 @@ "panel": { "cfg": { "type": "text", - "content": "## Iceberg catalog explorer — BI\n\nDataset shape and growth of every Iceberg catalog installed as an\n`ice_meta_` database, read through the `ice_meta_all` union layer\n(one `catalog` filter drives every tile; blank = all catalogs).\n\nAll numbers come from Iceberg *metadata* (metadata.json / manifest lists) —\nno table data is scanned. Install/extend catalogs with\n`iceberg-install.json`; per-table drill-down (data files, partition skew,\nParquet footers) comes from its \"Generate: per-catalog drill-down Library\"\nentry." + "content": "## Iceberg catalog explorer — BI\n\nDataset shape and growth of every Iceberg catalog installed as an\n`ice_meta_` database, read through the `ice_meta_all` union layer\n(one `catalog` filter drives every tile; blank = all catalogs).\n\nAll numbers come from Iceberg *metadata* (metadata.json / manifest lists) —\nno table data is scanned. Install/extend catalogs with\n`iceberg-install.json`; per-table drill-down (data files, partition skew,\nParquet footers) comes from its \"Generate: per-catalog drill-down portable bundle\"\nentry." + } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 2 } } } @@ -39,6 +52,14 @@ "series": null }, "key": "table:String|rows:Int64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -61,6 +82,14 @@ "series": null }, "key": "table:String|gib:Float64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -83,6 +112,14 @@ "series": null }, "key": "table:String|files:Int64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -105,6 +142,14 @@ "series": null }, "key": "table:String|avg_mib:Float64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -127,6 +172,14 @@ "series": 1 }, "key": "committed_at:DateTime64(3)|table:String|rows:Int64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -149,6 +202,14 @@ "series": 1 }, "key": "day:Date|table:String|commits:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -171,6 +232,14 @@ "series": null }, "key": "kind:String|objects:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -193,6 +262,14 @@ "series": null }, "key": "kind:String|mib:Nullable(Float64)" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -215,8 +292,120 @@ "series": null }, "key": "table:String|columns:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } } + ], + "dashboards": [ + { + "documentVersion": 1, + "id": "iceberg-catalog-explorer", + "title": "Iceberg catalog explorer — BI", + "description": "Business intelligence view of Iceberg catalog metadata.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "columns-2", + "items": { + "tile-iceb-0": { + "span": 2, + "height": "large" + }, + "tile-iceb-1": { + "span": 1, + "height": "large" + }, + "tile-iceb-2": { + "span": 1, + "height": "large" + }, + "tile-iceb-3": { + "span": 1, + "height": "large" + }, + "tile-iceb-4": { + "span": 1, + "height": "large" + }, + "tile-iceb-5": { + "span": 1, + "height": "large" + }, + "tile-iceb-6": { + "span": 1, + "height": "large" + }, + "tile-iceb-7": { + "span": 1, + "height": "large" + }, + "tile-iceb-8": { + "span": 1, + "height": "large" + }, + "tile-iceb-9": { + "span": 1, + "height": "large" + } + } + }, + "filters": [ + { + "id": "filter-catalog", + "parameter": "catalog" + } + ], + "tiles": [ + { + "id": "tile-iceb-0", + "queryId": "iceb-0" + }, + { + "id": "tile-iceb-1", + "queryId": "iceb-1" + }, + { + "id": "tile-iceb-2", + "queryId": "iceb-2" + }, + { + "id": "tile-iceb-3", + "queryId": "iceb-3" + }, + { + "id": "tile-iceb-4", + "queryId": "iceb-4" + }, + { + "id": "tile-iceb-5", + "queryId": "iceb-5" + }, + { + "id": "tile-iceb-6", + "queryId": "iceb-6" + }, + { + "id": "tile-iceb-7", + "queryId": "iceb-7" + }, + { + "id": "tile-iceb-8", + "queryId": "iceb-8" + }, + { + "id": "tile-iceb-9", + "queryId": "iceb-9" + } + ] + } ] } diff --git a/examples/iceberg-dba-dashboard.json b/examples/iceberg-dba-dashboard.json index c35dc828..4eeb925f 100644 --- a/examples/iceberg-dba-dashboard.json +++ b/examples/iceberg-dba-dashboard.json @@ -1,7 +1,12 @@ { - "format": "altinity-sql-browser/saved-queries", - "version": 2, + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 1, "exportedAt": "2026-07-13T09:08:20.678Z", + "metadata": { + "name": "Iceberg catalog explorer — DBA", + "description": "Maintenance and health view of Iceberg catalog metadata." + }, "queries": [ { "id": "iced-0", @@ -15,7 +20,15 @@ "panel": { "cfg": { "type": "text", - "content": "## Iceberg catalog explorer — DBA\n\nMaintenance signals for every installed catalog, from Iceberg metadata only:\nsnapshot-expiry backlog, manifest fragmentation, metadata.json bloat,\nmerge-on-read delete manifests, and two **log panels** — the snapshot commit\ntimeline and the metadata-version timeline (`from` accepts relative times\nlike `-30d`).\n\nThe shared `catalog` filter narrows every tile; blank = all catalogs.\nDetail tables (schema, partition spec, manifest inventory, full snapshot log)\nlive in the Library below the dashboard tiles — they are deliberately not on\nthe dashboard. Per-table small-file/skew/Parquet analysis: generate the\ndrill-down Library from `iceberg-install.json` (entry i5)." + "content": "## Iceberg catalog explorer — DBA\n\nMaintenance signals for every installed catalog, from Iceberg metadata only:\nsnapshot-expiry backlog, manifest fragmentation, metadata.json bloat,\nmerge-on-read delete manifests, and two **log panels** — the snapshot commit\ntimeline and the metadata-version timeline (`from` accepts relative times\nlike `-30d`).\n\nThe shared `catalog` filter narrows every tile; blank = all catalogs.\nDetail tables (schema, partition spec, manifest inventory, full snapshot log)\nlive in the Library below the dashboard tiles — they are deliberately not on\nthe dashboard. Per-table small-file/skew/Parquet analysis: generate the\ndrill-down portable bundle from `iceberg-install.json` (entry i5)." + } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 2 } } } @@ -39,6 +52,14 @@ "series": null }, "key": "table:String|old_snapshots:Int64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -62,6 +83,14 @@ "series": null }, "key": "table:String|manifests:UInt64|avg_files_per_manifest:Float64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -84,6 +113,14 @@ "series": null }, "key": "table:String|delete_manifests:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -107,6 +144,14 @@ "series": null }, "key": "table:String|metadata_files_on_s3:UInt64|snapshots_in_current_metadata:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -131,6 +176,14 @@ "series": null }, "key": "table:String|data_files:UInt64|manifests:UInt64|metadata_versions:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -150,6 +203,14 @@ "msg": "msg", "level": "level" } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 1.6 + } } } }, @@ -168,6 +229,14 @@ "time": "ts", "msg": "msg" } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 1.6 + } } } }, @@ -226,5 +295,101 @@ "view": "table" } } + ], + "dashboards": [ + { + "documentVersion": 1, + "id": "iceberg-dba-explorer", + "title": "Iceberg catalog explorer — DBA", + "description": "Maintenance and health view of Iceberg catalog metadata.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "columns-2", + "items": { + "tile-iced-0": { + "span": 2, + "height": "large" + }, + "tile-iced-1": { + "span": 1, + "height": "large" + }, + "tile-iced-2": { + "span": 1, + "height": "large" + }, + "tile-iced-3": { + "span": 1, + "height": "large" + }, + "tile-iced-4": { + "span": 1, + "height": "large" + }, + "tile-iced-5": { + "span": 1, + "height": "large" + }, + "tile-iced-6": { + "span": 2, + "height": "large" + }, + "tile-iced-7": { + "span": 2, + "height": "large" + } + } + }, + "filters": [ + { + "id": "filter-catalog", + "parameter": "catalog" + }, + { + "id": "filter-from", + "parameter": "from" + }, + { + "id": "filter-to", + "parameter": "to" + } + ], + "tiles": [ + { + "id": "tile-iced-0", + "queryId": "iced-0" + }, + { + "id": "tile-iced-1", + "queryId": "iced-1" + }, + { + "id": "tile-iced-2", + "queryId": "iced-2" + }, + { + "id": "tile-iced-3", + "queryId": "iced-3" + }, + { + "id": "tile-iced-4", + "queryId": "iced-4" + }, + { + "id": "tile-iced-5", + "queryId": "iced-5" + }, + { + "id": "tile-iced-6", + "queryId": "iced-6" + }, + { + "id": "tile-iced-7", + "queryId": "iced-7" + } + ] + } ] } diff --git a/examples/iceberg-install.json b/examples/iceberg-install.json index 1aff2d06..54f39e85 100644 --- a/examples/iceberg-install.json +++ b/examples/iceberg-install.json @@ -1,7 +1,12 @@ { - "format": "altinity-sql-browser/saved-queries", - "version": 2, + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 1, "exportedAt": "2026-07-13T16:06:07.085Z", + "metadata": { + "name": "Iceberg Catalog Explorer installer", + "description": "Administrative setup and generation queries for the Iceberg examples." + }, "queries": [ { "id": "ice-i0", @@ -14,7 +19,7 @@ "panel": { "cfg": { "type": "text", - "content": "## Iceberg Catalog Explorer — install walkthrough\n\nTurns the raw Iceberg metadata of a catalog into queryable ClickHouse views\n(`ice_meta_.tables / snapshots / schema_columns / partition_spec /\nmanifest_lists / objects / metadata_log` + parameterized `data_files(prefix)` /\n`parquet_stats(prefix)`), plus a cross-catalog union layer `ice_meta_all`.\n\n**Precondition**: the catalog must be a REST catalog whose warehouse is a plain\n`s3://` bucket the ClickHouse pod's IAM role can read (keyless `s3()`).\nGlue vended-credentials and S3Tables (ARN warehouse) catalogs are **not**\nsupported by this raw-S3 approach. Also make sure\n`access_control_improvements.enable_read_write_grants = true` on the server,\nor the bucket-scoped S3 grant used by the views fail-opens to unrestricted S3.\n\n**Steps** (run 1–3 on a connection that may CREATE databases/views/users/roles\nand hold+grant `READ ON S3`):\n\n1. **Preflight** — fill `warehouse_url` (https form) on the *Preflight* entry\n and Run. It must report your tables; an access error or 0 files means stop.\n2. **Install the catalog** — fill `catalog` (short name, e.g. `ice`),\n `warehouse_url`, `glob_depth` (`2` for the usual `namespace/table` layout)\n and `grant_role` (an existing role/user to grant read access to, or `none`)\n on *Generate: catalog install DDL*. Run, **copy the raw result**, paste into\n a new tab, Run. Repeat per catalog.\n3. **Union layer** — Run *Generate: ice_meta_all union DDL* (no inputs), copy\n the result into a new tab, Run. Re-run this step whenever you add or remove\n a catalog.\n4. *(optional, needs the chmem memory store + `memory_writer`)* — *Generate:\n chmem seed facts INSERT*: run, copy, run in a new tab. Makes the views\n self-describing to MCP agents.\n5. **Drill-down tiles** — *Generate: per-catalog drill-down Library*: run,\n copy the JSON, save as `iceberg--drilldown.json`, then\n File ▸ Append it. Gives per-table small-file / partition-skew / Parquet\n tiles for that catalog.\n\nThen File ▸ Append `iceberg-catalog-dashboard.json` (BI) and/or\n`iceberg-dba-dashboard.json` (DBA + log panels) and open the Dashboard.\n\nTo **remove** a catalog, see the comment block at the end of its generated\ninstall script." + "content": "## Iceberg Catalog Explorer — install walkthrough\n\nTurns the raw Iceberg metadata of a catalog into queryable ClickHouse views\n(`ice_meta_.tables / snapshots / schema_columns / partition_spec /\nmanifest_lists / objects / metadata_log` + parameterized `data_files(prefix)` /\n`parquet_stats(prefix)`), plus a cross-catalog union layer `ice_meta_all`.\n\n**Precondition**: the catalog must be a REST catalog whose warehouse is a plain\n`s3://` bucket the ClickHouse pod's IAM role can read (keyless `s3()`).\nGlue vended-credentials and S3Tables (ARN warehouse) catalogs are **not**\nsupported by this raw-S3 approach. Also make sure\n`access_control_improvements.enable_read_write_grants = true` on the server,\nor the bucket-scoped S3 grant used by the views fail-opens to unrestricted S3.\n\n**Steps** (run 1–3 on a connection that may CREATE databases/views/users/roles\nand hold+grant `READ ON S3`):\n\n1. **Preflight** — fill `warehouse_url` (https form) on the *Preflight* entry\n and Run. It must report your tables; an access error or 0 files means stop.\n2. **Install the catalog** — fill `catalog` (short name, e.g. `ice`),\n `warehouse_url`, `glob_depth` (`2` for the usual `namespace/table` layout)\n and `grant_role` (an existing role/user to grant read access to, or `none`)\n on *Generate: catalog install DDL*. Run, **copy the raw result**, paste into\n a new tab, Run. Repeat per catalog.\n3. **Union layer** — Run *Generate: ice_meta_all union DDL* (no inputs), copy\n the result into a new tab, Run. Re-run this step whenever you add or remove\n a catalog.\n4. *(optional, needs the chmem memory store + `memory_writer`)* — *Generate:\n chmem seed facts INSERT*: run, copy, run in a new tab. Makes the views\n self-describing to MCP agents.\n5. **Drill-down tiles** — *Generate: per-catalog drill-down portable bundle*: run,\n copy the JSON, save as `iceberg--drilldown.json`, then\n File → Replace workspace…. Gives per-table small-file / partition-skew / Parquet\n tiles for that catalog.\n\nThen File ▸ Append `iceberg-catalog-dashboard.json` (BI) and/or\n`iceberg-dba-dashboard.json` (DBA + log panels) and open the Dashboard.\n\nTo **remove** a catalog, see the comment block at the end of its generated\ninstall script." } }, "view": "panel" @@ -69,11 +74,12 @@ "sql": "WITH\n throwIf(NOT match({catalog:String}, '^[a-z][a-z0-9_]*$'), 'catalog must match ^[a-z][a-z0-9_]*$ (it becomes a database/role name suffix)') AS _g1\nSELECT replaceAll('{\n \"format\": \"altinity-sql-browser/saved-queries\",\n \"version\": 2,\n \"exportedAt\": \"2026-01-01T00:00:00.000Z\",\n \"queries\": [\n {\n \"id\": \"iced-__CATALOG__-0\",\n \"sql\": \"\",\n \"specVersion\": 1,\n \"spec\": {\n \"name\": \"About: __CATALOG__ per-table drill-down\",\n \"favorite\": true,\n \"description\": \"Generated for catalog __CATALOG__ by the Iceberg install Library (i5).\",\n \"view\": \"panel\",\n \"panel\": {\n \"cfg\": {\n \"type\": \"text\",\n \"content\": \"## __CATALOG__ — per-table drill-down\\\\n\\\\nThese tiles use the parameterized views `ice_meta___CATALOG__.data_files(prefix)` and `parquet_stats(prefix)`, which need a table **prefix** in slash form (`namespace/table`).\\\\n\\\\nFill the shared **prefix** filter above — valid values come from:\\\\n\\\\n```sql\\\\nSELECT s3_prefix FROM ice_meta___CATALOG__.tables\\\\n```\\\\n\\\\nThe Parquet footer stats entry is deliberately **not** on the dashboard (it reads every data-file footer — slow); run it from the Library when needed.\"\n }\n }\n }\n },\n {\n \"id\": \"iced-__CATALOG__-1\",\n \"sql\": \"SELECT file_path, file_format, record_count, file_size, partition\\\\nFROM ice_meta___CATALOG__.data_files(prefix = {prefix:String})\\\\nORDER BY file_size DESC\",\n \"specVersion\": 1,\n \"spec\": {\n \"name\": \"Data files of a table (__CATALOG__)\",\n \"favorite\": true,\n \"description\": \"Every data file of one Iceberg table with rows/bytes/partition. prefix = tables.s3_prefix (slash form, e.g. namespace/table).\",\n \"view\": \"panel\",\n \"panel\": {\n \"cfg\": {\n \"type\": \"table\"\n }\n }\n }\n },\n {\n \"id\": \"iced-__CATALOG__-2\",\n \"sql\": \"SELECT\\\\n multiIf(file_size < 1048576, ''a: <1 MiB'',\\\\n file_size < 16777216, ''b: 1-16 MiB'',\\\\n file_size < 134217728, ''c: 16-128 MiB'',\\\\n ''d: >=128 MiB'') AS size_bucket,\\\\n count() AS files,\\\\n round(sum(file_size) / 1048576, 1) AS total_mib\\\\nFROM ice_meta___CATALOG__.data_files(prefix = {prefix:String})\\\\nGROUP BY size_bucket\\\\nORDER BY size_bucket\",\n \"specVersion\": 1,\n \"spec\": {\n \"name\": \"Small-file buckets (__CATALOG__)\",\n \"favorite\": true,\n \"description\": \"Small-file problem detector: data files of the table bucketed by size. Many files under 16 MiB = compaction candidate.\",\n \"view\": \"panel\",\n \"panel\": {\n \"cfg\": {\n \"type\": \"bar\",\n \"x\": 0,\n \"y\": [\n 1\n ],\n \"series\": null\n }\n }\n }\n },\n {\n \"id\": \"iced-__CATALOG__-3\",\n \"sql\": \"SELECT partition, sum(record_count) AS rows, count() AS files\\\\nFROM ice_meta___CATALOG__.data_files(prefix = {prefix:String})\\\\nGROUP BY partition\\\\nORDER BY rows DESC\\\\nLIMIT 30\",\n \"specVersion\": 1,\n \"spec\": {\n \"name\": \"Partition skew (__CATALOG__)\",\n \"favorite\": true,\n \"description\": \"Rows and file count per partition value of the table - a lopsided chart means partition skew.\",\n \"view\": \"panel\",\n \"panel\": {\n \"cfg\": {\n \"type\": \"hbar\",\n \"x\": 0,\n \"y\": [\n 1\n ],\n \"series\": null\n }\n }\n }\n },\n {\n \"id\": \"iced-__CATALOG__-4\",\n \"sql\": \"SELECT * FROM ice_meta___CATALOG__.parquet_stats(prefix = {prefix:String})\",\n \"specVersion\": 1,\n \"spec\": {\n \"name\": \"Parquet footer stats (__CATALOG__) - SLOW, on demand\",\n \"favorite\": false,\n \"description\": \"Compression ratio, row-group counts from every Parquet footer of the table. Reads each file footer (~4s / 40 files) - run on demand, never a live tile.\",\n \"view\": \"table\",\n \"panel\": {\n \"cfg\": {\n \"type\": \"table\"\n }\n }\n }\n }\n ]\n}\n', '__CATALOG__', {catalog:String}) AS library_json\nFORMAT TSVRaw", "specVersion": 1, "spec": { - "name": "Generate: per-catalog drill-down Library", + "name": "Generate: per-catalog drill-down portable bundle", "favorite": false, - "description": "Step 5, once per catalog. Input: catalog (must already be installed). Emits a complete saved-queries Library JSON with the per-table drill-down tiles (data files, small-file buckets, partition skew, on-demand Parquet footer stats) bound to ice_meta_ - database names cannot be query parameters, so these tiles are generated per catalog. Copy the raw result, save it as iceberg--drilldown.json, then File > Append it. The tiles share one `prefix` filter (values = SELECT s3_prefix FROM ice_meta_.tables).", + "description": "Step 5, once per catalog. Input: catalog (must already be installed). Emits a complete portable bundle JSON with the per-table drill-down tiles (data files, small-file buckets, partition skew, on-demand Parquet footer stats) bound to ice_meta_ - database names cannot be query parameters, so these tiles are generated per catalog. Copy the raw result, save it as iceberg--drilldown.json, then File > Replace workspace…. The tiles share one `prefix` filter (values = SELECT s3_prefix FROM ice_meta_.tables).", "view": "table" } } - ] + ], + "dashboards": [] } diff --git a/examples/iceberg-templates/ice_meta_drilldown.json.tmpl b/examples/iceberg-templates/ice_meta_drilldown.json.tmpl index c1736e0e..f461f8e0 100644 --- a/examples/iceberg-templates/ice_meta_drilldown.json.tmpl +++ b/examples/iceberg-templates/ice_meta_drilldown.json.tmpl @@ -1,7 +1,12 @@ { - "format": "altinity-sql-browser/saved-queries", - "version": 2, + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 1, "exportedAt": "2026-01-01T00:00:00.000Z", + "metadata": { + "name": "Iceberg __CATALOG__ drill-down", + "description": "Generated per-catalog Iceberg drill-down bundle." + }, "queries": [ { "id": "iced-__CATALOG__-0", @@ -17,6 +22,14 @@ "type": "text", "content": "## __CATALOG__ — per-table drill-down\n\nThese tiles use the parameterized views `ice_meta___CATALOG__.data_files(prefix)` and `parquet_stats(prefix)`, which need a table **prefix** in slash form (`namespace/table`).\n\nFill the shared **prefix** filter above — valid values come from:\n\n```sql\nSELECT s3_prefix FROM ice_meta___CATALOG__.tables\n```\n\nThe Parquet footer stats entry is deliberately **not** on the dashboard (it reads every data-file footer — slow); run it from the Library when needed." } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 2 + } } } }, @@ -33,6 +46,14 @@ "cfg": { "type": "table" } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 1.6 + } } } }, @@ -54,6 +75,14 @@ ], "series": null } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -75,6 +104,14 @@ ], "series": null } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -94,5 +131,61 @@ } } } + ], + "dashboards": [ + { + "documentVersion": 1, + "id": "iceberg-__CATALOG__-drilldown", + "title": "Iceberg __CATALOG__ drill-down", + "description": "Per-table data-file, size, and partition analysis.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "columns-2", + "items": { + "tile-iced-__CATALOG__-0": { + "span": 2, + "height": "large" + }, + "tile-iced-__CATALOG__-1": { + "span": 2, + "height": "large" + }, + "tile-iced-__CATALOG__-2": { + "span": 1, + "height": "large" + }, + "tile-iced-__CATALOG__-3": { + "span": 1, + "height": "large" + } + } + }, + "filters": [ + { + "id": "filter-prefix", + "parameter": "prefix" + } + ], + "tiles": [ + { + "id": "tile-iced-__CATALOG__-0", + "queryId": "iced-__CATALOG__-0" + }, + { + "id": "tile-iced-__CATALOG__-1", + "queryId": "iced-__CATALOG__-1" + }, + { + "id": "tile-iced-__CATALOG__-2", + "queryId": "iced-__CATALOG__-2" + }, + { + "id": "tile-iced-__CATALOG__-3", + "queryId": "iced-__CATALOG__-3" + } + ] + } ] } diff --git a/examples/kpi-panel.json b/examples/kpi-panel.json index 5e5f15c9..ca839b08 100644 --- a/examples/kpi-panel.json +++ b/examples/kpi-panel.json @@ -1,8 +1,12 @@ { - "$schema": "https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json", - "format": "altinity-sql-browser/saved-queries", - "version": 2, + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 1, "exportedAt": "2026-07-14T00:00:00.000Z", + "metadata": { + "name": "KPI panel example", + "description": "Scalar and named-tuple KPI presentation example." + }, "queries": [ { "id": "kpi-service-health", @@ -14,22 +18,68 @@ "favorite": true, "view": "panel", "panel": { - "cfg": { "type": "kpi" }, + "cfg": { + "type": "kpi" + }, "fieldConfig": { - "defaults": { "noValue": "—" }, + "defaults": { + "noValue": "—" + }, "columns": { - "active_users": { "displayName": "Active users", "color": "#4f8cff" }, + "active_users": { + "displayName": "Active users", + "color": "#4f8cff" + }, "availability": { "displayName": "Availability", "description": "Current service availability.", "unit": "%", "decimals": 2, - "delta": { "unit": " pp", "decimals": 2, "positiveIsGood": true } + "delta": { + "unit": " pp", + "decimals": 2, + "positiveIsGood": true + } } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "compact", + "minimum": "compact", + "aspectRatio": 2 + } } } } + ], + "dashboards": [ + { + "documentVersion": 1, + "id": "kpi-panel-example", + "title": "KPI panel example", + "description": "Scalar and named-tuple KPI presentation example.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "report", + "items": { + "tile-kpi-service-health": { + "span": 1, + "height": "compact" + } + } + }, + "filters": [], + "tiles": [ + { + "id": "tile-kpi-service-health", + "queryId": "kpi-service-health" + } + ] + } ] } diff --git a/examples/mjs/README.md b/examples/mjs/README.md index fa28ca93..f5526b95 100644 --- a/examples/mjs/README.md +++ b/examples/mjs/README.md @@ -1,23 +1,32 @@ -# Example Generators +# Example Bundles and Generators -This directory contains the maintenance scripts that regenerate the checked-in -example libraries in `examples/`. +The checked-in JSON files under `examples/` are canonical **portable bundle +v1** documents. Query definitions use saved-query **Spec v1** and every +Dashboard example includes an explicit **Dashboard document v1** with tile +membership, flow-layout placement, and filter definitions. -## Scripts +Legacy Library v1/v2 JSON remains importable for compatibility, but it is not an +authoring format for new or regenerated examples. -- `build-ontime-charts.mjs` regenerates `ontime-charts.json` -- `build-system-explorer-charts.mjs` regenerates `system-explorer-charts.json` -- `build-iceberg-install.mjs` regenerates `iceberg-install.json` -- `build-iceberg-dashboards.mjs` regenerates `iceberg-catalog-dashboard.json` - and `iceberg-dba-dashboard.json` -- `validate-library.mjs` is the shared validator used by the generators and - tests before any generated Library JSON is written +## Maintenance commands -## Notes +- `node examples/mjs/normalize-examples.mjs --check` verifies that every + checked-in example and the Iceberg drill-down template use the canonical + envelope and explicit Dashboard model. +- `node examples/mjs/normalize-examples.mjs` migrates/normalizes existing + checked-in artifacts without changing their SQL or panel schema keys. -- These are build-time helpers, not app runtime code. -- The dashboard generators need a ClickHouse client connection that can read - the referenced datasets so they can derive live schema keys. -- The install generator uses the local Iceberg templates in - `examples/iceberg-templates/`. +## Generators +- `build-ontime-charts.mjs` regenerates `ontime-charts.json`. +- `build-system-explorer-charts.mjs` regenerates + `system-explorer-charts.json`. +- `build-iceberg-install.mjs` regenerates `iceberg-install.json`. +- `build-iceberg-dashboards.mjs` regenerates + `iceberg-catalog-dashboard.json` and `iceberg-dba-dashboard.json`. +- `example-bundle.mjs` owns the shared portable-bundle and Dashboard authoring + helpers used by those generators. + +The dashboard generators that derive live result schema keys require an +appropriately privileged ClickHouse client connection. The install generator +uses the templates in `examples/iceberg-templates/`. diff --git a/examples/mjs/build-iceberg-dashboards.mjs b/examples/mjs/build-iceberg-dashboards.mjs index 912446c7..e4318173 100644 --- a/examples/mjs/build-iceberg-dashboards.mjs +++ b/examples/mjs/build-iceberg-dashboards.mjs @@ -24,11 +24,10 @@ // Out: examples/iceberg-catalog-dashboard.json, examples/iceberg-dba-dashboard.json import { execFileSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { homedir } from 'node:os'; -import { assertValidLibraryDocument } from './validate-library.mjs'; +import { buildDashboard, writeExampleBundle } from './example-bundle.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const CH_CMD = (process.env.ICE_CH_CMD || `${homedir()}/bin/cl cw-metrics`).split(' '); @@ -345,14 +344,29 @@ function buildEntries(specs, idPrefix) { } const stamp = new Date().toISOString(); -for (const [file, specs, prefix] of [ - ['iceberg-catalog-dashboard.json', BI, 'iceb'], - ['iceberg-dba-dashboard.json', DBA, 'iced'], +for (const [file, specs, prefix, dashboardMeta] of [ + ['iceberg-catalog-dashboard.json', BI, 'iceb', { + id: 'iceberg-catalog-explorer', title: 'Iceberg catalog explorer — BI', + description: 'Business intelligence view of Iceberg catalog metadata.', + }], + ['iceberg-dba-dashboard.json', DBA, 'iced', { + id: 'iceberg-dba-explorer', title: 'Iceberg catalog explorer — DBA', + description: 'Maintenance and health view of Iceberg catalog metadata.', + }], ]) { const queries = buildEntries(specs, prefix); - const doc = { format: 'altinity-sql-browser/saved-queries', version: 2, exportedAt: stamp, queries }; - assertValidLibraryDocument(doc); + const dashboard = buildDashboard({ + ...dashboardMeta, + queries, + tileQueryIds: queries.filter((query) => query.spec.favorite).map((query) => query.id), + preset: 'columns-2', + }); const out = resolve(here, file); - writeFileSync(out, JSON.stringify(doc, null, 2) + '\n'); - console.log(`wrote ${out} (${queries.length} entries, ${queries.filter((q) => q.spec.favorite).length} on the dashboard)`); + writeExampleBundle(out, { + exportedAt: stamp, + metadata: { name: dashboard.title, description: dashboard.description }, + queries, + dashboards: [dashboard], + }); + console.log(`wrote ${out} (${queries.length} entries, ${dashboard.tiles.length} on the dashboard)`); } diff --git a/examples/mjs/build-iceberg-install.mjs b/examples/mjs/build-iceberg-install.mjs index 9feb4cdb..17f3e195 100644 --- a/examples/mjs/build-iceberg-install.mjs +++ b/examples/mjs/build-iceberg-install.mjs @@ -10,10 +10,10 @@ // Run: node examples/mjs/build-iceberg-install.mjs // Out: examples/iceberg-install.json -import { readFileSync, writeFileSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; -import { assertValidLibraryDocument } from './validate-library.mjs'; +import { writeExampleBundle } from './example-bundle.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const tmpl = (name) => readFileSync(resolve(here, 'iceberg-templates', name), 'utf8'); @@ -215,26 +215,27 @@ FORMAT TSVRaw`, }); // --------------------------------------------------------------------------- -const doc = { - format: 'altinity-sql-browser/saved-queries', - version: 2, - exportedAt: new Date().toISOString(), - queries: queries.map(({ id, sql, name, favorite, description, panel, view }) => ({ - id, - sql, - specVersion: 1, - spec: { - name, - favorite, - ...(description ? { description } : {}), - ...(panel ? { panel } : {}), - ...(view ? { view } : {}), - }, - })), -}; - -assertValidLibraryDocument(doc); +const normalizedQueries = queries.map(({ id, sql, name, favorite, description, panel, view }) => ({ + id, + sql, + specVersion: 1, + spec: { + name, + favorite, + ...(description ? { description } : {}), + ...(panel ? { panel } : {}), + ...(view ? { view } : {}), + }, +})); const out = resolve(here, 'iceberg-install.json'); -writeFileSync(out, JSON.stringify(doc, null, 2) + '\n'); +writeExampleBundle(out, { + exportedAt: new Date().toISOString(), + metadata: { + name: 'Iceberg Catalog Explorer installer', + description: 'Administrative setup and generation queries for the Iceberg examples.', + }, + queries: normalizedQueries, + dashboards: [], +}); console.log(`wrote ${out} (${queries.length} entries)`); diff --git a/examples/mjs/build-ontime-charts.mjs b/examples/mjs/build-ontime-charts.mjs index 6414f91d..c5ece1ab 100644 --- a/examples/mjs/build-ontime-charts.mjs +++ b/examples/mjs/build-ontime-charts.mjs @@ -12,10 +12,9 @@ // Out: examples/ontime-charts.json import { execFileSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; -import { assertValidLibraryDocument } from './validate-library.mjs'; +import { buildDashboard, writeExampleBundle } from './example-bundle.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const CONNECTION = 'antalya'; @@ -190,15 +189,20 @@ const queries = SPECS.map((s, i) => { }; }); -const doc = { - format: 'altinity-sql-browser/saved-queries', - version: 2, - exportedAt: new Date().toISOString(), +const dashboard = buildDashboard({ + id: 'ontime-chart-gallery', + title: 'On-time chart gallery', + description: 'Chart gallery over the public ontime flight dataset.', queries, -}; - -assertValidLibraryDocument(doc); + tileQueryIds: queries.map((query) => query.id), + preset: 'columns-2', +}); const outPath = resolve(here, 'ontime-charts.json'); -writeFileSync(outPath, JSON.stringify(doc, null, 2) + '\n'); +writeExampleBundle(outPath, { + exportedAt: new Date().toISOString(), + metadata: { name: dashboard.title, description: dashboard.description }, + queries, + dashboards: [dashboard], +}); console.log(`\nwrote ${outPath} (${queries.length} queries)`); diff --git a/examples/mjs/build-system-explorer-charts.mjs b/examples/mjs/build-system-explorer-charts.mjs index 45f3b422..5fc117c6 100644 --- a/examples/mjs/build-system-explorer-charts.mjs +++ b/examples/mjs/build-system-explorer-charts.mjs @@ -41,10 +41,9 @@ // Out: examples/system-explorer-charts.json import { execFileSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; -import { assertValidLibraryDocument } from './validate-library.mjs'; +import { buildDashboard, writeExampleBundle } from './example-bundle.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const CONNECTION = process.argv[2] || 'github-admin'; @@ -239,15 +238,20 @@ const queries = SPECS.map((s, i) => { return { ...base, spec: { ...base.spec, panel: { cfg: s.cfg, key }, view: 'panel' } }; }); -const doc = { - format: 'altinity-sql-browser/saved-queries', - version: 2, - exportedAt: new Date().toISOString(), +const dashboard = buildDashboard({ + id: 'system-explorer', + title: 'ClickHouse system explorer', + description: 'Operational views over ClickHouse system tables.', queries, -}; - -assertValidLibraryDocument(doc); + tileQueryIds: queries.filter((query) => query.spec.favorite).map((query) => query.id), + preset: 'columns-2', +}); const outPath = resolve(here, 'system-explorer-charts.json'); -writeFileSync(outPath, JSON.stringify(doc, null, 2) + '\n'); +writeExampleBundle(outPath, { + exportedAt: new Date().toISOString(), + metadata: { name: dashboard.title, description: dashboard.description }, + queries, + dashboards: [dashboard], +}); console.log(`\nwrote ${outPath} (${queries.length} queries, ${queries.filter((q) => q.spec.favorite).length} favorited for the Dashboard)`); diff --git a/examples/mjs/rewrite-example-generators.mjs b/examples/mjs/rewrite-example-generators.mjs deleted file mode 100644 index fc60282e..00000000 --- a/examples/mjs/rewrite-example-generators.mjs +++ /dev/null @@ -1,270 +0,0 @@ -// One-time source migration used by the rework-examples workflow. It updates -// generators, validation, tests, and documentation before normalizing the -// checked-in artifacts. The workflow removes this file after it runs. - -import { readFileSync, unlinkSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const pathOf = (path) => resolve(root, path); -const read = (path) => readFileSync(pathOf(path), 'utf8'); -const write = (path, content) => writeFileSync(pathOf(path), content); - -function replace(path, before, after) { - const source = read(path); - if (!source.includes(before)) throw new Error(`${path}: expected source fragment was not found`); - write(path, source.replace(before, after)); -} - -function replaceRegex(path, pattern, after) { - const source = read(path); - if (!pattern.test(source)) throw new Error(`${path}: expected source pattern was not found`); - write(path, source.replace(pattern, after)); -} - -// On-time gallery: every query is deliberately represented as a Dashboard tile. -replace('examples/mjs/build-ontime-charts.mjs', - "import { assertValidLibraryDocument } from './validate-library.mjs';", - "import { buildDashboard, writeExampleBundle } from './example-bundle.mjs';"); -replaceRegex('examples/mjs/build-ontime-charts.mjs', - /const doc = \{[\s\S]*?assertValidLibraryDocument\(doc\);\n\nconst outPath = resolve\(here, 'ontime-charts\.json'\);\nwriteFileSync\(outPath, JSON\.stringify\(doc, null, 2\) \+ '\\n'\);/, - `const dashboard = buildDashboard({ - id: 'ontime-chart-gallery', - title: 'On-time chart gallery', - description: 'Chart gallery over the public ontime flight dataset.', - queries, - tileQueryIds: queries.map((query) => query.id), - preset: 'columns-2', -}); - -const outPath = resolve(here, 'ontime-charts.json'); -writeExampleBundle(outPath, { - exportedAt: new Date().toISOString(), - metadata: { name: dashboard.title, description: dashboard.description }, - queries, - dashboards: [dashboard], -});`); -replace('examples/mjs/build-ontime-charts.mjs', "import { writeFileSync } from 'node:fs';\n", ''); - -// System explorer: chartable favorites are the explicit Dashboard membership. -replace('examples/mjs/build-system-explorer-charts.mjs', - "import { assertValidLibraryDocument } from './validate-library.mjs';", - "import { buildDashboard, writeExampleBundle } from './example-bundle.mjs';"); -replaceRegex('examples/mjs/build-system-explorer-charts.mjs', - /const doc = \{[\s\S]*?assertValidLibraryDocument\(doc\);\n\nconst outPath = resolve\(here, 'system-explorer-charts\.json'\);\nwriteFileSync\(outPath, JSON\.stringify\(doc, null, 2\) \+ '\\n'\);/, - `const dashboard = buildDashboard({ - id: 'system-explorer', - title: 'ClickHouse system explorer', - description: 'Operational views over ClickHouse system tables.', - queries, - tileQueryIds: queries.filter((query) => query.spec.favorite).map((query) => query.id), - preset: 'columns-2', -}); - -const outPath = resolve(here, 'system-explorer-charts.json'); -writeExampleBundle(outPath, { - exportedAt: new Date().toISOString(), - metadata: { name: dashboard.title, description: dashboard.description }, - queries, - dashboards: [dashboard], -});`); -replace('examples/mjs/build-system-explorer-charts.mjs', "import { writeFileSync } from 'node:fs';\n", ''); - -// Iceberg persona dashboards: explicit tile membership and catalog/time filters. -replace('examples/mjs/build-iceberg-dashboards.mjs', - "import { assertValidLibraryDocument } from './validate-library.mjs';", - "import { buildDashboard, writeExampleBundle } from './example-bundle.mjs';"); -replaceRegex('examples/mjs/build-iceberg-dashboards.mjs', - /const stamp = new Date\(\)\.toISOString\(\);[\s\S]*?console\.log\(`wrote \$\{out\} \(\$\{queries\.length\} entries, \$\{queries\.filter\(\(q\) => q\.spec\.favorite\)\.length\} on the dashboard\)`\);\n\}/, - `const stamp = new Date().toISOString(); -for (const [file, specs, prefix, dashboardMeta] of [ - ['iceberg-catalog-dashboard.json', BI, 'iceb', { - id: 'iceberg-catalog-explorer', title: 'Iceberg catalog explorer — BI', - description: 'Business intelligence view of Iceberg catalog metadata.', - }], - ['iceberg-dba-dashboard.json', DBA, 'iced', { - id: 'iceberg-dba-explorer', title: 'Iceberg catalog explorer — DBA', - description: 'Maintenance and health view of Iceberg catalog metadata.', - }], -]) { - const queries = buildEntries(specs, prefix); - const dashboard = buildDashboard({ - ...dashboardMeta, - queries, - tileQueryIds: queries.filter((query) => query.spec.favorite).map((query) => query.id), - preset: 'columns-2', - }); - const out = resolve(here, file); - writeExampleBundle(out, { - exportedAt: stamp, - metadata: { name: dashboard.title, description: dashboard.description }, - queries, - dashboards: [dashboard], - }); - console.log(\`wrote \${out} (\${queries.length} entries, \${dashboard.tiles.length} on the dashboard)\`); -}`); -replace('examples/mjs/build-iceberg-dashboards.mjs', "import { writeFileSync } from 'node:fs';\n", ''); - -// Iceberg installer remains a query-only portable bundle. -replace('examples/mjs/build-iceberg-install.mjs', - "import { assertValidLibraryDocument } from './validate-library.mjs';", - "import { writeExampleBundle } from './example-bundle.mjs';"); -replaceRegex('examples/mjs/build-iceberg-install.mjs', - /const doc = \{[\s\S]*?assertValidLibraryDocument\(doc\);\n\nconst out = resolve\(here, 'iceberg-install\.json'\);\nwriteFileSync\(out, JSON\.stringify\(doc, null, 2\) \+ '\\n'\);/, - `const normalizedQueries = queries.map(({ id, sql, name, favorite, description, panel, view }) => ({ - id, - sql, - specVersion: 1, - spec: { - name, - favorite, - ...(description ? { description } : {}), - ...(panel ? { panel } : {}), - ...(view ? { view } : {}), - }, -})); - -const out = resolve(here, 'iceberg-install.json'); -writeExampleBundle(out, { - exportedAt: new Date().toISOString(), - metadata: { - name: 'Iceberg Catalog Explorer installer', - description: 'Administrative setup and generation queries for the Iceberg examples.', - }, - queries: normalizedQueries, - dashboards: [], -});`); -replace('examples/mjs/build-iceberg-install.mjs', - "import { readFileSync, writeFileSync } from 'node:fs';", - "import { readFileSync } from 'node:fs';"); - -// Keep the old module path as a fail-closed compatibility alias for external -// maintenance scripts; all in-repo generators now import example-bundle.mjs. -write('examples/mjs/validate-library.mjs', `// Deprecated compatibility alias. Checked-in examples are portable bundles.\nexport { assertValidExampleBundle as assertValidLibraryDocument } from './example-bundle.mjs';\n`); - -write('examples/mjs/README.md', `# Example Bundles and Generators - -The checked-in JSON files under \`examples/\` are canonical **portable bundle -v1** documents. Query definitions use saved-query **Spec v1** and every -Dashboard example includes an explicit **Dashboard document v1** with tile -membership, flow-layout placement, and filter definitions. - -Legacy Library v1/v2 JSON remains importable for compatibility, but it is not an -authoring format for new or regenerated examples. - -## Maintenance commands - -- \`node examples/mjs/normalize-examples.mjs --check\` verifies that every - checked-in example and the Iceberg drill-down template use the canonical - envelope and explicit Dashboard model. -- \`node examples/mjs/normalize-examples.mjs\` migrates/normalizes existing - checked-in artifacts without changing their SQL or panel schema keys. - -## Generators - -- \`build-ontime-charts.mjs\` regenerates \`ontime-charts.json\`. -- \`build-system-explorer-charts.mjs\` regenerates - \`system-explorer-charts.json\`. -- \`build-iceberg-install.mjs\` regenerates \`iceberg-install.json\`. -- \`build-iceberg-dashboards.mjs\` regenerates - \`iceberg-catalog-dashboard.json\` and \`iceberg-dba-dashboard.json\`. -- \`example-bundle.mjs\` owns the shared portable-bundle and Dashboard authoring - helpers used by those generators. - -The dashboard generators that derive live result schema keys require an -appropriately privileged ClickHouse client connection. The install generator -uses the templates in \`examples/iceberg-templates/\`. -`); - -write('tests/unit/spec-examples.test.js', `import { describe, expect, it } from 'vitest'; -import { execFileSync } from 'node:child_process'; -import { readFileSync, readdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { assertValidExampleBundle } from '../../examples/mjs/example-bundle.mjs'; -import { decodePortableBundleJson } from '../../src/dashboard/model/portable-bundle-codec.js'; -import { querySpecSchemaService } from '../../src/core/spec-schema.js'; -import { filterExecution } from '../../src/core/filter-execution.js'; -import { effectiveDashboardRole } from '../../src/core/result-choice.js'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); - -function decodeExample(text, name) { - const result = decodePortableBundleJson(text); - expect(result.ok, result.ok ? name : \`${name}: \${result.diagnostics.map((d) => d.message).join('; ')}\`).toBe(true); - if (!result.ok) throw new Error(name); - return result.value; -} - -describe('schema artifacts and examples', () => { - it('keeps generated schema artifacts deterministic and current', () => { - expect(() => execFileSync(process.execPath, ['build/compile-json-schemas.mjs', '--check'], { - cwd: root, stdio: 'pipe', - })).not.toThrow(); - }); - - it('keeps every checked-in JSON example on portable bundle v1 with explicit Dashboard v1 documents', () => { - const examples = resolve(root, 'examples'); - for (const name of readdirSync(examples).filter((item) => item.endsWith('.json'))) { - const text = readFileSync(resolve(examples, name), 'utf8'); - const bundle = decodeExample(text, name); - expect(bundle.format, name).toBe('altinity-sql-browser/portable-bundle'); - expect(bundle.version, name).toBe(1); - expect(bundle.queries.length, name).toBeGreaterThan(0); - expect(() => assertValidExampleBundle(bundle), name).not.toThrow(); - for (const dashboard of bundle.dashboards) { - expect(dashboard.documentVersion, name).toBe(1); - expect(dashboard.layout.type, name).toBe('flow'); - expect(dashboard.tiles.length, name).toBeGreaterThan(0); - } - } - expect(() => execFileSync(process.execPath, ['examples/mjs/normalize-examples.mjs', '--check'], { - cwd: root, stdio: 'pipe', - })).not.toThrow(); - }); - - it('validates the generated Iceberg drilldown portable-bundle template', () => { - const template = readFileSync(resolve(root, 'examples/iceberg-templates/ice_meta_drilldown.json.tmpl'), 'utf8') - .replaceAll('__CATALOG__', 'demo'); - const bundle = decodeExample(template, 'ice_meta_drilldown.json.tmpl'); - expect(bundle.dashboards).toHaveLength(1); - expect(bundle.dashboards[0].tiles.length).toBeGreaterThan(0); - }); - - it('every Filter-role example query is a valid Filter source', () => { - const examples = resolve(root, 'examples'); - for (const name of readdirSync(examples).filter((item) => item.endsWith('.json'))) { - const bundle = decodeExample(readFileSync(resolve(examples, name), 'utf8'), name); - for (const query of bundle.queries) { - if (effectiveDashboardRole(query.spec) !== 'filter') continue; - expect(filterExecution(query.sql).diagnostics, \`${name}:\${query.id}\`).toEqual([]); - } - } - }); - - it('validates every JSON Spec example used by the authoring documentation', () => { - for (const name of ['saved-query-spec-json-schema.md', 'visualization-spec-authoring-guide.md']) { - const source = readFileSync(resolve(root, 'docs/drafts', name), 'utf8'); - const snippets = [...source.matchAll(/\`\`\`json\n([\s\S]*?)\`\`\`/g)].map((match) => JSON.parse(match[1])); - expect(snippets.length, name).toBeGreaterThan(0); - for (const spec of snippets) expect(querySpecSchemaService.validate(spec), name).toEqual([]); - } - }); - - it('makes example validation fail before an invalid document can be written', () => { - const valid = { - $schema: 'https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json', - format: 'altinity-sql-browser/portable-bundle', version: 1, - exportedAt: '2026-07-14T00:00:00.000Z', dashboards: [], - queries: [{ id: 'q', sql: 'SELECT 1', specVersion: 1, spec: { panel: { cfg: { type: 'table' } } } }], - }; - expect(assertValidExampleBundle(valid).queries).toHaveLength(1); - valid.queries[0].spec.panel = {}; - expect(() => assertValidExampleBundle(valid)).toThrow('panel requires cfg.type'); - }); -}); -`); - -// Remove this one-time script from the resulting commit. -unlinkSync(fileURLToPath(import.meta.url)); diff --git a/examples/mjs/validate-library.mjs b/examples/mjs/validate-library.mjs index 68b2e839..85ed63e6 100644 --- a/examples/mjs/validate-library.mjs +++ b/examples/mjs/validate-library.mjs @@ -1,9 +1,2 @@ -// Shared assertion for checked-in Library generators. Validation happens -// immediately before a generator writes output, so generated examples cannot -// drift from the complete canonical Library/saved-query/query.spec contract. - -import { parseImportDoc } from '../../src/core/saved-io.js'; - -export function assertValidLibraryDocument(document) { - return parseImportDoc(JSON.stringify(document)); -} +// Deprecated compatibility alias. Checked-in examples are portable bundles. +export { assertValidExampleBundle as assertValidLibraryDocument } from './example-bundle.mjs'; diff --git a/examples/ontime-charts.json b/examples/ontime-charts.json index 37e552a1..4390ae38 100644 --- a/examples/ontime-charts.json +++ b/examples/ontime-charts.json @@ -1,7 +1,12 @@ { - "format": "altinity-sql-browser/saved-queries", - "version": 2, + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 1, "exportedAt": "2026-06-24T13:22:11.940Z", + "metadata": { + "name": "On-time chart gallery", + "description": "Chart gallery over the public ontime flight dataset." + }, "queries": [ { "id": "s1", @@ -9,7 +14,7 @@ "specVersion": 1, "spec": { "name": "Busiest origin airports — 2023", - "favorite": false, + "favorite": true, "description": "Top 15 departure airports by flight count (joined to dim_airports for readable names). Horizontal Bar — hover any bar, long or short, to read its exact value.", "view": "panel", "panel": { @@ -22,6 +27,14 @@ "series": null }, "key": "airport:String|flights:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -31,7 +44,7 @@ "specVersion": 1, "spec": { "name": "Flights by month — 2023", - "favorite": false, + "favorite": true, "description": "Monthly US flight volume. A numeric column named \"month\" is detected as an ordinal axis → vertical Column chart, with K/M-humanised value ticks.", "view": "panel", "panel": { @@ -44,6 +57,14 @@ "series": null }, "key": "month:UInt8|flights:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -53,7 +74,7 @@ "specVersion": 1, "spec": { "name": "Daily flights — 2023", - "favorite": false, + "favorite": true, "description": "One point per day across 2023 (~365 rows). A Date X axis is auto-detected as a time series → Line chart.", "view": "panel", "panel": { @@ -66,6 +87,14 @@ "series": null }, "key": "date:Date|flights:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -75,7 +104,7 @@ "specVersion": 1, "spec": { "name": "Daily on-time rate — 2023", - "favorite": false, + "favorite": true, "description": "Share of flights arriving on time (< 15 min late) per day, as a percentage. Rendered as a filled Area chart.", "view": "panel", "panel": { @@ -88,6 +117,14 @@ "series": null }, "key": "date:Date|on_time_pct:Float64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -97,7 +134,7 @@ "specVersion": 1, "spec": { "name": "Cancellation reasons — 2023", - "favorite": false, + "favorite": true, "description": "Why flights were cancelled in 2023 (carrier / weather / national air system / security). A small categorical breakdown → Pie chart with a legend.", "view": "panel", "panel": { @@ -110,6 +147,14 @@ "series": null }, "key": "reason:String|cancellations:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -119,7 +164,7 @@ "specVersion": 1, "spec": { "name": "Monthly flights by carrier — 2023", - "favorite": false, + "favorite": true, "description": "Flights per month split across four major carriers (WN, AA, DL, UA). The \"carrier\" column is used as the Series, producing grouped bars with a per-carrier legend.", "view": "panel", "panel": { @@ -132,6 +177,14 @@ "series": 1 }, "key": "month:UInt8|carrier:LowCardinality(String)|flights:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -141,7 +194,7 @@ "specVersion": 1, "spec": { "name": "Average delay breakdown by carrier — 2023", - "favorite": false, + "favorite": true, "description": "Mean minutes of each delay cause (carrier, weather, NAS, late aircraft) for delayed flights, per carrier. Four measures plotted at once (\"All measures\") as grouped columns.", "view": "panel", "panel": { @@ -157,6 +210,14 @@ "series": null }, "key": "carrier:LowCardinality(String)|carrier_delay:Nullable(Float64)|weather_delay:Nullable(Float64)|nas_delay:Nullable(Float64)|late_aircraft_delay:Nullable(Float64)" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -166,7 +227,7 @@ "specVersion": 1, "spec": { "name": "Daily flights since 2022", - "favorite": false, + "favorite": true, "description": "Every day from 2022 onward (~1,460 points). The chart plots the first 500 and shows a \"first 500 of N rows\" note — the table view still has them all.", "view": "panel", "panel": { @@ -179,6 +240,14 @@ "series": null }, "key": "date:Date|flights:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -188,7 +257,7 @@ "specVersion": 1, "spec": { "name": "Flights by day of week — 2023", - "favorite": false, + "favorite": true, "description": "Volume by day of week (1 = Monday … 7 = Sunday). \"dayofweek\" is recognised as an ordinal axis → Column chart.", "view": "panel", "panel": { @@ -201,6 +270,14 @@ "series": null }, "key": "dayofweek:UInt8|flights:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -210,7 +287,7 @@ "specVersion": 1, "spec": { "name": "Worst average departure delay by airport — 2023", - "favorite": false, + "favorite": true, "description": "Airports with the highest mean departure delay (minutes) among those with ≥ 10,000 departures in 2023. Horizontal Bar of a non-count measure, joined for names.", "view": "panel", "panel": { @@ -223,8 +300,115 @@ "series": null }, "key": "airport:String|avg_dep_delay:Nullable(Float64)" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } } + ], + "dashboards": [ + { + "documentVersion": 1, + "id": "ontime-chart-gallery", + "title": "On-time chart gallery", + "description": "Chart gallery over the public ontime flight dataset.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "columns-2", + "items": { + "tile-s1": { + "span": 1, + "height": "large" + }, + "tile-s2": { + "span": 1, + "height": "large" + }, + "tile-s3": { + "span": 1, + "height": "large" + }, + "tile-s4": { + "span": 1, + "height": "large" + }, + "tile-s5": { + "span": 1, + "height": "large" + }, + "tile-s6": { + "span": 1, + "height": "large" + }, + "tile-s7": { + "span": 1, + "height": "large" + }, + "tile-s8": { + "span": 1, + "height": "large" + }, + "tile-s9": { + "span": 1, + "height": "large" + }, + "tile-s10": { + "span": 1, + "height": "large" + } + } + }, + "filters": [], + "tiles": [ + { + "id": "tile-s1", + "queryId": "s1" + }, + { + "id": "tile-s2", + "queryId": "s2" + }, + { + "id": "tile-s3", + "queryId": "s3" + }, + { + "id": "tile-s4", + "queryId": "s4" + }, + { + "id": "tile-s5", + "queryId": "s5" + }, + { + "id": "tile-s6", + "queryId": "s6" + }, + { + "id": "tile-s7", + "queryId": "s7" + }, + { + "id": "tile-s8", + "queryId": "s8" + }, + { + "id": "tile-s9", + "queryId": "s9" + }, + { + "id": "tile-s10", + "queryId": "s10" + } + ] + } ] } diff --git a/examples/query-log-explorer.json b/examples/query-log-explorer.json index dd4d9adc..2fe22a21 100644 --- a/examples/query-log-explorer.json +++ b/examples/query-log-explorer.json @@ -1,7 +1,12 @@ { - "format": "altinity-sql-browser/saved-queries", - "version": 2, + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 1, "exportedAt": "2026-07-15T00:00:00.000Z", + "metadata": { + "name": "Query log explorer", + "description": "ClickHouse query-log health, performance, and usage dashboard." + }, "queries": [ { "id": "qle-filter", @@ -9,7 +14,7 @@ "specVersion": 1, "spec": { "name": "Filter", - "favorite": true, + "favorite": false, "description": "Single Dashboard Filter source. `user` is a value/label bundle (Array(Tuple(value, label)) — #160): the value is the full user that binds to a Panel's {user:String}, the label is the name before '@' for display. `query_kind` is a plain Array(String). Upgrades any Panel's {user:String} and {query_kind:String} parameters to searchable single-select dropdowns.", "dashboard": { "role": "filter" @@ -57,6 +62,14 @@ } } } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "compact", + "minimum": "compact", + "aspectRatio": 2 + } } } }, @@ -72,7 +85,15 @@ "panel": { "cfg": { "type": "text", - "content": "# Dashboard Filter sources demo\n\nEvery favorite below is a Panel, except one **Filter**-role source (#160) that populates the filter bar above with `user` and `query_kind` option lists queried live from `system.query_log` on the **otel** demo cluster.\n\n**Shared filters** — plain, type-detected controls (no Filter source), declared by the data Panels:\n\n- `from` — **required** window start (`DateTime`); type an absolute time or a relative expression like `-24h`, `-7d`, `now`\n- `to` — optional window end (`DateTime`); blank means \"up to now\"\n- `search` — optional case-insensitive substring search over the query text (and, in the log panel, the exception message)\n- `minDurationMs` — optional minimum query-duration threshold, in ms (slowest-queries panel)\n\n**Curated filters** — sourced from the single favorited **Filter** query, upgrading matching Panel parameters to searchable dropdowns:\n\n- `user` — real ClickHouse users seen in the query log\n- `query_kind` — the query kinds seen in the query log (Select, Insert, …)\n\nAnalytical panels are adapted from the Altinity KB page [\"Handy queries for system.query_log\"](https://kb.altinity.com/altinity-kb-useful-queries/query_log/)." + "content": "# Dashboard Filter sources demo\n\nEvery bundled Dashboard tile below is a Panel; one separate **Filter**-role source (#160) that populates the filter bar above with `user` and `query_kind` option lists queried live from `system.query_log` on the **otel** demo cluster.\n\n**Shared filters** — plain, type-detected controls (no Filter source), declared by the data Panels:\n\n- `from` — **required** window start (`DateTime`); type an absolute time or a relative expression like `-24h`, `-7d`, `now`\n- `to` — optional window end (`DateTime`); blank means \"up to now\"\n- `search` — optional case-insensitive substring search over the query text (and, in the log panel, the exception message)\n- `minDurationMs` — optional minimum query-duration threshold, in ms (slowest-queries panel)\n\n**Curated filters** — sourced from the single favorited **Filter** query, upgrading matching Panel parameters to searchable dropdowns:\n\n- `user` — real ClickHouse users seen in the query log\n- `query_kind` — the query kinds seen in the query log (Select, Insert, …)\n\nAnalytical panels are adapted from the Altinity KB page [\"Handy queries for system.query_log\"](https://kb.altinity.com/altinity-kb-useful-queries/query_log/)." + } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 2 } } } @@ -90,6 +111,14 @@ "cfg": { "type": "table" } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 1.6 + } } } }, @@ -111,6 +140,14 @@ ], "series": null } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -132,6 +169,14 @@ ], "series": null } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -151,8 +196,110 @@ "msg": "message", "level": "level" } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 1.6 + } } } } + ], + "dashboards": [ + { + "documentVersion": 1, + "id": "query-log-explorer", + "title": "Query log explorer", + "description": "ClickHouse query-log health, performance, and usage dashboard.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "columns-2", + "items": { + "tile-qle-kpi": { + "span": 1, + "height": "compact" + }, + "tile-qle-text": { + "span": 2, + "height": "large" + }, + "tile-qle-slowest": { + "span": 2, + "height": "large" + }, + "tile-qle-columns": { + "span": 1, + "height": "large" + }, + "tile-qle-functions": { + "span": 1, + "height": "large" + }, + "tile-qle-logs": { + "span": 2, + "height": "large" + } + } + }, + "filters": [ + { + "id": "filter-from", + "parameter": "from" + }, + { + "id": "filter-to", + "parameter": "to" + }, + { + "id": "filter-search", + "parameter": "search" + }, + { + "id": "filter-user", + "parameter": "user", + "sourceQueryId": "qle-filter" + }, + { + "id": "filter-minDurationMs", + "parameter": "minDurationMs" + }, + { + "id": "filter-query_kind", + "parameter": "query_kind", + "sourceQueryId": "qle-filter" + } + ], + "tiles": [ + { + "id": "tile-qle-kpi", + "queryId": "qle-kpi" + }, + { + "id": "tile-qle-text", + "queryId": "qle-text" + }, + { + "id": "tile-qle-slowest", + "queryId": "qle-slowest" + }, + { + "id": "tile-qle-columns", + "queryId": "qle-columns" + }, + { + "id": "tile-qle-functions", + "queryId": "qle-functions" + }, + { + "id": "tile-qle-logs", + "queryId": "qle-logs" + } + ] + } ] } diff --git a/examples/shop-charts.json b/examples/shop-charts.json index 7e7900af..5dffcb0e 100644 --- a/examples/shop-charts.json +++ b/examples/shop-charts.json @@ -1,7 +1,12 @@ { - "format": "altinity-sql-browser/saved-queries", - "version": 2, + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 1, "exportedAt": "2026-06-28T00:00:00.000Z", + "metadata": { + "name": "Shop analytics", + "description": "Chart examples over the sample shop dataset." + }, "queries": [ { "id": "shop-revenue-by-country", @@ -22,6 +27,14 @@ "series": null }, "key": "country:LowCardinality(String)|revenue:Decimal(38, 2)" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -66,6 +79,14 @@ "series": null }, "key": "category:LowCardinality(String)|revenue:Decimal(38, 2)" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -154,6 +175,14 @@ "series": 1 }, "key": "day:Date|country:LowCardinality(String)|revenue:Decimal(38, 2)" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -179,5 +208,48 @@ } } } + ], + "dashboards": [ + { + "documentVersion": 1, + "id": "shop-charts", + "title": "Shop analytics", + "description": "Chart examples over the sample shop dataset.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "columns-2", + "items": { + "tile-shop-revenue-by-country": { + "span": 1, + "height": "large" + }, + "tile-shop-revenue-by-category": { + "span": 1, + "height": "large" + }, + "tile-shop-revenue-trend-by-country": { + "span": 1, + "height": "large" + } + } + }, + "filters": [], + "tiles": [ + { + "id": "tile-shop-revenue-by-country", + "queryId": "shop-revenue-by-country" + }, + { + "id": "tile-shop-revenue-by-category", + "queryId": "shop-revenue-by-category" + }, + { + "id": "tile-shop-revenue-trend-by-country", + "queryId": "shop-revenue-trend-by-country" + } + ] + } ] } diff --git a/examples/system-explorer-charts.json b/examples/system-explorer-charts.json index 8130fa26..b492bd2a 100644 --- a/examples/system-explorer-charts.json +++ b/examples/system-explorer-charts.json @@ -1,7 +1,12 @@ { - "format": "altinity-sql-browser/saved-queries", - "version": 2, + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 1, "exportedAt": "2026-07-04T19:47:55.982Z", + "metadata": { + "name": "ClickHouse system explorer", + "description": "Operational views over ClickHouse system tables." + }, "queries": [ { "id": "sys-1", @@ -72,6 +77,14 @@ "series": null }, "key": "table:String|disk_bytes:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -94,6 +107,14 @@ "series": null }, "key": "table:String|parts:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -116,6 +137,14 @@ "series": null }, "key": "name:String|times:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -138,6 +167,14 @@ "series": null }, "key": "t:DateTime|queries:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -160,6 +197,14 @@ "series": null }, "key": "query:String|avg_duration_ms:Float64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -182,6 +227,14 @@ "series": 1 }, "key": "t:DateTime|error:LowCardinality(String)|n:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -204,6 +257,14 @@ "series": 1 }, "key": "t:DateTime|event_type:Enum8('NewPart' = 1, 'MergeParts' = 2, 'DownloadPart' = 3, 'RemovePart' = 4, 'MutatePart' = 5, 'MovePart' = 6, 'MergePartsStart' = 7, 'MutatePartStart' = 8)|n:UInt64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -226,6 +287,14 @@ "series": null }, "key": "t:DateTime|memory_bytes:Float64" + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "medium", + "minimum": "compact", + "aspectRatio": 1.5 + } } } }, @@ -239,5 +308,97 @@ "description": "The deep-dive version of \"Slowest query patterns\": executions, max/avg duration, rows and bytes read, and p99 memory per query shape over a {from:String}/{to:String} range. Table view — too many columns for one chart, but the full picture behind the bar chart above." } } + ], + "dashboards": [ + { + "documentVersion": 1, + "id": "system-explorer", + "title": "ClickHouse system explorer", + "description": "Operational views over ClickHouse system tables.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "columns-2", + "items": { + "tile-sys-6": { + "span": 1, + "height": "large" + }, + "tile-sys-7": { + "span": 1, + "height": "large" + }, + "tile-sys-8": { + "span": 1, + "height": "large" + }, + "tile-sys-9": { + "span": 1, + "height": "large" + }, + "tile-sys-10": { + "span": 1, + "height": "large" + }, + "tile-sys-11": { + "span": 1, + "height": "large" + }, + "tile-sys-12": { + "span": 1, + "height": "large" + }, + "tile-sys-13": { + "span": 1, + "height": "large" + } + } + }, + "filters": [ + { + "id": "filter-from", + "parameter": "from" + }, + { + "id": "filter-to", + "parameter": "to" + } + ], + "tiles": [ + { + "id": "tile-sys-6", + "queryId": "sys-6" + }, + { + "id": "tile-sys-7", + "queryId": "sys-7" + }, + { + "id": "tile-sys-8", + "queryId": "sys-8" + }, + { + "id": "tile-sys-9", + "queryId": "sys-9" + }, + { + "id": "tile-sys-10", + "queryId": "sys-10" + }, + { + "id": "tile-sys-11", + "queryId": "sys-11" + }, + { + "id": "tile-sys-12", + "queryId": "sys-12" + }, + { + "id": "tile-sys-13", + "queryId": "sys-13" + } + ] + } ] } diff --git a/examples/text-log-panel.json b/examples/text-log-panel.json index d9487b9f..8ca6a37b 100644 --- a/examples/text-log-panel.json +++ b/examples/text-log-panel.json @@ -1,7 +1,12 @@ { - "format": "altinity-sql-browser/saved-queries", - "version": 2, + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 1, "exportedAt": "2026-07-13T05:01:01.000Z", + "metadata": { + "name": "Server log panel example", + "description": "Parameterized system.text_log dashboard example." + }, "queries": [ { "id": "log-1", @@ -19,8 +24,64 @@ "msg": "message", "level": "level" } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 1.6 + } } } } + ], + "dashboards": [ + { + "documentVersion": 1, + "id": "text-log-panel-example", + "title": "Server log panel example", + "description": "Parameterized system.text_log dashboard example.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "report", + "items": { + "tile-log-1": { + "span": 1, + "height": "large" + } + } + }, + "filters": [ + { + "id": "filter-from", + "parameter": "from" + }, + { + "id": "filter-to", + "parameter": "to" + }, + { + "id": "filter-level", + "parameter": "level" + }, + { + "id": "filter-logger", + "parameter": "logger" + }, + { + "id": "filter-message", + "parameter": "message" + } + ], + "tiles": [ + { + "id": "tile-log-1", + "queryId": "log-1" + } + ] + } ] } diff --git a/tests/unit/spec-examples.test.js b/tests/unit/spec-examples.test.js index 0268eb3f..ee8279cb 100644 --- a/tests/unit/spec-examples.test.js +++ b/tests/unit/spec-examples.test.js @@ -3,14 +3,21 @@ import { execFileSync } from 'node:child_process'; import { readFileSync, readdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { assertValidLibraryDocument } from '../../examples/mjs/validate-library.mjs'; -import { parseImportDoc } from '../../src/core/saved-io.js'; +import { assertValidExampleBundle } from '../../examples/mjs/example-bundle.mjs'; +import { decodePortableBundleJson } from '../../src/dashboard/model/portable-bundle-codec.js'; import { querySpecSchemaService } from '../../src/core/spec-schema.js'; import { filterExecution } from '../../src/core/filter-execution.js'; import { effectiveDashboardRole } from '../../src/core/result-choice.js'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +function decodeExample(text, name) { + const result = decodePortableBundleJson(text); + expect(result.ok, result.ok ? name : name + ': ' + result.diagnostics.map((d) => d.message).join('; ')).toBe(true); + if (!result.ok) throw new Error(name); + return result.value; +} + describe('schema artifacts and examples', () => { it('keeps generated schema artifacts deterministic and current', () => { expect(() => execFileSync(process.execPath, ['build/compile-json-schemas.mjs', '--check'], { @@ -18,24 +25,41 @@ describe('schema artifacts and examples', () => { })).not.toThrow(); }); - it('validates every checked-in example Library and the generated drilldown template', () => { + it('keeps every checked-in JSON example on portable bundle v1 with explicit Dashboard v1 documents', () => { const examples = resolve(root, 'examples'); for (const name of readdirSync(examples).filter((item) => item.endsWith('.json'))) { - const { queries } = parseImportDoc(readFileSync(resolve(examples, name), 'utf8')); - expect(queries.length, name).toBeGreaterThan(0); + const text = readFileSync(resolve(examples, name), 'utf8'); + const bundle = decodeExample(text, name); + expect(bundle.format, name).toBe('altinity-sql-browser/portable-bundle'); + expect(bundle.version, name).toBe(1); + expect(bundle.queries.length, name).toBeGreaterThan(0); + expect(() => assertValidExampleBundle(bundle), name).not.toThrow(); + for (const dashboard of bundle.dashboards) { + expect(dashboard.documentVersion, name).toBe(1); + expect(dashboard.layout.type, name).toBe('flow'); + expect(dashboard.tiles.length, name).toBeGreaterThan(0); + } } - const template = readFileSync(resolve(examples, 'iceberg-templates/ice_meta_drilldown.json.tmpl'), 'utf8') + expect(() => execFileSync(process.execPath, ['examples/mjs/normalize-examples.mjs', '--check'], { + cwd: root, stdio: 'pipe', + })).not.toThrow(); + }); + + it('validates the generated Iceberg drilldown portable-bundle template', () => { + const template = readFileSync(resolve(root, 'examples/iceberg-templates/ice_meta_drilldown.json.tmpl'), 'utf8') .replaceAll('__CATALOG__', 'demo'); - expect(parseImportDoc(template).queries.length).toBeGreaterThan(0); + const bundle = decodeExample(template, 'ice_meta_drilldown.json.tmpl'); + expect(bundle.dashboards).toHaveLength(1); + expect(bundle.dashboards[0].tiles.length).toBeGreaterThan(0); }); - it('every Filter-role example query is a valid Filter source (single row-returning statement, no params/FORMAT)', () => { + it('every Filter-role example query is a valid Filter source', () => { const examples = resolve(root, 'examples'); for (const name of readdirSync(examples).filter((item) => item.endsWith('.json'))) { - const { queries } = parseImportDoc(readFileSync(resolve(examples, name), 'utf8')); - for (const q of queries) { - if (effectiveDashboardRole(q.spec) !== 'filter') continue; - expect(filterExecution(q.sql).diagnostics, `${name}:${q.id}`).toEqual([]); + const bundle = decodeExample(readFileSync(resolve(examples, name), 'utf8'), name); + for (const query of bundle.queries) { + if (effectiveDashboardRole(query.spec) !== 'filter') continue; + expect(filterExecution(query.sql).diagnostics, name + ':' + query.id).toEqual([]); } } }); @@ -49,13 +73,15 @@ describe('schema artifacts and examples', () => { } }); - it('makes generator validation fail before an invalid document can be written', () => { + it('makes example validation fail before an invalid document can be written', () => { const valid = { - format: 'altinity-sql-browser/saved-queries', version: 2, exportedAt: '2026-07-14T00:00:00.000Z', + $schema: 'https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json', + format: 'altinity-sql-browser/portable-bundle', version: 1, + exportedAt: '2026-07-14T00:00:00.000Z', dashboards: [], queries: [{ id: 'q', sql: 'SELECT 1', specVersion: 1, spec: { panel: { cfg: { type: 'table' } } } }], }; - expect(assertValidLibraryDocument(valid).queries).toHaveLength(1); - valid.queries[0].spec.panel.cfg = { type: 'pie', x: 0, y: [1, 2] }; - expect(() => assertValidLibraryDocument(valid)).toThrow('panel.cfg.y must contain at most 1 item'); + expect(assertValidExampleBundle(valid).queries).toHaveLength(1); + valid.queries[0].spec.panel = {}; + expect(() => assertValidExampleBundle(valid)).toThrow('panel requires cfg.type'); }); }); From b84ef040942ecedfd43d0ec13c1ab66ee6f87aa3 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:48:27 +0200 Subject: [PATCH 16/18] ci(examples): persist verified migration from PR run --- .github/workflows/rework-examples.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml index 454adc38..c98cfe91 100644 --- a/.github/workflows/rework-examples.yml +++ b/.github/workflows/rework-examples.yml @@ -92,7 +92,7 @@ jobs: - name: Build run: npm run build - name: Commit migrated examples - if: ${{ success() && github.event_name == 'push' }} + if: ${{ success() }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" From e80f09aeb105aa3332f52edf85ce322434e031e9 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:49:27 +0200 Subject: [PATCH 17/18] ci(examples): make staging patch escape-insensitive --- .github/workflows/rework-examples.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml index c98cfe91..ec556425 100644 --- a/.github/workflows/rework-examples.yml +++ b/.github/workflows/rework-examples.yml @@ -28,16 +28,19 @@ jobs: python3 - <<'PY' from pathlib import Path path = Path('examples/mjs/rewrite-example-generators.mjs') - text = path.read_text() - text = text.replace( - "expect(result.ok, result.ok ? name : \\`${name}: \\${result.diagnostics.map((d) => d.message).join('; ')}\\`).toBe(true);", - "expect(result.ok, result.ok ? name : name + ': ' + result.diagnostics.map((d) => d.message).join('; ')).toBe(true);", - ) - text = text.replace( - "expect(filterExecution(query.sql).diagnostics, \\`${name}:\\${query.id}\\`).toEqual([]);", - "expect(filterExecution(query.sql).diagnostics, name + ':' + query.id).toEqual([]);", - ) - path.write_text(text) + lines = path.read_text().splitlines() + result_hits = 0 + filter_hits = 0 + for i, line in enumerate(lines): + if 'expect(result.ok, result.ok ? name :' in line: + lines[i] = " expect(result.ok, result.ok ? name : name + ': ' + result.diagnostics.map((d) => d.message).join('; ')).toBe(true);" + result_hits += 1 + elif 'expect(filterExecution(query.sql).diagnostics,' in line: + lines[i] = " expect(filterExecution(query.sql).diagnostics, name + ':' + query.id).toEqual([]);" + filter_hits += 1 + if result_hits != 1 or filter_hits != 1: + raise SystemExit(f'expected one result and filter patch, got {result_hits} and {filter_hits}') + path.write_text('\n'.join(lines) + '\n') PY - name: Rewrite generators and tests run: | From af37813fe5d1d7b8c99c28bf90d9809ce7bd328d Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:51:07 +0200 Subject: [PATCH 18/18] ci(examples): remove one-time migration workflow --- .github/workflows/rework-examples.yml | 104 -------------------------- 1 file changed, 104 deletions(-) delete mode 100644 .github/workflows/rework-examples.yml diff --git a/.github/workflows/rework-examples.yml b/.github/workflows/rework-examples.yml deleted file mode 100644 index ec556425..00000000 --- a/.github/workflows/rework-examples.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: Rework examples - -on: - push: - branches: [examples-rework-bootstrap] - pull_request: - branches: [main] - -permissions: - contents: write - -jobs: - migrate-and-verify: - if: ${{ github.event_name == 'pull_request' || !contains(github.event.head_commit.message, '[examples migrated]') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: examples-rework-bootstrap - fetch-depth: 0 - - uses: actions/setup-node@v7 - with: - node-version: 22 - cache: npm - - run: npm ci --no-audit --no-fund - - name: Patch one-time generator rewrite - run: | - python3 - <<'PY' - from pathlib import Path - path = Path('examples/mjs/rewrite-example-generators.mjs') - lines = path.read_text().splitlines() - result_hits = 0 - filter_hits = 0 - for i, line in enumerate(lines): - if 'expect(result.ok, result.ok ? name :' in line: - lines[i] = " expect(result.ok, result.ok ? name : name + ': ' + result.diagnostics.map((d) => d.message).join('; ')).toBe(true);" - result_hits += 1 - elif 'expect(filterExecution(query.sql).diagnostics,' in line: - lines[i] = " expect(filterExecution(query.sql).diagnostics, name + ':' + query.id).toEqual([]);" - filter_hits += 1 - if result_hits != 1 or filter_hits != 1: - raise SystemExit(f'expected one result and filter patch, got {result_hits} and {filter_hits}') - path.write_text('\n'.join(lines) + '\n') - PY - - name: Rewrite generators and tests - run: | - if ! node examples/mjs/rewrite-example-generators.mjs > /tmp/rewrite.log 2>&1; then - cat /tmp/rewrite.log - exit 1 - fi - - name: Patch generated test regex - run: | - python3 - <<'PY' - from pathlib import Path - path = Path('tests/unit/spec-examples.test.js') - lines = path.read_text().splitlines() - start = next((i for i, line in enumerate(lines) if 'const snippets = [...source.matchAll(' in line), None) - if start is None: - raise SystemExit('generated markdown regex was not found') - end = start - while end < len(lines) and 'JSON.parse(match[1]));' not in lines[end]: - end += 1 - if end >= len(lines): - raise SystemExit('generated markdown regex end was not found') - replacement = r" const snippets = [...source.matchAll(/```json\n([\s\S]*?)```/g)].map((match) => JSON.parse(match[1]));" - lines[start:end + 1] = [replacement] - path.write_text('\n'.join(lines) + '\n') - PY - - name: Upload rewrite diagnostics - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: rewrite-diagnostics - path: /tmp/rewrite.log - - name: Normalize checked-in examples - run: node examples/mjs/normalize-examples.mjs - - name: Verify normalization - run: node examples/mjs/normalize-examples.mjs --check - - name: Test - run: | - if ! npm test > /tmp/test.log 2>&1; then - cat /tmp/test.log - exit 1 - fi - - name: Upload test diagnostics - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: test-diagnostics - path: /tmp/test.log - - name: Typecheck - run: npx tsc --noEmit - - name: Boundaries - run: npm run check:arch - - name: Build - run: npm run build - - name: Commit migrated examples - if: ${{ success() }} - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add examples tests/unit/spec-examples.test.js - git commit -m "docs(examples): migrate to portable dashboard bundles [examples migrated]" - git push origin HEAD:examples-rework-bootstrap