diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ec4322..996750e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,12 @@ auto-generated per-PR notes; this file is the curated, human-readable history. flashes its chevron shut and back open (`src/ui/schema.js`). ### Added +- **KPI panels now render one-row scalar and named-tuple results** (#154) in + both the workbench and Dashboard through one shared reader and renderer. + The canonical Presentation Spec supports exact-name field metadata, nested + delta display semantics, units, rounding, colors, NULL text, and visibility; + explicit KPI queries own typed progress streaming and reject authored + trailing `FORMAT` clauses before sending a request. - **The saved-query Spec editor now provides complete schema-driven native CodeMirror autocomplete** (#221). Root/nested properties, discriminated panel branches, constants, enums, booleans, nullable values, defaults, examples, diff --git a/README.md b/README.md index 37b2acc6..a575f99d 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,33 @@ saved-query and Library envelopes plus the offline schema bundle. Its toolbar is **Save**, and the **SQL | Spec** switch. Blocking errors disable Save and are never persisted; unknown fields remain valid and survive Save. +The implemented **KPI** panel turns an exactly-one-row result into responsive +cards: numeric scalar columns become simple KPIs, while named ClickHouse +`Tuple(value numeric, delta Nullable(numeric))` columns add an optional delta. +SQL owns the values; `panel.fieldConfig` owns labels, descriptions, units, +rounding, colors, NULL text, visibility, and delta semantics. The complete +[`kpi-panel.json`](examples/kpi-panel.json) Library example can be opened from +**File ▾ → Open** and renders identically in the workbench and Dashboard. +When constructing a named tuple from expressions, either enable alias-derived +member names for the query: + +```sql +SELECT (99.95 AS value, 0.08 AS delta) AS availability +SETTINGS enable_named_columns_in_function_tuple = 1 +``` + +or cast an ordinary tuple to an explicitly named type: + +```sql +SELECT CAST( + (99.95, 0.08), + 'Tuple(value Float64, delta Float64)' +) AS availability +``` + +Without the setting or cast, ClickHouse reports `Tuple(Float64, Float64)`, +which is positional and intentionally ineligible for KPI value/delta roles. + Panel controls and Library favorite/pencil edits merge their fields into valid open Spec drafts, preserving unrelated unsaved and extension fields. Syntax or schema/feature errors block the staged writer before any draft or Library entry diff --git a/docs/drafts/query-presentation-spec-next.schema.json b/docs/drafts/query-presentation-spec-next.schema.json index 6342af3c..9aff0b8d 100644 --- a/docs/drafts/query-presentation-spec-next.schema.json +++ b/docs/drafts/query-presentation-spec-next.schema.json @@ -692,15 +692,6 @@ "properties": { "type": { "const": "kpi" - }, - "layout": { - "type": "string", - "enum": [ - "auto", - "row", - "grid" - ], - "default": "auto" } }, "additionalProperties": true, diff --git a/docs/drafts/visualization-spec-authoring-guide.md b/docs/drafts/visualization-spec-authoring-guide.md index 12116703..4d994a26 100644 --- a/docs/drafts/visualization-spec-authoring-guide.md +++ b/docs/drafts/visualization-spec-authoring-guide.md @@ -159,6 +159,7 @@ SELECT 12.4 AS value, -1.7 AS delta ) AS cancellation_rate +SETTINGS enable_named_columns_in_function_tuple = 1 ``` The top-level column is `cancellation_rate`. Its runtime object is: @@ -170,12 +171,27 @@ The top-level column is `cancellation_rate`. Its runtime object is: } ``` -ClickHouse 24.7+ supports constructing a named tuple by aliasing tuple elements: +ClickHouse can construct a named tuple from aliased tuple elements when the +query enables named columns for the `tuple` function: ```sql (expr AS member_name, expr AS another_member) AS result_column +SETTINGS enable_named_columns_in_function_tuple = 1 ``` +Alternatively, cast a positional tuple to an explicitly named tuple type: + +```sql +CAST( + (expr, another_expr), + 'Tuple(member_name Float64, another_member Float64)' +) AS result_column +``` + +Aliasing tuple elements without the setting does not establish the result type +contract: ClickHouse reports a positional type such as +`Tuple(Float64, Float64)`. + Only **named** tuples are used as visual-object contracts. Positional tuples such as `(12.4, -1.7)` are ambiguous and MUST NOT be interpreted by member position. ### 3.4 One-row versus row-oriented panels @@ -527,6 +543,7 @@ SELECT 87.2 AS value, 2.3 AS delta ) AS on_time_rate +SETTINGS enable_named_columns_in_function_tuple = 1 ``` ### Spec diff --git a/examples/kpi-panel.json b/examples/kpi-panel.json new file mode 100644 index 00000000..5e5f15c9 --- /dev/null +++ b/examples/kpi-panel.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json", + "format": "altinity-sql-browser/saved-queries", + "version": 2, + "exportedAt": "2026-07-14T00:00:00.000Z", + "queries": [ + { + "id": "kpi-service-health", + "sql": "SELECT count() AS active_users, (99.95 AS value, 0.08 AS delta) AS availability SETTINGS enable_named_columns_in_function_tuple = 1", + "specVersion": 1, + "spec": { + "name": "Service KPIs", + "description": "Scalar and named-tuple KPI cards from one SQL row.", + "favorite": true, + "view": "panel", + "panel": { + "cfg": { "type": "kpi" }, + "fieldConfig": { + "defaults": { "noValue": "—" }, + "columns": { + "active_users": { "displayName": "Active users", "color": "#4f8cff" }, + "availability": { + "displayName": "Availability", + "description": "Current service availability.", + "unit": "%", + "decimals": 2, + "delta": { "unit": " pp", "decimals": 2, "positiveIsGood": true } + } + } + } + } + } + } + ] +} diff --git a/schemas/generated/library-v2.bundle.schema.json b/schemas/generated/library-v2.bundle.schema.json index ee8ce2ac..eda22ba7 100644 --- a/schemas/generated/library-v2.bundle.schema.json +++ b/schemas/generated/library-v2.bundle.schema.json @@ -79,9 +79,56 @@ "source": "resultColumnIndexes" } }, + "deltaPresentation": { + "title": "Delta presentation", + "description": "Display metadata for a runtime KPI delta value.", + "type": "object", + "properties": { + "displayName": { + "title": "Delta label", + "description": "Optional visible label for the delta.", + "type": "string" + }, + "unit": { + "title": "Delta unit", + "description": "Display-only suffix appended to the delta.", + "type": "string" + }, + "decimals": { + "title": "Delta decimal places", + "description": "Requested display rounding for the delta.", + "type": "integer", + "minimum": 0, + "maximum": 20, + "default": 0, + "examples": [ + 1 + ] + }, + "positiveIsGood": { + "title": "Positive is good", + "description": "Whether a positive runtime delta has good semantics.", + "type": "boolean" + }, + "show": { + "title": "Show delta", + "description": "Whether a present runtime delta is rendered.", + "type": "boolean", + "default": true + } + }, + "additionalProperties": true, + "x-altinity-order": [ + "displayName", + "unit", + "decimals", + "positiveIsGood", + "show" + ] + }, "fieldConfigValue": { - "title": "Field display configuration", - "description": "Known display metadata for one result column. Unknown renderer extensions are retained.", + "title": "Field presentation metadata", + "description": "Known presentation metadata for one result column. Unknown renderer extensions are retained.", "type": "object", "properties": { "displayName": { @@ -93,13 +140,57 @@ "title": "Decimal places", "description": "Requested number of decimal places for numeric display.", "type": "integer", - "default": 0 + "minimum": 0, + "maximum": 20, + "default": 0, + "examples": [ + 2 + ] + }, + "description": { + "title": "Description", + "description": "Supporting display text for the field.", + "type": "string" + }, + "unit": { + "title": "Unit", + "description": "Display-only suffix appended to the value.", + "type": "string", + "examples": [ + "%" + ] + }, + "color": { + "title": "Color", + "description": "Theme token or CSS color hint interpreted by the renderer.", + "type": "string" + }, + "noValue": { + "title": "No-value text", + "description": "Text shown for NULL or unavailable values.", + "type": "string", + "default": "—" + }, + "hidden": { + "title": "Hidden", + "description": "Suppress this otherwise eligible result field.", + "type": "boolean", + "default": false + }, + "delta": { + "$ref": "#/$defs/deltaPresentation" } }, "additionalProperties": true, "x-altinity-order": [ "displayName", - "decimals" + "description", + "unit", + "decimals", + "color", + "noValue", + "hidden", + "delta" ] }, "fieldConfig": { @@ -388,6 +479,26 @@ } ] }, + { + "title": "KPI", + "description": "One-row scalar and named-tuple KPI cards.", + "x-altinity-status": "implemented", + "x-altinity-snippet": { + "type": "kpi" + }, + "properties": { + "type": { + "const": "kpi" + } + }, + "required": [ + "type" + ], + "additionalProperties": true, + "x-altinity-order": [ + "type" + ] + }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", @@ -486,6 +597,7 @@ "line", "area", "pie", + "kpi", "table", "logs", "text" diff --git a/schemas/query-spec-v1.schema.json b/schemas/query-spec-v1.schema.json index 2edbc52e..09f6160b 100644 --- a/schemas/query-spec-v1.schema.json +++ b/schemas/query-spec-v1.schema.json @@ -53,9 +53,23 @@ "minimum": 0, "x-altinity-completion": { "source": "resultColumnIndexes" } }, + "deltaPresentation": { + "title": "Delta presentation", + "description": "Display metadata for a runtime KPI delta value.", + "type": "object", + "properties": { + "displayName": { "title": "Delta label", "description": "Optional visible label for the delta.", "type": "string" }, + "unit": { "title": "Delta unit", "description": "Display-only suffix appended to the delta.", "type": "string" }, + "decimals": { "title": "Delta decimal places", "description": "Requested display rounding for the delta.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [1] }, + "positiveIsGood": { "title": "Positive is good", "description": "Whether a positive runtime delta has good semantics.", "type": "boolean" }, + "show": { "title": "Show delta", "description": "Whether a present runtime delta is rendered.", "type": "boolean", "default": true } + }, + "additionalProperties": true, + "x-altinity-order": ["displayName", "unit", "decimals", "positiveIsGood", "show"] + }, "fieldConfigValue": { - "title": "Field display configuration", - "description": "Known display metadata for one result column. Unknown renderer extensions are retained.", + "title": "Field presentation metadata", + "description": "Known presentation metadata for one result column. Unknown renderer extensions are retained.", "type": "object", "properties": { "displayName": { @@ -67,11 +81,20 @@ "title": "Decimal places", "description": "Requested number of decimal places for numeric display.", "type": "integer", - "default": 0 - } + "minimum": 0, + "maximum": 20, + "default": 0, + "examples": [2] + }, + "description": { "title": "Description", "description": "Supporting display text for the field.", "type": "string" }, + "unit": { "title": "Unit", "description": "Display-only suffix appended to the value.", "type": "string", "examples": ["%"] }, + "color": { "title": "Color", "description": "Theme token or CSS color hint interpreted by the renderer.", "type": "string" }, + "noValue": { "title": "No-value text", "description": "Text shown for NULL or unavailable values.", "type": "string", "default": "—" }, + "hidden": { "title": "Hidden", "description": "Suppress this otherwise eligible result field.", "type": "boolean", "default": false }, + "delta": { "$ref": "#/$defs/deltaPresentation" } }, "additionalProperties": true, - "x-altinity-order": ["displayName", "decimals"] + "x-altinity-order": ["displayName", "description", "unit", "decimals", "color", "noValue", "hidden", "delta"] }, "fieldConfig": { "title": "Panel field configuration", @@ -222,6 +245,16 @@ } ] }, + { + "title": "KPI", + "description": "One-row scalar and named-tuple KPI cards.", + "x-altinity-status": "implemented", + "x-altinity-snippet": { "type": "kpi" }, + "properties": { "type": { "const": "kpi" } }, + "required": ["type"], + "additionalProperties": true, + "x-altinity-order": ["type"] + }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", @@ -273,7 +306,7 @@ "type": { "type": "string", "minLength": 1, - "not": { "enum": ["bar", "hbar", "line", "area", "pie", "table", "logs", "text"] } + "not": { "enum": ["bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text"] } } }, "required": ["type"], diff --git a/src/core/format.js b/src/core/format.js index 9653eaad..55ac1565 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -126,8 +126,30 @@ export function withStatementBreak(sql) { * or without a following `SETTINGS …`). Pure. */ export function detectSqlFormat(sql) { - const m = /\bFORMAT\s+([A-Za-z][A-Za-z0-9]*)\b(?:\s+SETTINGS\b[\s\S]*)?\s*;?\s*$/i.exec(String(sql || '')); - return m ? m[1] : null; + const text = String(sql || ''); + const words = []; + let depth = 0; + for (const span of scanSpans(text)) { + if (span.kind !== 'code') continue; + const code = text.slice(span.start, span.end); + for (let i = 0; i < code.length;) { + const ch = code[i]; + if (ch === '(') { depth++; i++; continue; } + if (ch === ')') { depth = Math.max(0, depth - 1); i++; continue; } + if (depth === 0 && /[A-Za-z_]/.test(ch)) { + let end = i + 1; + while (end < code.length && /[A-Za-z0-9_]/.test(code[end])) end++; + words.push(code.slice(i, end)); i = end; continue; + } + i++; + } + } + for (let i = words.length - 2; i >= 0; i--) { + if (words[i].toUpperCase() !== 'FORMAT') continue; + const rest = words.slice(i + 2); + if (rest.length === 0 || rest[0].toUpperCase() === 'SETTINGS') return words[i + 1]; + } + return null; } /** diff --git a/src/core/kpi.js b/src/core/kpi.js new file mode 100644 index 00000000..4dda8c42 --- /dev/null +++ b/src/core/kpi.js @@ -0,0 +1,207 @@ +// Pure KPI result normalization and display formatting. SQL owns every runtime +// value; the saved-query Presentation Spec contributes display metadata only. + +import { cloneJson, isPlainObject } from './saved-query.js'; + +const NUMERIC = /^(?:U?Int(?:8|16|32|64|128|256)|Float(?:32|64)|BFloat16|Decimal(?:32|64|128|256)?\s*\()/; + +function unwrapNullable(type) { + let value = String(type || '').trim(); + let nullable = false; + while (/^Nullable\s*\(/.test(value) && value.endsWith(')')) { + nullable = true; + value = value.slice(value.indexOf('(') + 1, -1).trim(); + } + return { type: value, nullable }; +} + +export function isKpiNumericType(type) { + return NUMERIC.test(unwrapNullable(type).type); +} + +function splitTopLevel(text) { + const parts = []; + let depth = 0; let quote = ''; let start = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quote) { + if (ch === quote && text[i - 1] !== '\\') quote = ''; + } else if (ch === '`' || ch === '"' || ch === "'") quote = ch; + else if (ch === '(') depth++; + else if (ch === ')') depth--; + else if (ch === ',' && depth === 0) { parts.push(text.slice(start, i).trim()); start = i + 1; } + } + parts.push(text.slice(start).trim()); + return parts.filter(Boolean); +} + +function tupleMember(part) { + let depth = 0; let quote = ''; + for (let i = 0; i < part.length; i++) { + const ch = part[i]; + if (quote) { + if (ch === quote && part[i - 1] !== '\\') quote = ''; + } else if (ch === '`' || ch === '"' || ch === "'") quote = ch; + else if (ch === '(') depth++; + else if (ch === ')') depth--; + else if (/\s/.test(ch) && depth === 0) { + const rawName = part.slice(0, i).trim(); + const type = part.slice(i).trim(); + const name = /^([`"']).*\1$/.test(rawName) ? rawName.slice(1, -1) : rawName; + return name && type ? { name, type } : null; + } + } + return null; +} + +export function parseKpiTupleType(type) { + const unwrapped = unwrapNullable(type).type; + if (!/^Tuple\s*\(/.test(unwrapped) || !unwrapped.endsWith(')')) return null; + const body = unwrapped.slice(unwrapped.indexOf('(') + 1, -1); + const members = splitTopLevel(body).map(tupleMember); + if (!members.length || members.some((member) => member == null)) return null; + return members; +} + +export function resolveKpiPresentation({ fieldConfig, columnName }) { + const config = isPlainObject(fieldConfig) ? fieldConfig : {}; + const defaults = isPlainObject(config.defaults) ? cloneJson(config.defaults) : {}; + const columns = isPlainObject(config.columns) ? config.columns : {}; + const column = isPlainObject(columns[columnName]) ? cloneJson(columns[columnName]) : {}; + const delta = { + ...(isPlainObject(defaults.delta) ? defaults.delta : {}), + ...(isPlainObject(column.delta) ? column.delta : {}), + }; + const presentation = { ...defaults, ...column, delta }; + presentation.displayName = typeof presentation.displayName === 'string' ? presentation.displayName : columnName; + presentation.noValue = typeof presentation.noValue === 'string' ? presentation.noValue : '—'; + return presentation; +} + +function numericValue(value) { + if (typeof value === 'bigint') return Number(value); + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function trimFixed(value, places) { + return value.toFixed(places).replace(/(?:\.0+|(\.\d*?)0+)$/, '$1'); +} + +function decimalString(value, places, trim) { + const match = /^([+-]?)(\d+)(?:\.(\d*))?$/.exec(String(value).trim()); + if (!match) return null; + const fraction = match[3] || ''; + const kept = fraction.padEnd(places + 1, '0'); + const digits = match[2] + kept.slice(0, places); + let scaled = BigInt(digits || '0'); + if (kept[places] >= '5') scaled += 1n; + const base = 10n ** BigInt(places); + const whole = scaled / base; + const remainder = places ? String(scaled % base).padStart(places, '0') : ''; + const sign = match[1] === '-' && scaled !== 0n ? '-' : ''; + const rendered = sign + whole + (places ? '.' + remainder : ''); + return trim ? rendered.replace(/(?:\.0+|(\.\d*?)0+)$/, '$1') : rendered; +} + +function compactInteger(value) { + const integer = typeof value === 'bigint' ? value : BigInt(String(value).trim()); + const negative = integer < 0n; + const absolute = negative ? -integer : integer; + if (absolute < 1000n) return String(integer); + const bands = [[1_000_000_000n, 'B'], [1_000_000n, 'M'], [1000n, 'K']]; + let bandIndex = bands.findIndex(([limit]) => absolute >= limit); + let [size, suffix] = bands[bandIndex]; + let places = absolute < size * 10n ? 1 : 0; + let scale = places ? 10n : 1n; + let rounded = (absolute * scale + size / 2n) / size; + if (rounded >= 1000n * scale && bandIndex > 0) { + [size, suffix] = bands[--bandIndex]; + places = absolute < size * 10n ? 1 : 0; + scale = places ? 10n : 1n; + rounded = (absolute * scale + size / 2n) / size; + } + const whole = rounded / scale; + const fraction = places && rounded % scale ? '.' + (rounded % scale) : ''; + return (negative ? '-' : '') + whole + fraction + suffix; +} + +export function formatKpiValue({ value, clickhouseType, presentation = {} }) { + if (value == null) return presentation.noValue ?? '—'; + const type = unwrapNullable(clickhouseType).type; + const explicit = Number.isInteger(presentation.decimals) ? presentation.decimals : null; + let rendered; + const integerString = /^(?:U?Int)/.test(type) && (typeof value === 'bigint' || /^[+-]?\d+$/.test(String(value).trim())); + const exactDecimal = typeof value === 'string' && /^[+-]?\d+(?:\.\d*)?$/.test(value.trim()); + if (integerString && explicit != null) rendered = decimalString(value, explicit, false); + else if (integerString) rendered = compactInteger(value); + else if (exactDecimal) rendered = decimalString(value, explicit ?? 2, explicit == null); + else { + const number = numericValue(value); + if (number == null) return presentation.noValue ?? '—'; + const fixed = explicit != null ? number.toFixed(explicit) : trimFixed(number, 2); + rendered = /^-0(?:\.0+)?$/.test(fixed) ? fixed.slice(1) : fixed; + } + return rendered + (typeof presentation.unit === 'string' ? presentation.unit : ''); +} + +const diagnostic = (severity, code, message, columnName) => ({ + severity, code, message, ...(columnName == null ? {} : { columnName }), +}); + +export function readKpiFields({ columns = [], row, rowCount = row ? 1 : 0, fieldConfig = {}, serverVersion } = {}) { + if (rowCount === 0) return { items: [], diagnostics: [diagnostic('info', 'kpi-no-data', 'No data')] }; + if (rowCount !== 1) return { items: [], diagnostics: [diagnostic('error', 'kpi-row-count', `Expected 1 row, got ${rowCount}`)] }; + const diagnostics = []; + const items = []; + const names = new Set(columns.map((column) => column.name)); + const metadataColumns = isPlainObject(fieldConfig) && isPlainObject(fieldConfig.columns) ? fieldConfig.columns : {}; + for (const name of Object.keys(metadataColumns)) { + if (!names.has(name)) diagnostics.push(diagnostic('warning', 'kpi-missing-field-metadata-target', `Field metadata targets missing column ${name}`, name)); + } + columns.forEach((column, columnIndex) => { + const presentation = resolveKpiPresentation({ fieldConfig, columnName: column.name }); + if (presentation.hidden === true) return; + const value = Array.isArray(row) ? row[columnIndex] : row?.[column.name]; + const members = parseKpiTupleType(column.type); + if (members) { + if (value != null && !isPlainObject(value)) { + const suffix = serverVersion ? ` by ClickHouse ${serverVersion}` : ''; + diagnostics.push(diagnostic('warning', 'kpi-server-named-tuple-unsupported', `Column ${column.name} was not returned as a named tuple object${suffix}`, column.name)); + return; + } + const valueMember = members.find((member) => member.name === 'value'); + const deltaMember = members.find((member) => member.name === 'delta'); + if (!valueMember) { diagnostics.push(diagnostic('warning', 'kpi-missing-tuple-value', `Column ${column.name} has no value tuple member`, column.name)); return; } + if (!isKpiNumericType(valueMember.type)) { diagnostics.push(diagnostic('warning', 'kpi-nonnumeric-tuple-value', `Column ${column.name} has non-numeric value type ${valueMember.type}`, column.name)); return; } + let delta = null; let deltaType = null; + if (deltaMember && !isKpiNumericType(deltaMember.type)) diagnostics.push(diagnostic('warning', 'kpi-nonnumeric-delta', `Column ${column.name} has non-numeric delta type ${deltaMember.type}`, column.name)); + else if (deltaMember) { delta = value?.delta ?? null; deltaType = deltaMember.type; } + items.push({ columnName: column.name, columnIndex, sourceType: column.type, kind: 'tuple', value: value?.value ?? null, valueType: valueMember.type, delta, deltaType, presentation }); + return; + } + if (!isKpiNumericType(column.type)) { + diagnostics.push(diagnostic('warning', 'kpi-unsupported-field', `Column ${column.name} has unsupported KPI type ${column.type}`, column.name)); + return; + } + items.push({ columnName: column.name, columnIndex, sourceType: column.type, kind: 'scalar', value, valueType: column.type, delta: null, deltaType: null, presentation }); + }); + if (!items.length) diagnostics.push(diagnostic('error', 'kpi-no-eligible-fields', 'No eligible KPI fields in this result')); + return { items, diagnostics }; +} + +export function kpiDeltaState(item) { + if (item.delta == null || item.presentation.delta?.show === false) return null; + const numeric = numericValue(item.delta); + if (numeric == null) return null; + const direction = numeric > 0 ? 'up' : numeric < 0 ? 'down' : 'flat'; + const positiveIsGood = item.presentation.delta?.positiveIsGood; + const semantic = positiveIsGood == null || direction === 'flat' + ? 'neutral' + : (numeric > 0) === positiveIsGood ? 'good' : 'bad'; + return { value: item.delta, direction, semantic }; +} diff --git a/src/core/panel-cfg.js b/src/core/panel-cfg.js index eb339e23..6e8232ab 100644 --- a/src/core/panel-cfg.js +++ b/src/core/panel-cfg.js @@ -18,12 +18,13 @@ import { autoChart, chartCfgValid, normalizeChartCfg, schemaKey, CHART_TYPES } f import { detectLogsView, findTimeColumn, findMsgColumn, findLevelColumn } from './logs.js'; import { cloneJson } from './saved-query.js'; import { querySpecSchemaService } from './spec-schema.js'; +import { readKpiFields } from './kpi.js'; /** The chart-family type ids (share the chart-data cfg shape + `panel.key`). */ export const CHART_FAMILY = new Set(CHART_TYPES.map((t) => t.value)); /** Every v1 panel type id, in picker order (chart family first). */ -export const PANEL_TYPE_IDS = [...CHART_FAMILY, 'table', 'logs', 'text']; +export const PANEL_TYPE_IDS = ['kpi', ...CHART_FAMILY, 'table', 'logs', 'text']; const KNOWN_TYPES = new Set(PANEL_TYPE_IDS); @@ -107,7 +108,7 @@ export function panelCfgValid(cfg, columns, schemaService = querySpecSchemaServi if (!panelCfgStaticValid(cfg, schemaService)) return false; if (isChartFamily(cfg.type)) return chartCfgValid(cfg, columns); if (cfg.type === 'logs') return resolveLogsShape(cfg, columns) != null; - return cfg.type === 'table' || cfg.type === 'text'; + return cfg.type === 'kpi' || cfg.type === 'table' || cfg.type === 'text'; } /** @@ -142,9 +143,31 @@ function rederiveChart(type, columns) { * auto-proposed — they exist only as explicit choices. Returns * `{ cfg, shape? }`; never null (table is the universal fallback). */ -export function autoPanel(columns) { +function resultContext(input) { + if (Array.isArray(input)) return { columns: input, rows: null, rowCount: null, fieldConfig: {} }; + const value = input && typeof input === 'object' ? input : {}; + const rows = Array.isArray(value.rows) ? value.rows : null; + return { + columns: Array.isArray(value.columns) ? value.columns : [], + rows, + rowCount: Number.isInteger(value.rowCount) ? value.rowCount : rows ? rows.length : null, + fieldConfig: value.fieldConfig || {}, + serverVersion: value.serverVersion, + }; +} + +export function autoPanel(input) { + const context = resultContext(input); + const { columns } = context; const shape = detectLogsView(columns); if (shape) return { cfg: { type: 'logs' }, shape }; + if (context.rowCount === 1) { + const kpi = readKpiFields({ + columns, row: context.rows && context.rows[0], rowCount: 1, + fieldConfig: context.fieldConfig, serverVersion: context.serverVersion, + }); + if (kpi.items.length) return { cfg: { type: 'kpi' }, kpi }; + } const chart = autoChart(columns); if (chart) return { cfg: chart }; return { cfg: { type: 'table' } }; @@ -204,14 +227,23 @@ export function switchPanelType(payload, type, columns) { * * Returns { cfg, shape?, rederived, fallback, diagnostic? }. */ -export function resolvePanel(saved, columns) { +export function resolvePanel(saved, input) { + const context = resultContext(input); + const { columns } = context; const savedCfg = saved && saved.cfg && typeof saved.cfg === 'object' ? saved.cfg : null; - const fallbackTo = (diagnostic) => ({ ...autoPanel(columns), rederived: false, fallback: true, diagnostic }); - if (!savedCfg) return { ...autoPanel(columns), rederived: false, fallback: false }; + const fallbackTo = (diagnostic) => ({ ...autoPanel(context), rederived: false, fallback: true, diagnostic }); + if (!savedCfg) return { ...autoPanel(context), rederived: false, fallback: false }; if (!panelCfgStaticValid(savedCfg)) { return fallbackTo('Saved panel has invalid static configuration.'); } const cfg = normalizePanelCfg(clonePanelCfg(savedCfg)); + if (cfg.type === 'kpi') { + const kpi = context.rowCount == null ? null : readKpiFields({ + columns, row: context.rows && context.rows[0], rowCount: context.rowCount, + fieldConfig: saved.fieldConfig || context.fieldConfig, serverVersion: context.serverVersion, + }); + return { cfg, kpi, rederived: false, fallback: false }; + } if (isChartFamily(cfg.type)) { // An explicit key mismatch means the column positions no longer carry the // saved roles, even if every old index remains in range (columns may have diff --git a/src/core/panel-execution.js b/src/core/panel-execution.js new file mode 100644 index 00000000..d2f0b038 --- /dev/null +++ b/src/core/panel-execution.js @@ -0,0 +1,31 @@ +import { detectSqlFormat } from './format.js'; + +export function isKpiPanel(panel) { + return panel?.cfg?.type === 'kpi'; +} + +/** Resolve the transport owned by an explicit panel without changing SQL. */ +export function panelExecution(panel, sql, defaults = {}) { + if (!isKpiPanel(panel)) return { ...defaults, owned: false, error: null, params: { ...(defaults.params || {}) } }; + const authoredFormat = detectSqlFormat(sql); + if (authoredFormat) { + return { + ...defaults, + owned: true, + error: `KPI panel owns the result format. Remove FORMAT ${authoredFormat} from the SQL.`, + params: { ...(defaults.params || {}) }, + }; + } + return { + ...defaults, + owned: true, + error: null, + format: 'KPI', + rowLimit: 2, + params: { + ...(defaults.params || {}), + output_format_json_named_tuples_as_objects: 1, + output_format_json_quote_decimals: 1, + }, + }; +} diff --git a/src/generated/json-schema-validators.js b/src/generated/json-schema-validators.js index 458d15a6..76b9d698 100644 --- a/src/generated/json-schema-validators.js +++ b/src/generated/json-schema-validators.js @@ -285,7 +285,7 @@ var require_formats = __commonJS({ // json-schema-standalone.js var validateQuerySpecV1 = validate20; -var schema31 = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://altinity.com/schemas/altinity-sql-browser/query-spec-v1.schema.json", "title": "Altinity SQL Browser saved-query Spec v1", "description": "The user-authored query.spec document. Saved-query envelope fields are intentionally outside this schema.", "x-altinity-kind": "query-spec", "x-altinity-version": 1, "type": "object", "properties": { "name": { "title": "Name", "description": "Panel, tile, and Library title.", "type": "string", "minLength": 1, "pattern": "\\S", "examples": ["Revenue by country"] }, "description": { "title": "Description", "description": "Optional authoring note shown with the saved query.", "type": "string" }, "favorite": { "title": "Favorite", "description": "Whether the query is included in favorite-driven surfaces.", "type": "boolean", "default": false }, "view": { "title": "Preferred result view", "description": "The result representation restored when the saved query opens.", "type": "string", "enum": ["table", "json", "panel"], "default": "table" }, "panel": { "$ref": "#/$defs/panel" }, "dashboard": { "$ref": "#/$defs/dashboard" } }, "additionalProperties": true, "x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard"], "$defs": { "columnName": { "title": "Result column", "description": "Exact top-level ClickHouse result-column name.", "type": "string", "minLength": 1, "x-altinity-completion": { "source": "resultColumns" } }, "resultColumnIndex": { "title": "Result column index", "description": "Zero-based index of a ClickHouse result column.", "type": "integer", "minimum": 0, "x-altinity-completion": { "source": "resultColumnIndexes" } }, "fieldConfigValue": { "title": "Field display configuration", "description": "Known display metadata for one result column. Unknown renderer extensions are retained.", "type": "object", "properties": { "displayName": { "title": "Display name", "description": "Rendered label for the field.", "type": "string" }, "decimals": { "title": "Decimal places", "description": "Requested number of decimal places for numeric display.", "type": "integer", "default": 0 } }, "additionalProperties": true, "x-altinity-order": ["displayName", "decimals"] }, "fieldConfig": { "title": "Panel field configuration", "description": "Default and per-column display metadata.", "type": "object", "properties": { "defaults": { "$ref": "#/$defs/fieldConfigValue" }, "columns": { "title": "Column overrides", "description": "Display metadata keyed by exact result-column name.", "type": "object", "additionalProperties": { "$ref": "#/$defs/fieldConfigValue" }, "x-altinity-key-completion": { "source": "resultColumns" } } }, "additionalProperties": true, "x-altinity-order": ["defaults", "columns"] }, "dashboard": { "title": "Dashboard configuration", "description": "Dashboard participation metadata. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { "title": "Dashboard role", "description": "How the saved query participates in a dashboard.", "type": "string", "enum": ["panel", "filter", "setup"], "default": "panel" } }, "additionalProperties": true, "x-altinity-order": ["role"] }, "panel": { "title": "Panel configuration", "description": "Visualization and field metadata for the saved query.", "type": "object", "properties": { "cfg": { "$ref": "#/$defs/panelCfg" }, "key": { "title": "Result schema key", "description": "Saved result-column signature used to detect stale positional roles.", "type": ["string", "null"] }, "fieldConfig": { "$ref": "#/$defs/fieldConfig" } }, "additionalProperties": true, "x-altinity-order": ["cfg", "key", "fieldConfig"] }, "chartCfg": { "type": "object", "properties": { "x": { "$ref": "#/$defs/resultColumnIndex", "default": 0 }, "y": { "title": "Measure columns", "description": "One or more zero-based result-column indexes used as measures.", "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/resultColumnIndex" } }, "series": { "title": "Series column", "description": "Optional zero-based result-column index used to split series.", "oneOf": [{ "$ref": "#/$defs/resultColumnIndex" }, { "type": "null" }], "default": null, "x-altinity-completion": { "source": "resultColumnIndexes" } } }, "required": ["x", "y"], "additionalProperties": true, "x-altinity-order": ["type", "x", "y", "series"] }, "panelCfg": { "title": "Panel type configuration", "description": "Discriminated visualization configuration. Unknown types remain storable for forward compatibility.", "type": "object", "required": ["type"], "properties": { "type": { "title": "Panel type", "description": "Visualization renderer identifier.", "type": "string", "minLength": 1 } }, "additionalProperties": true, "x-altinity-discriminator": "type", "x-altinity-order": ["type"], "oneOf": [{ "title": "Column chart", "description": "Vertical columns using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "bar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "properties": { "type": { "const": "bar" } }, "required": ["type"] }] }, { "title": "Horizontal bar chart", "description": "Horizontal bars using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "hbar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "properties": { "type": { "const": "hbar" } }, "required": ["type"] }] }, { "title": "Line chart", "description": "Line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "line", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "properties": { "type": { "const": "line" } }, "required": ["type"] }] }, { "title": "Area chart", "description": "Filled line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "area", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "properties": { "type": { "const": "area" } }, "required": ["type"] }] }, { "title": "Pie chart", "description": "Pie slices using one positional measure role.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "pie", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "properties": { "type": { "const": "pie" }, "y": { "type": "array", "maxItems": 1 } }, "required": ["type"] }] }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "table" }, "properties": { "type": { "const": "table" } }, "required": ["type"], "additionalProperties": true }, { "title": "Logs", "description": "Timestamped log messages with optional explicit result-column roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "logs", "time": "event_time", "msg": "message", "level": "level" }, "properties": { "type": { "const": "logs" }, "time": { "$ref": "#/$defs/columnName" }, "msg": { "$ref": "#/$defs/columnName" }, "level": { "$ref": "#/$defs/columnName" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "time", "msg", "level"] }, { "title": "Markdown text", "description": "Safe Markdown content that does not require a SQL result.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "text", "content": "# Heading\n\nMarkdown content." }, "properties": { "type": { "const": "text" }, "content": { "title": "Markdown content", "description": "Source text for the safe Markdown renderer.", "type": "string", "default": "" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "content"] }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", "x-altinity-status": "planned", "x-altinity-snippet": { "type": "future-panel" }, "properties": { "type": { "type": "string", "minLength": 1, "not": { "enum": ["bar", "hbar", "line", "area", "pie", "table", "logs", "text"] } } }, "required": ["type"], "additionalProperties": true }] } } }; +var schema31 = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://altinity.com/schemas/altinity-sql-browser/query-spec-v1.schema.json", "title": "Altinity SQL Browser saved-query Spec v1", "description": "The user-authored query.spec document. Saved-query envelope fields are intentionally outside this schema.", "x-altinity-kind": "query-spec", "x-altinity-version": 1, "type": "object", "properties": { "name": { "title": "Name", "description": "Panel, tile, and Library title.", "type": "string", "minLength": 1, "pattern": "\\S", "examples": ["Revenue by country"] }, "description": { "title": "Description", "description": "Optional authoring note shown with the saved query.", "type": "string" }, "favorite": { "title": "Favorite", "description": "Whether the query is included in favorite-driven surfaces.", "type": "boolean", "default": false }, "view": { "title": "Preferred result view", "description": "The result representation restored when the saved query opens.", "type": "string", "enum": ["table", "json", "panel"], "default": "table" }, "panel": { "$ref": "#/$defs/panel" }, "dashboard": { "$ref": "#/$defs/dashboard" } }, "additionalProperties": true, "x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard"], "$defs": { "columnName": { "title": "Result column", "description": "Exact top-level ClickHouse result-column name.", "type": "string", "minLength": 1, "x-altinity-completion": { "source": "resultColumns" } }, "resultColumnIndex": { "title": "Result column index", "description": "Zero-based index of a ClickHouse result column.", "type": "integer", "minimum": 0, "x-altinity-completion": { "source": "resultColumnIndexes" } }, "deltaPresentation": { "title": "Delta presentation", "description": "Display metadata for a runtime KPI delta value.", "type": "object", "properties": { "displayName": { "title": "Delta label", "description": "Optional visible label for the delta.", "type": "string" }, "unit": { "title": "Delta unit", "description": "Display-only suffix appended to the delta.", "type": "string" }, "decimals": { "title": "Delta decimal places", "description": "Requested display rounding for the delta.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [1] }, "positiveIsGood": { "title": "Positive is good", "description": "Whether a positive runtime delta has good semantics.", "type": "boolean" }, "show": { "title": "Show delta", "description": "Whether a present runtime delta is rendered.", "type": "boolean", "default": true } }, "additionalProperties": true, "x-altinity-order": ["displayName", "unit", "decimals", "positiveIsGood", "show"] }, "fieldConfigValue": { "title": "Field presentation metadata", "description": "Known presentation metadata for one result column. Unknown renderer extensions are retained.", "type": "object", "properties": { "displayName": { "title": "Display name", "description": "Rendered label for the field.", "type": "string" }, "decimals": { "title": "Decimal places", "description": "Requested number of decimal places for numeric display.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [2] }, "description": { "title": "Description", "description": "Supporting display text for the field.", "type": "string" }, "unit": { "title": "Unit", "description": "Display-only suffix appended to the value.", "type": "string", "examples": ["%"] }, "color": { "title": "Color", "description": "Theme token or CSS color hint interpreted by the renderer.", "type": "string" }, "noValue": { "title": "No-value text", "description": "Text shown for NULL or unavailable values.", "type": "string", "default": "\u2014" }, "hidden": { "title": "Hidden", "description": "Suppress this otherwise eligible result field.", "type": "boolean", "default": false }, "delta": { "$ref": "#/$defs/deltaPresentation" } }, "additionalProperties": true, "x-altinity-order": ["displayName", "description", "unit", "decimals", "color", "noValue", "hidden", "delta"] }, "fieldConfig": { "title": "Panel field configuration", "description": "Default and per-column display metadata.", "type": "object", "properties": { "defaults": { "$ref": "#/$defs/fieldConfigValue" }, "columns": { "title": "Column overrides", "description": "Display metadata keyed by exact result-column name.", "type": "object", "additionalProperties": { "$ref": "#/$defs/fieldConfigValue" }, "x-altinity-key-completion": { "source": "resultColumns" } } }, "additionalProperties": true, "x-altinity-order": ["defaults", "columns"] }, "dashboard": { "title": "Dashboard configuration", "description": "Dashboard participation metadata. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { "title": "Dashboard role", "description": "How the saved query participates in a dashboard.", "type": "string", "enum": ["panel", "filter", "setup"], "default": "panel" } }, "additionalProperties": true, "x-altinity-order": ["role"] }, "panel": { "title": "Panel configuration", "description": "Visualization and field metadata for the saved query.", "type": "object", "properties": { "cfg": { "$ref": "#/$defs/panelCfg" }, "key": { "title": "Result schema key", "description": "Saved result-column signature used to detect stale positional roles.", "type": ["string", "null"] }, "fieldConfig": { "$ref": "#/$defs/fieldConfig" } }, "additionalProperties": true, "x-altinity-order": ["cfg", "key", "fieldConfig"] }, "chartCfg": { "type": "object", "properties": { "x": { "$ref": "#/$defs/resultColumnIndex", "default": 0 }, "y": { "title": "Measure columns", "description": "One or more zero-based result-column indexes used as measures.", "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/resultColumnIndex" } }, "series": { "title": "Series column", "description": "Optional zero-based result-column index used to split series.", "oneOf": [{ "$ref": "#/$defs/resultColumnIndex" }, { "type": "null" }], "default": null, "x-altinity-completion": { "source": "resultColumnIndexes" } } }, "required": ["x", "y"], "additionalProperties": true, "x-altinity-order": ["type", "x", "y", "series"] }, "panelCfg": { "title": "Panel type configuration", "description": "Discriminated visualization configuration. Unknown types remain storable for forward compatibility.", "type": "object", "required": ["type"], "properties": { "type": { "title": "Panel type", "description": "Visualization renderer identifier.", "type": "string", "minLength": 1 } }, "additionalProperties": true, "x-altinity-discriminator": "type", "x-altinity-order": ["type"], "oneOf": [{ "title": "Column chart", "description": "Vertical columns using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "bar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "properties": { "type": { "const": "bar" } }, "required": ["type"] }] }, { "title": "Horizontal bar chart", "description": "Horizontal bars using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "hbar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "properties": { "type": { "const": "hbar" } }, "required": ["type"] }] }, { "title": "Line chart", "description": "Line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "line", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "properties": { "type": { "const": "line" } }, "required": ["type"] }] }, { "title": "Area chart", "description": "Filled line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "area", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "properties": { "type": { "const": "area" } }, "required": ["type"] }] }, { "title": "Pie chart", "description": "Pie slices using one positional measure role.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "pie", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "properties": { "type": { "const": "pie" }, "y": { "type": "array", "maxItems": 1 } }, "required": ["type"] }] }, { "title": "KPI", "description": "One-row scalar and named-tuple KPI cards.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "kpi" }, "properties": { "type": { "const": "kpi" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type"] }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "table" }, "properties": { "type": { "const": "table" } }, "required": ["type"], "additionalProperties": true }, { "title": "Logs", "description": "Timestamped log messages with optional explicit result-column roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "logs", "time": "event_time", "msg": "message", "level": "level" }, "properties": { "type": { "const": "logs" }, "time": { "$ref": "#/$defs/columnName" }, "msg": { "$ref": "#/$defs/columnName" }, "level": { "$ref": "#/$defs/columnName" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "time", "msg", "level"] }, { "title": "Markdown text", "description": "Safe Markdown content that does not require a SQL result.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "text", "content": "# Heading\n\nMarkdown content." }, "properties": { "type": { "const": "text" }, "content": { "title": "Markdown content", "description": "Source text for the safe Markdown renderer.", "type": "string", "default": "" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "content"] }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", "x-altinity-status": "planned", "x-altinity-snippet": { "type": "future-panel" }, "properties": { "type": { "type": "string", "minLength": 1, "not": { "enum": ["bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text"] } } }, "required": ["type"], "additionalProperties": true }] } } }; var schema44 = { "title": "Dashboard configuration", "description": "Dashboard participation metadata. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { "title": "Dashboard role", "description": "How the saved query participates in a dashboard.", "type": "string", "enum": ["panel", "filter", "setup"], "default": "panel" } }, "additionalProperties": true, "x-altinity-order": ["role"] }; var func1 = require_ucs2length().default; var pattern4 = new RegExp("\\S", "u"); @@ -726,8 +726,8 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r errors++; } if (data.type !== void 0) { - if ("table" !== data.type) { - const err13 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/5/properties/type/const", keyword: "const", params: { allowedValue: "table" }, message: "must be equal to constant" }; + if ("kpi" !== data.type) { + const err13 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/5/properties/type/const", keyword: "const", params: { allowedValue: "kpi" }, message: "must be equal to constant" }; if (vErrors === null) { vErrors = [err13]; } else { @@ -761,8 +761,8 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r errors++; } if (data.type !== void 0) { - if ("logs" !== data.type) { - const err15 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/6/properties/type/const", keyword: "const", params: { allowedValue: "logs" }, message: "must be equal to constant" }; + if ("table" !== data.type) { + const err15 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/6/properties/type/const", keyword: "const", params: { allowedValue: "table" }, message: "must be equal to constant" }; if (vErrors === null) { vErrors = [err15]; } else { @@ -771,72 +771,6 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r errors++; } } - if (data.time !== void 0) { - let data8 = data.time; - if (typeof data8 === "string") { - if (func1(data8) < 1) { - const err16 = { instancePath: instancePath + "/time", schemaPath: "#/$defs/columnName/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; - if (vErrors === null) { - vErrors = [err16]; - } else { - vErrors.push(err16); - } - errors++; - } - } else { - const err17 = { instancePath: instancePath + "/time", schemaPath: "#/$defs/columnName/type", keyword: "type", params: { type: "string" }, message: "must be string" }; - if (vErrors === null) { - vErrors = [err17]; - } else { - vErrors.push(err17); - } - errors++; - } - } - if (data.msg !== void 0) { - let data9 = data.msg; - if (typeof data9 === "string") { - if (func1(data9) < 1) { - const err18 = { instancePath: instancePath + "/msg", schemaPath: "#/$defs/columnName/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; - if (vErrors === null) { - vErrors = [err18]; - } else { - vErrors.push(err18); - } - errors++; - } - } else { - const err19 = { instancePath: instancePath + "/msg", schemaPath: "#/$defs/columnName/type", keyword: "type", params: { type: "string" }, message: "must be string" }; - if (vErrors === null) { - vErrors = [err19]; - } else { - vErrors.push(err19); - } - errors++; - } - } - if (data.level !== void 0) { - let data10 = data.level; - if (typeof data10 === "string") { - if (func1(data10) < 1) { - const err20 = { instancePath: instancePath + "/level", schemaPath: "#/$defs/columnName/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; - if (vErrors === null) { - vErrors = [err20]; - } else { - vErrors.push(err20); - } - errors++; - } - } else { - const err21 = { instancePath: instancePath + "/level", schemaPath: "#/$defs/columnName/type", keyword: "type", params: { type: "string" }, message: "must be string" }; - if (vErrors === null) { - vErrors = [err21]; - } else { - vErrors.push(err21); - } - errors++; - } - } } var _valid0 = _errs27 === errors; if (_valid0 && valid0) { @@ -850,41 +784,96 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r props0 = true; } } - const _errs39 = errors; + const _errs30 = errors; if (data && typeof data == "object" && !Array.isArray(data)) { if (data.type === void 0) { - const err22 = { instancePath, schemaPath: "#/oneOf/7/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; + const err16 = { instancePath, schemaPath: "#/oneOf/7/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; if (vErrors === null) { - vErrors = [err22]; + vErrors = [err16]; } else { - vErrors.push(err22); + vErrors.push(err16); } errors++; } if (data.type !== void 0) { - if ("text" !== data.type) { - const err23 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/7/properties/type/const", keyword: "const", params: { allowedValue: "text" }, message: "must be equal to constant" }; + if ("logs" !== data.type) { + const err17 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/7/properties/type/const", keyword: "const", params: { allowedValue: "logs" }, message: "must be equal to constant" }; if (vErrors === null) { - vErrors = [err23]; + vErrors = [err17]; } else { - vErrors.push(err23); + vErrors.push(err17); } errors++; } } - if (data.content !== void 0) { - if (typeof data.content !== "string") { - const err24 = { instancePath: instancePath + "/content", schemaPath: "#/oneOf/7/properties/content/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (data.time !== void 0) { + let data9 = data.time; + if (typeof data9 === "string") { + if (func1(data9) < 1) { + const err18 = { instancePath: instancePath + "/time", schemaPath: "#/$defs/columnName/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err18]; + } else { + vErrors.push(err18); + } + errors++; + } + } else { + const err19 = { instancePath: instancePath + "/time", schemaPath: "#/$defs/columnName/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { - vErrors = [err24]; + vErrors = [err19]; } else { - vErrors.push(err24); + vErrors.push(err19); + } + errors++; + } + } + if (data.msg !== void 0) { + let data10 = data.msg; + if (typeof data10 === "string") { + if (func1(data10) < 1) { + const err20 = { instancePath: instancePath + "/msg", schemaPath: "#/$defs/columnName/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err20]; + } else { + vErrors.push(err20); + } + errors++; + } + } else { + const err21 = { instancePath: instancePath + "/msg", schemaPath: "#/$defs/columnName/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err21]; + } else { + vErrors.push(err21); + } + errors++; + } + } + if (data.level !== void 0) { + let data11 = data.level; + if (typeof data11 === "string") { + if (func1(data11) < 1) { + const err22 = { instancePath: instancePath + "/level", schemaPath: "#/$defs/columnName/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err22]; + } else { + vErrors.push(err22); + } + errors++; + } + } else { + const err23 = { instancePath: instancePath + "/level", schemaPath: "#/$defs/columnName/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err23]; + } else { + vErrors.push(err23); } errors++; } } } - var _valid0 = _errs39 === errors; + var _valid0 = _errs30 === errors; if (_valid0 && valid0) { valid0 = false; passing0 = [passing0, 7]; @@ -896,23 +885,31 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r props0 = true; } } - const _errs44 = errors; + const _errs42 = errors; if (data && typeof data == "object" && !Array.isArray(data)) { if (data.type === void 0) { - const err25 = { instancePath, schemaPath: "#/oneOf/8/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; + const err24 = { instancePath, schemaPath: "#/oneOf/8/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; if (vErrors === null) { - vErrors = [err25]; + vErrors = [err24]; } else { - vErrors.push(err25); + vErrors.push(err24); } errors++; } if (data.type !== void 0) { - let data13 = data.type; - const _errs48 = errors; - const _errs49 = errors; - if (!(data13 === "bar" || data13 === "hbar" || data13 === "line" || data13 === "area" || data13 === "pie" || data13 === "table" || data13 === "logs" || data13 === "text")) { - const err26 = {}; + if ("text" !== data.type) { + const err25 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/8/properties/type/const", keyword: "const", params: { allowedValue: "text" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err25]; + } else { + vErrors.push(err25); + } + errors++; + } + } + if (data.content !== void 0) { + if (typeof data.content !== "string") { + const err26 = { instancePath: instancePath + "/content", schemaPath: "#/oneOf/8/properties/content/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { vErrors = [err26]; } else { @@ -920,28 +917,37 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - var valid18 = _errs49 === errors; - if (valid18) { - const err27 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/8/properties/type/not", keyword: "not", params: {}, message: "must NOT be valid" }; + } + } + var _valid0 = _errs42 === errors; + if (_valid0 && valid0) { + valid0 = false; + passing0 = [passing0, 8]; + } else { + if (_valid0) { + valid0 = true; + passing0 = 8; + if (props0 !== true) { + props0 = true; + } + } + const _errs47 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.type === void 0) { + const err27 = { instancePath, schemaPath: "#/oneOf/9/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; if (vErrors === null) { vErrors = [err27]; } else { vErrors.push(err27); } errors++; - } else { - errors = _errs48; - if (vErrors !== null) { - if (_errs48) { - vErrors.length = _errs48; - } else { - vErrors = null; - } - } } - if (typeof data13 === "string") { - if (func1(data13) < 1) { - const err28 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/8/properties/type/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (data.type !== void 0) { + let data14 = data.type; + const _errs51 = errors; + const _errs52 = errors; + if (!(data14 === "bar" || data14 === "hbar" || data14 === "line" || data14 === "area" || data14 === "pie" || data14 === "kpi" || data14 === "table" || data14 === "logs" || data14 === "text")) { + const err28 = {}; if (vErrors === null) { vErrors = [err28]; } else { @@ -949,27 +955,57 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - } else { - const err29 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/8/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; - if (vErrors === null) { - vErrors = [err29]; + var valid19 = _errs52 === errors; + if (valid19) { + const err29 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/9/properties/type/not", keyword: "not", params: {}, message: "must NOT be valid" }; + if (vErrors === null) { + vErrors = [err29]; + } else { + vErrors.push(err29); + } + errors++; } else { - vErrors.push(err29); + errors = _errs51; + if (vErrors !== null) { + if (_errs51) { + vErrors.length = _errs51; + } else { + vErrors = null; + } + } + } + if (typeof data14 === "string") { + if (func1(data14) < 1) { + const err30 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/9/properties/type/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err30]; + } else { + vErrors.push(err30); + } + errors++; + } + } else { + const err31 = { instancePath: instancePath + "/type", schemaPath: "#/oneOf/9/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err31]; + } else { + vErrors.push(err31); + } + errors++; } - errors++; } } - } - var _valid0 = _errs44 === errors; - if (_valid0 && valid0) { - valid0 = false; - passing0 = [passing0, 8]; - } else { - if (_valid0) { - valid0 = true; - passing0 = 8; - if (props0 !== true) { - props0 = true; + var _valid0 = _errs47 === errors; + if (_valid0 && valid0) { + valid0 = false; + passing0 = [passing0, 9]; + } else { + if (_valid0) { + valid0 = true; + passing0 = 9; + if (props0 !== true) { + props0 = true; + } } } } @@ -981,11 +1017,11 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r } } if (!valid0) { - const err30 = { instancePath, schemaPath: "#/oneOf", keyword: "oneOf", params: { passingSchemas: passing0 }, message: "must match exactly one schema in oneOf" }; + const err32 = { instancePath, schemaPath: "#/oneOf", keyword: "oneOf", params: { passingSchemas: passing0 }, message: "must match exactly one schema in oneOf" }; if (vErrors === null) { - vErrors = [err30]; + vErrors = [err32]; } else { - vErrors.push(err30); + vErrors.push(err32); } errors++; } else { @@ -1000,42 +1036,42 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r } if (data && typeof data == "object" && !Array.isArray(data)) { if (data.type === void 0) { - const err31 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; + const err33 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; if (vErrors === null) { - vErrors = [err31]; + vErrors = [err33]; } else { - vErrors.push(err31); + vErrors.push(err33); } errors++; } if (data.type !== void 0) { - let data14 = data.type; - if (typeof data14 === "string") { - if (func1(data14) < 1) { - const err32 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + let data15 = data.type; + if (typeof data15 === "string") { + if (func1(data15) < 1) { + const err34 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; if (vErrors === null) { - vErrors = [err32]; + vErrors = [err34]; } else { - vErrors.push(err32); + vErrors.push(err34); } errors++; } } else { - const err33 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + const err35 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { - vErrors = [err33]; + vErrors = [err35]; } else { - vErrors.push(err33); + vErrors.push(err35); } errors++; } } } else { - const err34 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err36 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err34]; + vErrors = [err36]; } else { - vErrors.push(err34); + vErrors.push(err36); } errors++; } @@ -1043,10 +1079,10 @@ function validate22(data, { instancePath = "", parentData, parentDataProperty, r return errors === 0; } validate22.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -function validate30(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +function validate31(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; - const evaluated0 = validate30.evaluated; + const evaluated0 = validate31.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -1054,97 +1090,248 @@ function validate30(data, { instancePath = "", parentData, parentDataProperty, r evaluated0.items = void 0; } if (data && typeof data == "object" && !Array.isArray(data)) { - if (data.defaults !== void 0) { - let data0 = data.defaults; - if (data0 && typeof data0 == "object" && !Array.isArray(data0)) { - if (data0.displayName !== void 0) { - if (typeof data0.displayName !== "string") { - const err0 = { instancePath: instancePath + "/defaults/displayName", schemaPath: "#/$defs/fieldConfigValue/properties/displayName/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (data.displayName !== void 0) { + if (typeof data.displayName !== "string") { + const err0 = { instancePath: instancePath + "/displayName", schemaPath: "#/properties/displayName/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + } + if (data.decimals !== void 0) { + let data1 = data.decimals; + if (!(typeof data1 == "number" && (!(data1 % 1) && !isNaN(data1)) && isFinite(data1))) { + const err1 = { instancePath: instancePath + "/decimals", schemaPath: "#/properties/decimals/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (typeof data1 == "number" && isFinite(data1)) { + if (data1 > 20 || isNaN(data1)) { + const err2 = { instancePath: instancePath + "/decimals", schemaPath: "#/properties/decimals/maximum", keyword: "maximum", params: { comparison: "<=", limit: 20 }, message: "must be <= 20" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + if (data1 < 0 || isNaN(data1)) { + const err3 = { instancePath: instancePath + "/decimals", schemaPath: "#/properties/decimals/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + } + } + if (data.description !== void 0) { + if (typeof data.description !== "string") { + const err4 = { instancePath: instancePath + "/description", schemaPath: "#/properties/description/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + } + if (data.unit !== void 0) { + if (typeof data.unit !== "string") { + const err5 = { instancePath: instancePath + "/unit", schemaPath: "#/properties/unit/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + } + if (data.color !== void 0) { + if (typeof data.color !== "string") { + const err6 = { instancePath: instancePath + "/color", schemaPath: "#/properties/color/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + } + if (data.noValue !== void 0) { + if (typeof data.noValue !== "string") { + const err7 = { instancePath: instancePath + "/noValue", schemaPath: "#/properties/noValue/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + } + if (data.hidden !== void 0) { + if (typeof data.hidden !== "boolean") { + const err8 = { instancePath: instancePath + "/hidden", schemaPath: "#/properties/hidden/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + } + if (data.delta !== void 0) { + let data7 = data.delta; + if (data7 && typeof data7 == "object" && !Array.isArray(data7)) { + if (data7.displayName !== void 0) { + if (typeof data7.displayName !== "string") { + const err9 = { instancePath: instancePath + "/delta/displayName", schemaPath: "#/$defs/deltaPresentation/properties/displayName/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { - vErrors = [err0]; + vErrors = [err9]; } else { - vErrors.push(err0); + vErrors.push(err9); } errors++; } } - if (data0.decimals !== void 0) { - let data2 = data0.decimals; - if (!(typeof data2 == "number" && (!(data2 % 1) && !isNaN(data2)) && isFinite(data2))) { - const err1 = { instancePath: instancePath + "/defaults/decimals", schemaPath: "#/$defs/fieldConfigValue/properties/decimals/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (data7.unit !== void 0) { + if (typeof data7.unit !== "string") { + const err10 = { instancePath: instancePath + "/delta/unit", schemaPath: "#/$defs/deltaPresentation/properties/unit/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { - vErrors = [err1]; + vErrors = [err10]; } else { - vErrors.push(err1); + vErrors.push(err10); } errors++; } } - } else { - const err2 = { instancePath: instancePath + "/defaults", schemaPath: "#/$defs/fieldConfigValue/type", keyword: "type", params: { type: "object" }, message: "must be object" }; - if (vErrors === null) { - vErrors = [err2]; - } else { - vErrors.push(err2); - } - errors++; - } - } - if (data.columns !== void 0) { - let data3 = data.columns; - if (data3 && typeof data3 == "object" && !Array.isArray(data3)) { - for (const key0 in data3) { - let data4 = data3[key0]; - if (data4 && typeof data4 == "object" && !Array.isArray(data4)) { - if (data4.displayName !== void 0) { - if (typeof data4.displayName !== "string") { - const err3 = { instancePath: instancePath + "/columns/" + key0.replace(/~/g, "~0").replace(/\//g, "~1") + "/displayName", schemaPath: "#/$defs/fieldConfigValue/properties/displayName/type", keyword: "type", params: { type: "string" }, message: "must be string" }; - if (vErrors === null) { - vErrors = [err3]; - } else { - vErrors.push(err3); - } - errors++; + if (data7.decimals !== void 0) { + let data10 = data7.decimals; + if (!(typeof data10 == "number" && (!(data10 % 1) && !isNaN(data10)) && isFinite(data10))) { + const err11 = { instancePath: instancePath + "/delta/decimals", schemaPath: "#/$defs/deltaPresentation/properties/decimals/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + if (typeof data10 == "number" && isFinite(data10)) { + if (data10 > 20 || isNaN(data10)) { + const err12 = { instancePath: instancePath + "/delta/decimals", schemaPath: "#/$defs/deltaPresentation/properties/decimals/maximum", keyword: "maximum", params: { comparison: "<=", limit: 20 }, message: "must be <= 20" }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); } + errors++; } - if (data4.decimals !== void 0) { - let data6 = data4.decimals; - if (!(typeof data6 == "number" && (!(data6 % 1) && !isNaN(data6)) && isFinite(data6))) { - const err4 = { instancePath: instancePath + "/columns/" + key0.replace(/~/g, "~0").replace(/\//g, "~1") + "/decimals", schemaPath: "#/$defs/fieldConfigValue/properties/decimals/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; - if (vErrors === null) { - vErrors = [err4]; - } else { - vErrors.push(err4); - } - errors++; + if (data10 < 0 || isNaN(data10)) { + const err13 = { instancePath: instancePath + "/delta/decimals", schemaPath: "#/$defs/deltaPresentation/properties/decimals/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); } + errors++; } - } else { - const err5 = { instancePath: instancePath + "/columns/" + key0.replace(/~/g, "~0").replace(/\//g, "~1"), schemaPath: "#/$defs/fieldConfigValue/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + } + } + if (data7.positiveIsGood !== void 0) { + if (typeof data7.positiveIsGood !== "boolean") { + const err14 = { instancePath: instancePath + "/delta/positiveIsGood", schemaPath: "#/$defs/deltaPresentation/properties/positiveIsGood/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }; if (vErrors === null) { - vErrors = [err5]; + vErrors = [err14]; } else { - vErrors.push(err5); + vErrors.push(err14); + } + errors++; + } + } + if (data7.show !== void 0) { + if (typeof data7.show !== "boolean") { + const err15 = { instancePath: instancePath + "/delta/show", schemaPath: "#/$defs/deltaPresentation/properties/show/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); } errors++; } } } else { - const err6 = { instancePath: instancePath + "/columns", schemaPath: "#/properties/columns/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err16 = { instancePath: instancePath + "/delta", schemaPath: "#/$defs/deltaPresentation/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err6]; + vErrors = [err16]; } else { - vErrors.push(err6); + vErrors.push(err16); + } + errors++; + } + } + } else { + const err17 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err17]; + } else { + vErrors.push(err17); + } + errors++; + } + validate31.errors = vErrors; + return errors === 0; +} +validate31.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate30(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + let vErrors = null; + let errors = 0; + const evaluated0 = validate30.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.defaults !== void 0) { + if (!validate31(data.defaults, { instancePath: instancePath + "/defaults", parentData: data, parentDataProperty: "defaults", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate31.errors : vErrors.concat(validate31.errors); + errors = vErrors.length; + } + } + if (data.columns !== void 0) { + let data1 = data.columns; + if (data1 && typeof data1 == "object" && !Array.isArray(data1)) { + for (const key0 in data1) { + if (!validate31(data1[key0], { instancePath: instancePath + "/columns/" + key0.replace(/~/g, "~0").replace(/\//g, "~1"), parentData: data1, parentDataProperty: key0, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate31.errors : vErrors.concat(validate31.errors); + errors = vErrors.length; + } + } + } else { + const err0 = { instancePath: instancePath + "/columns", schemaPath: "#/properties/columns/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); } errors++; } } } else { - const err7 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err1 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err7]; + vErrors = [err1]; } else { - vErrors.push(err7); + vErrors.push(err1); } errors++; } @@ -1339,12 +1526,12 @@ function validate20(data, { instancePath = "", parentData, parentDataProperty, r return errors === 0; } validate20.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -var validateSavedQueryV2 = validate33; -function validate33(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +var validateSavedQueryV2 = validate36; +function validate36(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate33.evaluated; + const evaluated0 = validate36.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -1550,17 +1737,17 @@ function validate33(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate33.errors = vErrors; + validate36.errors = vErrors; return errors === 0; } -validate33.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -var validateLibraryV2 = validate35; +validate36.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validateLibraryV2 = validate38; var formats0 = require_formats().fullFormats["date-time"]; -function validate35(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +function validate38(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate35.evaluated; + const evaluated0 = validate38.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -1685,8 +1872,8 @@ function validate35(data, { instancePath = "", parentData, parentDataProperty, r } const len0 = data4.length; for (let i0 = 0; i0 < len0; i0++) { - if (!validate33(data4[i0], { instancePath: instancePath + "/queries/" + i0, parentData: data4, parentDataProperty: i0, rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate33.errors : vErrors.concat(validate33.errors); + if (!validate36(data4[i0], { instancePath: instancePath + "/queries/" + i0, parentData: data4, parentDataProperty: i0, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate36.errors : vErrors.concat(validate36.errors); errors = vErrors.length; } } @@ -1709,10 +1896,10 @@ function validate35(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate35.errors = vErrors; + validate38.errors = vErrors; return errors === 0; } -validate35.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +validate38.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; export { validateLibraryV2, validateQuerySpecV1, diff --git a/src/generated/json-schemas.js b/src/generated/json-schemas.js index f5de9ac5..11e10890 100644 --- a/src/generated/json-schemas.js +++ b/src/generated/json-schemas.js @@ -75,9 +75,56 @@ export const querySpecV1Schema = { "source": "resultColumnIndexes" } }, + "deltaPresentation": { + "title": "Delta presentation", + "description": "Display metadata for a runtime KPI delta value.", + "type": "object", + "properties": { + "displayName": { + "title": "Delta label", + "description": "Optional visible label for the delta.", + "type": "string" + }, + "unit": { + "title": "Delta unit", + "description": "Display-only suffix appended to the delta.", + "type": "string" + }, + "decimals": { + "title": "Delta decimal places", + "description": "Requested display rounding for the delta.", + "type": "integer", + "minimum": 0, + "maximum": 20, + "default": 0, + "examples": [ + 1 + ] + }, + "positiveIsGood": { + "title": "Positive is good", + "description": "Whether a positive runtime delta has good semantics.", + "type": "boolean" + }, + "show": { + "title": "Show delta", + "description": "Whether a present runtime delta is rendered.", + "type": "boolean", + "default": true + } + }, + "additionalProperties": true, + "x-altinity-order": [ + "displayName", + "unit", + "decimals", + "positiveIsGood", + "show" + ] + }, "fieldConfigValue": { - "title": "Field display configuration", - "description": "Known display metadata for one result column. Unknown renderer extensions are retained.", + "title": "Field presentation metadata", + "description": "Known presentation metadata for one result column. Unknown renderer extensions are retained.", "type": "object", "properties": { "displayName": { @@ -89,13 +136,57 @@ export const querySpecV1Schema = { "title": "Decimal places", "description": "Requested number of decimal places for numeric display.", "type": "integer", - "default": 0 + "minimum": 0, + "maximum": 20, + "default": 0, + "examples": [ + 2 + ] + }, + "description": { + "title": "Description", + "description": "Supporting display text for the field.", + "type": "string" + }, + "unit": { + "title": "Unit", + "description": "Display-only suffix appended to the value.", + "type": "string", + "examples": [ + "%" + ] + }, + "color": { + "title": "Color", + "description": "Theme token or CSS color hint interpreted by the renderer.", + "type": "string" + }, + "noValue": { + "title": "No-value text", + "description": "Text shown for NULL or unavailable values.", + "type": "string", + "default": "—" + }, + "hidden": { + "title": "Hidden", + "description": "Suppress this otherwise eligible result field.", + "type": "boolean", + "default": false + }, + "delta": { + "$ref": "#/$defs/deltaPresentation" } }, "additionalProperties": true, "x-altinity-order": [ "displayName", - "decimals" + "description", + "unit", + "decimals", + "color", + "noValue", + "hidden", + "delta" ] }, "fieldConfig": { @@ -384,6 +475,26 @@ export const querySpecV1Schema = { } ] }, + { + "title": "KPI", + "description": "One-row scalar and named-tuple KPI cards.", + "x-altinity-status": "implemented", + "x-altinity-snippet": { + "type": "kpi" + }, + "properties": { + "type": { + "const": "kpi" + } + }, + "required": [ + "type" + ], + "additionalProperties": true, + "x-altinity-order": [ + "type" + ] + }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", @@ -482,6 +593,7 @@ export const querySpecV1Schema = { "line", "area", "pie", + "kpi", "table", "logs", "text" diff --git a/src/net/ch-client.js b/src/net/ch-client.js index b668e4ba..53b57760 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -585,12 +585,12 @@ export async function exportQuery(ctx, sql, { queryId, signal, format, params } */ export async function runQuery(ctx, sql, o = {}) { const fmt = o.format || 'Table'; - const isStreaming = fmt === 'Table'; + const isStreaming = fmt === 'Table' || fmt === 'KPI'; // Streaming gets the progress-bearing JSON; raw mode sends the requested format // verbatim as default_format (a real ClickHouse format name from a FORMAT clause // or an implicit EXPLAIN). 'TSV' keeps its with-names-and-types expansion. const fmtParam = isStreaming - ? 'JSONStringsEachRowWithProgress' + ? (fmt === 'KPI' ? 'JSONEachRowWithProgress' : 'JSONStringsEachRowWithProgress') : fmt === 'TSV' ? 'TabSeparatedWithNamesAndTypes' : fmt; diff --git a/src/styles.css b/src/styles.css index 1d3ffb4d..522980cb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -22,6 +22,41 @@ html { zoom: var(--zoom); } --vp-zoom: var(--zoom); } +/* ------------ KPI panel ------------ */ +.kpi-panel { display: grid; gap: 10px; min-width: 0; align-content: start; } +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 280px)); + gap: 10px; + align-items: stretch; + justify-content: start; +} +.kpi-card { + --kpi-accent: var(--accent); + min-width: 0; + padding: 14px 16px; + border: 1px solid var(--border); + border-top: 3px solid var(--kpi-accent); + border-radius: 8px; + background: var(--bg-modal); +} +.kpi-label { color: var(--fg-mute); font-size: 12px; font-weight: 600; letter-spacing: .02em; } +.kpi-value { margin-top: 6px; font-size: clamp(24px, 4vw, 38px); font-weight: 700; line-height: 1.08; overflow-wrap: anywhere; } +.kpi-description { margin-top: 7px; color: var(--fg-mute); font-size: 12px; line-height: 1.4; } +.kpi-delta { margin-top: 9px; font-size: 13px; font-weight: 600; } +.kpi-delta.is-good { color: var(--success, #238636); } +.kpi-delta.is-bad { color: var(--danger, #cf222e); } +.kpi-delta.is-neutral { color: var(--fg-mute); } +.kpi-warnings { display: grid; gap: 4px; } +.kpi-diagnostic { color: var(--fg-mute); font-size: 12px; } +.kpi-diagnostic.is-error { color: var(--danger, #cf222e); } +.kpi-state { min-height: 120px; display: grid; place-items: center; text-align: center; } +.panel-authoring-hint { color: var(--fg-mute); font-size: 12px; line-height: 1.4; } + +@media (max-width: 520px) { + .kpi-grid { grid-template-columns: 1fr; } +} + /* Fallback for engines that can't even parse `zoom` (no `@supports (zoom: 1)`): neutralize the factor so the `html{zoom}` no-op AND every `calc(.../var(--vp-zoom))` viewport panel collapse to a consistent 1× layout, instead of dividing by a @@ -2132,6 +2167,8 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } consistent with the all-modes-to-one-column narrow fallback. */ .dash-grid.is-report { grid-template-columns: 1fr; max-width: 1100px; } .dash-grid.is-report .dash-tile { min-height: 440px; } +.dash-grid.is-report .dash-tile.is-kpi, +.dash-grid.is-wide .dash-tile.is-kpi { min-height: 0; } /* Full width mode (#184): one tile per row filling the whole available dashboard content width (inside the grid's existing 20px page gutters), for horizontally expansive Grafana-style panels. Unlike Report it keeps the @@ -2157,7 +2194,8 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dash-tile-body { flex: 1; min-height: 0; padding: 6px 8px; display: flex; } -.dash-tile-body > .chart-view { flex: 1; min-width: 0; } +.dash-tile-body > .chart-view, +.dash-tile-body > .kpi-panel { flex: 1; min-width: 0; } /* Table/logs tiles (#149 D9): explicit flex constraints so content scrolls inside the tile instead of clipping; the workbench grid's height:100% is overridden back to auto (flex sizes it here). */ diff --git a/src/ui/app.js b/src/ui/app.js index 26f2b88d..8b736363 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -39,6 +39,7 @@ import { generatePKCE, randomState } from '../core/pkce.js'; import { viewportZoom } from '../core/zoom-support.js'; import { configBase } from '../core/dashboard.js'; import { isQuerylessPanel } from '../core/panel-cfg.js'; +import { isKpiPanel, panelExecution } from '../core/panel-execution.js'; import { snapshotAuth, restoreAuth, hasAuth, isAuthRequest, isAuthGrant, AUTH_REQUEST, AUTH_GRANT } from '../core/auth-handoff.js'; import * as oauthCfg from '../net/oauth-config.js'; import * as oauth from '../net/oauth.js'; @@ -797,10 +798,21 @@ export function createApp(env = {}) { // records the template (srcSql / tab.sqlDraft). const execSql = execStatementSql(srcSql); + const kpiExecution = panelExecution(tabPanel(tab), execSql, { + format: 'Table', rowLimit: app.state.resultRowLimit, params: {}, + }); + if (kpiExecution.error) { + tab.result = newResult('KPI', 2); + tab.result.error = kpiExecution.error; + app.state.resultView.value = 'panel'; + renderResults(app); + return; + } + // An explicit FORMAT clause runs raw and shows ClickHouse's response verbatim // (single raw tab). Otherwise an EXPLAIN (typed, or forced by the button) gets // the five EXPLAIN views; everything else streams structured (Table). - const explicitFmt = detectSqlFormat(execSql); + const explicitFmt = isKpiPanel(tabPanel(tab)) ? null : detectSqlFormat(execSql); const parsed = explicitFmt ? null : parseExplain(execSql); const explainMode = !explicitFmt && (parsed != null || app.state.forceExplain); let runSql = execSql; @@ -822,14 +834,14 @@ export function createApp(env = {}) { ? execSql : buildExplainQuery(inner, explainView, explainOpts); } else { - fmt = explicitFmt || 'Table'; + fmt = isKpiPanel(tabPanel(tab)) ? kpiExecution.format : explicitFmt || 'Table'; } // Cap a normal result query (Table or explicit-FORMAT SELECT) at the global // row limit; EXPLAIN/PIPELINE/ESTIMATE are exempt (small output, and a cap // would truncate a plan oddly). The streaming guard reads it off the result; // runQuery adds the server-side max_result_rows for the Table path. - const rowLimit = explainMode ? 0 : app.state.resultRowLimit; + const rowLimit = explainMode ? 0 : isKpiPanel(tabPanel(tab)) ? kpiExecution.rowLimit : app.state.resultRowLimit; const t0 = now(); tab.result = newResult(fmt, rowLimit); if (explainView) tab.result.explainView = explainView; @@ -861,7 +873,7 @@ export function createApp(env = {}) { // Native ClickHouse query parameters (#134/#173): pass prepared values // as param_ so the server substitutes them (only row-returning // statements bind — a CREATE VIEW / DDL source stays verbatim). - params: { ...sessionParamsFor(tab, [srcSql]), ...mergedSourceArgs(src) }, + params: { ...sessionParamsFor(tab, [srcSql]), ...mergedSourceArgs(src), ...kpiExecution.params }, onChunk: () => renderResults(app), }); } finally { @@ -882,7 +894,7 @@ export function createApp(env = {}) { // renders the toolbar + its Expand affordance, which gates on // `result.source` — set it after and the button never appears until the // next paint. - if (!tab.result.error && !tab.result.cancelled && fmt === 'Table' && tab.result.rows.length > 0) { + if (!tab.result.error && !tab.result.cancelled && (fmt === 'Table' || fmt === 'KPI') && tab.result.rows.length > 0) { tab.result.source = buildResultSource({ srcSql, tabId: tab.id, @@ -899,7 +911,7 @@ export function createApp(env = {}) { // Spec completion is intentionally stable during a run and survives a // later failed/cancelled run. Snapshot only completed structured // results; never expose partially streamed metadata to the editor. - tab.lastSuccessfulResultColumns = fmt === 'Table' + tab.lastSuccessfulResultColumns = (fmt === 'Table' || fmt === 'KPI') ? tab.result.columns.map((column) => ({ ...column })) : []; app.recordHistory(tab, opts && opts.sql); diff --git a/src/ui/dashboard.js b/src/ui/dashboard.js index f559c905..c974273f 100644 --- a/src/ui/dashboard.js +++ b/src/ui/dashboard.js @@ -10,11 +10,11 @@ // through the shared panel registry (panels.js) — an explicit saved // `panel` wins (and never vanishes: zero-row explicit panels show an honest // "0 rows" state), an unconfigured result goes through the autoPanel -// heuristic, and only unconfigured empty/single-row (future KPI) results are -// skipped, counted in a header note. A global filter bar (D3, below) drives +// heuristic; eligible one-row results become KPI tiles and only unconfigured +// empty results are skipped and counted in a header note. A global filter bar drives // the same `{name:Type}` mechanism the SQL Browser workbench uses, fanning it -// out across every favorite instead of one query at a time. KPI tiles, -// per-tile overrides, and export arrive in later phases (D5–D8). +// out across every favorite instead of one query at a time. Per-tile overrides +// and export arrive in later phases (D7–D8). import { h } from './dom.js'; import { Icon } from './icons.js'; @@ -34,6 +34,7 @@ import { hasOptionalBlocks } from '../core/optional-blocks.js'; import { effectiveFilterActive } from '../state.js'; import { buildFilterBar } from './filter-bar.js'; import { queryDescription, queryFavorite, queryName, queryPanel } from '../core/saved-query.js'; +import { isKpiPanel, panelExecution } from '../core/panel-execution.js'; // At most this many tile queries run at once, so a large favorites list doesn't // fire a thundering herd of concurrent reads at ClickHouse (saturating the @@ -154,7 +155,7 @@ function buildTileSlot(q) { const head = h('div', { class: 'dash-tile-head' }, h('span', { class: 'dash-tile-name', title: name }, name)); if (description) head.appendChild(h('div', { class: 'dash-tile-desc', title: description }, description)); - const card = h('div', { class: 'dash-tile' }, head, body, foot); + const card = h('div', { class: `dash-tile${isKpiPanel(queryPanel(q)) ? ' is-kpi' : ''}` }, head, body, foot); return { card, body, foot, gen: 0, status: null, destroy: null, panelState: null, abortController: null, loadLabel: null, @@ -241,11 +242,10 @@ function applyTileResult(app, q, slot, r) { return; } const explicit = explicitPanel(q); - // Unconfigured results keep the skip ladder (#166: empty, and single-row - // until the KPI arm lands with #154). An EXPLICIT panel never vanishes — + // Unconfigured empty results remain skipped. An EXPLICIT panel never vanishes — // a zero-row one renders an honest "0 rows" state instead (visible, and // excluded from the header's skip tally). - if (!explicit && r.rows.length <= 1) { + if (!explicit && r.rows.length === 0) { slot.status = 'skip'; slot.card.style.display = 'none'; // Clear the previous panel's DOM (its live instance is already torn down @@ -257,7 +257,7 @@ function applyTileResult(app, q, slot, r) { } slot.status = 'panel'; slot.card.style.display = ''; - if (explicit && r.rows.length === 0) { + if (explicit && r.rows.length === 0 && !isKpiPanel(explicit)) { slot.body.replaceChildren(h('div', { class: 'dash-tile-empty' }, '0 rows')); slot.foot.replaceChildren(...tileFooter(r.meta)); return; @@ -266,8 +266,9 @@ function applyTileResult(app, q, slot, r) { // the type and re-derive roles; impossible shapes fall back with a // diagnostic), an unconfigured result goes through the autoPanel ladder. const resolved = explicit - ? resolvePanel(explicit, r.columns) - : { ...autoPanel(r.columns), rederived: false, fallback: false }; + ? resolvePanel(explicit, { columns: r.columns, rows: r.rows, fieldConfig: explicit.fieldConfig, serverVersion: app.state.serverVersion }) + : { ...autoPanel({ columns: r.columns, rows: r.rows, serverVersion: app.state.serverVersion }), rederived: false, fallback: false }; + slot.card.classList.toggle('is-kpi', resolved.cfg.type === 'kpi'); // Grid state persists across refreshes/filter edits on the stable slot, // keyed by result schema — a schema change resets it, a re-run keeps it. const key = schemaKey(r.columns); @@ -326,9 +327,14 @@ async function runSlotTile(app, q, slot, onSettled, src, generation) { // JSONStringsEachRowWithProgress format, so an explicit `FORMAT` clause would // silently corrupt the tile (an empty successful-looking result, or ignored // lines). Reject it with a clear error rather than mis-parse. - if (detectSqlFormat(execSql)) { + const explicit = explicitPanel(q); + const execution = panelExecution(explicit, execSql, { + format: 'Table', rowLimit: DASH_TILE_ROW_CAP + 1, + params: { readonly: 2, max_result_bytes: DASH_TILE_BYTE_CAP, ...mergedSourceArgs(src) }, + }); + if (execution.error || (!isKpiPanel(explicit) && detectSqlFormat(execSql))) { applyTileResult(app, q, slot, { - error: 'Dashboard panels require structured streaming results. Remove the explicit FORMAT clause.', + error: execution.error || 'Dashboard panels require structured streaming results. Remove the explicit FORMAT clause.', }); onSettled(); return; @@ -340,15 +346,15 @@ async function runSlotTile(app, q, slot, onSettled, src, generation) { // Client row limit = CAP (newResult trims + flags `capped`); server cap = // CAP + 1 (the sentinel one past the client limit), so an exactly-CAP result // is NOT marked truncated and a >CAP result is trimmed AND flagged (#193 req 1). - const result = newResult('Table', DASH_TILE_ROW_CAP); + const result = newResult(execution.format, isKpiPanel(explicit) ? 2 : DASH_TILE_ROW_CAP); await app.runReadInto(result, { sql: execSql, - format: 'Table', - rowLimit: DASH_TILE_ROW_CAP + 1, + format: execution.format, + rowLimit: execution.rowLimit, // readonly:2 rejects writes server-side (a favorite containing an INSERT/DDL // is guarded, not executed); max_result_bytes bounds wide rows; param_ // are the wave's prepared filter args (#173). - params: { readonly: 2, max_result_bytes: DASH_TILE_BYTE_CAP, ...mergedSourceArgs(src) }, + params: execution.params, signal: ac.signal, // Progress-only repaint (#193 design req 4): update the loading placeholder's // row count as rows stream, never classify/render mid-stream. Updates the @@ -490,7 +496,7 @@ export function renderDashboard(app) { if (skipped) { skipNote.style.display = ''; skipNote.textContent = skipped + ' not shown'; - skipNote.title = skipped + ' empty or single-row (KPI) favorite(s) — KPI panels arrive in a later phase.'; + skipNote.title = skipped + ' empty favorite(s) with no panel to render.'; } else { skipNote.style.display = 'none'; } diff --git a/src/ui/kpi-panel.js b/src/ui/kpi-panel.js new file mode 100644 index 00000000..50aa4be8 --- /dev/null +++ b/src/ui/kpi-panel.js @@ -0,0 +1,45 @@ +import { formatKpiValue, kpiDeltaState } from '../core/kpi.js'; +import { h } from './dom.js'; + +function diagnosticNode(diagnostic) { + return h('div', { + class: `kpi-diagnostic is-${diagnostic.severity}`, + role: diagnostic.severity === 'error' ? 'alert' : 'status', + }, diagnostic.message); +} + +function absoluteValue(value) { + if (typeof value === 'bigint') return value < 0n ? -value : value; + if (typeof value === 'string') return value.trim().replace(/^[+-]/, ''); + return Math.abs(value); +} + +export function renderKpiPanel(normalized) { + const data = normalized || { items: [], diagnostics: [] }; + const errors = data.diagnostics.filter((item) => item.severity === 'error' || item.code === 'kpi-no-data'); + if (errors.length) return h('div', { class: 'kpi-state' }, ...data.diagnostics.map(diagnosticNode)); + const cards = data.items.map((item) => { + const presentation = item.presentation; + const label = h('div', { class: 'kpi-label' }, presentation.displayName); + const value = h('div', { class: 'kpi-value' }, formatKpiValue({ value: item.value, clickhouseType: item.valueType, presentation })); + const children = [label, value]; + if (presentation.description) children.push(h('div', { class: 'kpi-description' }, presentation.description)); + const delta = kpiDeltaState(item); + if (delta) { + const deltaPresentation = { ...presentation.delta, noValue: presentation.noValue }; + const arrow = delta.direction === 'up' ? '↑' : delta.direction === 'down' ? '↓' : '→'; + const deltaLabel = presentation.delta?.displayName ? `${presentation.delta.displayName} ` : ''; + children.push(h('div', { + class: `kpi-delta is-${delta.semantic}`, + 'aria-label': `${presentation.delta?.displayName || 'Delta'} ${delta.direction} ${absoluteValue(delta.value)}`, + }, arrow + ' ' + deltaLabel + formatKpiValue({ value: absoluteValue(delta.value), clickhouseType: item.deltaType, presentation: deltaPresentation }))); + } + const card = h('section', { class: 'kpi-card', 'aria-label': presentation.displayName }, ...children); + if (typeof presentation.color === 'string' && presentation.color) card.style.setProperty('--kpi-accent', presentation.color); + return card; + }); + const warnings = data.diagnostics.filter((item) => item.severity === 'warning'); + return h('div', { class: 'kpi-panel' }, + h('div', { class: 'kpi-grid', role: 'group', 'aria-label': 'Key performance indicators' }, ...cards), + ...(warnings.length ? [h('div', { class: 'kpi-warnings' }, ...warnings.map(diagnosticNode))] : [])); +} diff --git a/src/ui/panels.js b/src/ui/panels.js index 3000b4a7..94dc0857 100644 --- a/src/ui/panels.js +++ b/src/ui/panels.js @@ -30,6 +30,7 @@ import { resolvePanel, resolveLogsShape, switchPanelType, isChartFamily, CHART_FAMILY, clonePanelCfg, } from '../core/panel-cfg.js'; import { CHART_TYPES, schemaKey } from '../core/chart-data.js'; +import { renderKpiPanel } from './kpi-panel.js'; // ── Markdown AST → DOM ─────────────────────────────────────────────────────── @@ -150,6 +151,10 @@ const chartArm = { }; const PANEL_TYPES = { + kpi: { + controls: () => null, + renderPanel({ kpi }) { return { node: renderKpiPanel(kpi) }; }, + }, table: { controls: () => null, // no schema-bound fields; sort/widths are surface state renderPanel({ result, state, rerender, cap, onCell }) { @@ -214,6 +219,7 @@ export { PANEL_TYPES }; * result view is its workbench surface, so offering it here would duplicate * the adjacent Table button. */ export const PANEL_PICKER_OPTIONS = [ + { value: 'kpi', label: 'KPI' }, ...CHART_TYPES, { value: 'logs', label: 'Logs' }, { value: 'text', label: 'Text' }, @@ -229,7 +235,7 @@ export const PANEL_PICKER_OPTIONS = [ */ export function renderResolvedPanel(app, resolved, result, opts) { const arm = PANEL_TYPES[resolved.cfg.type]; - const out = arm.renderPanel({ app, result, cfg: resolved.cfg, shape: resolved.shape, ...opts }); + const out = arm.renderPanel({ app, result, cfg: resolved.cfg, shape: resolved.shape, kpi: resolved.kpi, ...opts }); if (!resolved.diagnostic && !resolved.rederived) return out; // Wrap with the mismatch affordance: a small hint bar above the panel. const note = resolved.diagnostic @@ -258,7 +264,10 @@ function panelContext(app, r) { const hasGrid = !!(r && !r.error && r.rawText == null && r.rows); const columns = hasGrid ? r.columns : []; const saved = tabPanel(tab); - const resolved = resolvePanel(saved, columns); + const resolved = resolvePanel(saved, { + columns, rows: hasGrid ? r.rows : null, + fieldConfig: saved?.fieldConfig, serverVersion: app.state.serverVersion, + }); // Rescue (#192/#195): a saved Logs panel that falls back (its Time/Message // roles no longer resolve) still needs its Logs controls so the user can // repair the roles, but the fallback preview (Table OR a derived chart) is @@ -342,7 +351,10 @@ export function renderPanelView(app, r, hooks) { ? [PANEL_TYPES.logs, clonePanelCfg(saved.cfg)] : [PANEL_TYPES[resolved.cfg.type], resolved.cfg]; const controlsNode = controlsArm.controls({ app, result: hasGrid ? r : null, cfg: controlsCfg, onChange }); - const bar = controlsNode ? h('div', { class: 'panel-config' }, controlsNode) : null; + const kpiHint = resolved.cfg.type === 'kpi' + ? h('div', { class: 'panel-authoring-hint' }, 'Labels, units, decimals, colors, and delta semantics are authored in Spec → panel.fieldConfig.') + : null; + const bar = controlsNode || kpiHint ? h('div', { class: 'panel-config' }, controlsNode, kpiHint) : null; const body = h('div', { class: 'panel-body' }); const isText = resolved.cfg.type === 'text'; diff --git a/src/ui/results.js b/src/ui/results.js index 45ab1c91..7952ee0f 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -22,6 +22,7 @@ import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph } from '. import { openInDetachedTab } from './detached-view.js'; import { buildFilterBar } from './filter-bar.js'; import { startDrag, clampDrawerWidth } from './splitters.js'; +import { panelExecution } from '../core/panel-execution.js'; // View id → tab glyph for the EXPLAIN view strip (kept here so core/explain.js // stays DOM-free). Pipeline reuses the node-graph share glyph. @@ -697,7 +698,7 @@ export function expandDataPane(app, r) { cap: visCap(res), panel: { mode: 'readonly', - resolved: resolvePanel(savedPanel, res.columns), + resolved: resolvePanel(savedPanel, { columns: res.columns, rows: res.rows, fieldConfig: savedPanel?.fieldConfig, serverVersion: app.state.serverVersion }), state: panelState, setChart: (c) => { chartInstance = c; }, }, @@ -748,13 +749,18 @@ export function expandDataPane(app, r) { if (myGen === gen && !closed) settle('Not signed in'); return; } - const result = newResult('Table', source.rowLimit); + const execution = panelExecution(savedPanel, mergedSourceSql(src, source.sql), { + format: 'Table', rowLimit: source.rowLimit, + params: { ...(sessionId ? { session_id: sessionId } : {}), ...mergedSourceArgs(src) }, + }); + if (execution.error) { settle(execution.error); return; } + const result = newResult(execution.format, execution.rowLimit); await app.runReadInto(result, { sql: mergedSourceSql(src, source.sql), - format: 'Table', - rowLimit: source.rowLimit, + format: execution.format, + rowLimit: execution.rowLimit, // Native param_ bindings + the captured session (when any). - params: { ...(sessionId ? { session_id: sessionId } : {}), ...mergedSourceArgs(src) }, + params: execution.params, signal, // Progress-only streaming (#198): update the lightweight status text as // rows arrive, but NEVER paint the in-flight result and NEVER touch the diff --git a/tests/e2e/kpi.html b/tests/e2e/kpi.html new file mode 100644 index 00000000..60776338 --- /dev/null +++ b/tests/e2e/kpi.html @@ -0,0 +1,44 @@ + + + + + KPI panel harness + + + + +
+
+
+
Service KPIs
+
+
1 row
+
+
+ + + diff --git a/tests/e2e/kpi.spec.js b/tests/e2e/kpi.spec.js new file mode 100644 index 00000000..8e18fa29 --- /dev/null +++ b/tests/e2e/kpi.spec.js @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; + +test.describe('KPI panel', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/e2e/kpi.html'); + await page.waitForFunction(() => window.__ready === true); + }); + + test('renders equivalent accessible cards on workbench and dashboard surfaces', async ({ page }) => { + for (const surface of ['#workbench', '#dashboard']) { + await expect(page.locator(`${surface} .kpi-card`)).toHaveCount(2); + await expect(page.locator(`${surface} .kpi-label`).nth(0)).toHaveText('Active users'); + await expect(page.locator(`${surface} .kpi-value`).nth(0)).toHaveText('13K'); + await expect(page.locator(`${surface} .kpi-value`).nth(1)).toHaveText('99.95%'); + await expect(page.locator(`${surface} .kpi-delta`)).toHaveText('↑ 0.08 pp'); + await expect(page.locator(`${surface} .kpi-grid`)).toHaveAttribute('aria-label', 'Key performance indicators'); + } + }); + + test('uses bounded horizontal cards and natural Report tile height', async ({ page }) => { + const boxes = await page.locator('#dashboard .kpi-card').evaluateAll((nodes) => + nodes.map((node) => node.getBoundingClientRect())); + expect(Math.abs(boxes[1].top - boxes[0].top)).toBeLessThan(1); + expect(boxes[1].left).toBeGreaterThan(boxes[0].right); + expect(boxes[0].width).toBeGreaterThanOrEqual(220); + expect(boxes[0].width).toBeLessThanOrEqual(280); + expect(Math.abs(boxes[1].height - boxes[0].height)).toBeLessThan(1); + expect(await page.locator('#dashboard .dash-tile').evaluate((node) => node.getBoundingClientRect().height)).toBeLessThan(300); + }); + + test('wraps to one card per row on a narrow mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }); + const boxes = await page.locator('#workbench .kpi-card').evaluateAll((nodes) => nodes.map((node) => node.getBoundingClientRect())); + expect(boxes[1].top).toBeGreaterThan(boxes[0].bottom); + expect(boxes[0].width).toBeGreaterThan(250); + }); + + test('shows visible no-data and row-count diagnostics', async ({ page }) => { + await page.evaluate(() => window.__renderDiagnostic(0)); + await expect(page.locator('#workbench [role="status"]')).toHaveText('No data'); + await page.evaluate(() => window.__renderDiagnostic(2)); + await expect(page.locator('#workbench [role="alert"]')).toHaveText('Expected 1 row, got 2'); + }); +}); diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 41c97deb..a5746f7d 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -439,6 +439,37 @@ describe('query run', () => { app.renderApp(); return { app, e }; } + it('runs an explicit KPI with owned typed streaming and renders the shared cards', async () => { + const { app } = appForRun([ + [(u, sql) => /SELECT 42/.test(sql), resp({ body: streamBody([ + '{"meta":[{"name":"users","type":"UInt64"}]}\n', '{"row":{"users":42}}\n', + ]) })], + ]); + const tab = app.activeTab(); + tab.sqlDraft = 'SELECT 42 AS users'; + tab.specParsed.panel = { cfg: { type: 'kpi' }, fieldConfig: { columns: { users: { displayName: 'Active users' } } } }; + tab.specText = JSON.stringify(tab.specParsed); + app.state.resultView.value = 'panel'; + await app.actions.run(); + const request = app.chCtx.fetch.mock.calls.find(([, init]) => /SELECT 42/.test(init.body)); + expect(request[0]).toContain('default_format=JSONEachRowWithProgress'); + expect(request[0]).toContain('output_format_json_named_tuples_as_objects=1'); + expect(request[0]).toContain('output_format_json_quote_decimals=1'); + expect(request[0]).toContain('max_result_rows=2'); + expect(app.dom.resultsRegion.querySelector('.kpi-label').textContent).toBe('Active users'); + expect(app.dom.resultsRegion.querySelector('.kpi-value').textContent).toBe('42'); + }); + it('blocks an explicit KPI query with authored FORMAT before fetch', async () => { + const { app } = appForRun([]); + const tab = app.activeTab(); + tab.sqlDraft = 'SELECT 1 FORMAT CSV'; + tab.specParsed.panel = { cfg: { type: 'kpi' } }; + tab.specText = JSON.stringify(tab.specParsed); + await app.actions.run(); + expect(app.chCtx.fetch.mock.calls.some(([, init]) => init?.body === 'SELECT 1 FORMAT CSV')).toBe(false); + expect(tab.result.error).toBe('KPI panel owns the result format. Remove FORMAT CSV from the SQL.'); + expect(app.state.resultView.value).toBe('panel'); + }); it('runs a streaming query and records history', async () => { const { app } = appForRun([ [(u, sql) => /SELECT 1/.test(sql), resp({ body: streamBody(['{"meta":[{"name":"a","type":"UInt8"}]}\n', '{"row":{"a":"1"}}\n']) })], diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index 57848bd2..9f1597e1 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -419,6 +419,18 @@ describe('loadEntityDoc (#27 — lazy hover docs)', () => { }); describe('runQuery', () => { + it('uses typed progress streaming for the KPI transport alias', async () => { + const ctx = ctxWith(async () => streamResp([ + '{"meta":[{"name":"metric","type":"Tuple(value Decimal(38, 2), delta Decimal(38, 2))"}]}\n', + '{"row":{"metric":{"value":"9007199254740993.25","delta":"-9007199254740993.25"}}}\n', + ])); + const lines = []; + await runQuery(ctx, 'SELECT 1', { format: 'KPI', params: { output_format_json_named_tuples_as_objects: 1, output_format_json_quote_decimals: 1 }, onLine: (line) => lines.push(line) }); + expect(ctx.fetch.mock.calls[0][0]).toContain('default_format=JSONEachRowWithProgress'); + expect(ctx.fetch.mock.calls[0][0]).toContain('output_format_json_named_tuples_as_objects=1'); + expect(ctx.fetch.mock.calls[0][0]).toContain('output_format_json_quote_decimals=1'); + expect(lines[1].row.metric).toEqual({ value: '9007199254740993.25', delta: '-9007199254740993.25' }); + }); it('streams lines and reports an error result on !ok', async () => { const ctx = ctxWith(async () => textResp('{"exception":"boom"}', false, 500)); const out = await runQuery(ctx, 'bad', { format: 'Table' }); diff --git a/tests/unit/dashboard.test.js b/tests/unit/dashboard.test.js index e8097b6e..d9c37670 100644 --- a/tests/unit/dashboard.test.js +++ b/tests/unit/dashboard.test.js @@ -225,7 +225,7 @@ describe('renderDashboard', () => { expect(app.root.querySelector('.dash-fav').textContent).toContain('1 favorite'); }); - it('skips single-row (KPI) favorites and notes how many are not shown', async () => { + it('auto-renders eligible single-row favorites as KPI cards', async () => { const favorites = [ { id: '1', name: 'Chart', sql: 'chart', favorite: true }, { id: '2', name: 'Kpi', sql: 'kpi', favorite: true }, @@ -233,14 +233,13 @@ describe('renderDashboard', () => { const runTile = vi.fn(async (sql) => (sql === 'kpi' ? kpiResult() : chartResult())); const app = dashApp(favorites, runTile); await renderDashboard(app); - // Stable per-favorite slots (#149 D3): a skipped tile's card stays in the - // DOM (its identity is preserved for a later filter re-run) but hidden. const tiles = [...app.root.querySelectorAll('.dash-tile')]; expect(tiles.length).toBe(2); - expect(tiles.filter((t) => t.style.display !== 'none')).toHaveLength(1); + expect(tiles.filter((t) => t.style.display !== 'none')).toHaveLength(2); + expect(tiles[1].querySelector('.kpi-value').textContent).toBe('42'); + expect(tiles[1].classList.contains('is-kpi')).toBe(true); const note = app.root.querySelector('.dash-skip'); - expect(note.style.display).toBe(''); - expect(note.textContent).toBe('1 not shown'); + expect(note.style.display).toBe('none'); }); it('shows a per-tile error when the query fails', async () => { @@ -312,15 +311,16 @@ describe('renderDashboard', () => { expect(charts[0].destroyed).toBe(true); // prior instance destroyed, not orphaned }); - it('a tile that flips chart -> skip on Refresh clears its old chart DOM (no dead canvas lingers)', async () => { + it('a tile that flips chart -> KPI on Refresh clears its old chart DOM', async () => { const runTile = vi.fn(async () => chartResult()); const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], runTile); await renderDashboard(app); expect(app.root.querySelector('.dash-tile canvas')).not.toBeNull(); - runTile.mockImplementation(async () => kpiResult()); // next refresh becomes a skip (KPI) + runTile.mockImplementation(async () => kpiResult()); await app.root.querySelector('.dash-btn').onclick(); - expect(app.root.querySelector('.dash-tile').style.display).toBe('none'); + expect(app.root.querySelector('.dash-tile').style.display).toBe(''); expect(app.root.querySelector('.dash-tile canvas')).toBeNull(); // stale chart DOM cleared, not just hidden + expect(app.root.querySelector('.kpi-card')).not.toBeNull(); }); it('Refresh marks every tile loading immediately (no stale content lingers beyond the concurrency window)', async () => { @@ -528,6 +528,25 @@ describe('renderDashboard — streaming seam (#193)', () => { expect(opts.signal).toBeTruthy(); // an AbortController signal → real per-tile cancellation }); + it('uses the same owned typed transport and two-row sentinel for an explicit KPI', async () => { + const app = dashApp([{ id: '1', name: 'KPI', sql: 'SELECT 42 AS n', favorite: true, panel: { cfg: { type: 'kpi' } } }], vi.fn(async () => kpiResult())); + await renderDashboard(app); + const [result, opts] = app.runReadInto.mock.calls[0]; + expect(result.rawFormat).toBe('KPI'); + expect(result.rowLimit).toBe(2); + expect(opts).toMatchObject({ format: 'KPI', rowLimit: 2 }); + expect(opts.params).toMatchObject({ readonly: 2, output_format_json_named_tuples_as_objects: 1, output_format_json_quote_decimals: 1 }); + expect(app.root.querySelector('.kpi-value').textContent).toBe('42'); + expect(app.root.querySelector('.dash-tile').classList.contains('is-kpi')).toBe(true); + }); + + it('uses the KPI-specific authored FORMAT diagnostic and sends no request', async () => { + const app = dashApp([{ id: '1', name: 'KPI', sql: 'SELECT 1 FORMAT CSV', favorite: true, panel: { cfg: { type: 'kpi' } } }], vi.fn()); + await renderDashboard(app); + expect(app.runReadInto).not.toHaveBeenCalled(); + expect(app.root.querySelector('.dash-tile-error').textContent).toBe('KPI panel owns the result format. Remove FORMAT CSV from the SQL.'); + }); + it('exactly-CAP is not truncated; CAP+1 is trimmed AND flagged (req 1, via the real applyStreamLine)', async () => { // Stream N single-column rows through the REAL accumulator so the client cap // (newResult('Table', CAP)) trims + flags exactly as production would. @@ -876,22 +895,24 @@ describe('renderDashboard — panel tiles (#166, absorbs #164 D9)', () => { expect(app.root.querySelector('.dash-tile canvas')).not.toBeNull(); // fell back to the auto chart }); - it('an explicit single-row table panel renders (only unconfigured single rows are KPI-skipped)', async () => { + it('an explicit single-row table panel remains a table instead of auto-selecting KPI', async () => { const app = oneFav(vi.fn(async () => kpiResult()), { panel: { cfg: { type: 'table' } } }); await renderDashboard(app); expect(app.root.querySelector('.dash-tile').style.display).not.toBe('none'); expect(app.root.querySelectorAll('.res-table tbody tr')).toHaveLength(1); }); - it('a tile that flips table → skip on Refresh clears its old grid DOM', async () => { + it('a tile that flips table → KPI on Refresh clears its old grid DOM', async () => { const runTile = vi.fn(async () => tableResult()); const app = oneFav(runTile); await renderDashboard(app); expect(app.root.querySelector('.res-table-wrap')).not.toBeNull(); - runTile.mockImplementation(async () => kpiResult()); // next refresh becomes a skip (KPI) + runTile.mockImplementation(async () => kpiResult()); await app.root.querySelector('.dash-btn').onclick(); - expect(app.root.querySelector('.dash-tile').style.display).toBe('none'); + expect(app.root.querySelector('.dash-tile').style.display).toBe(''); expect(app.root.querySelector('.res-table-wrap')).toBeNull(); // stale grid DOM cleared, not just hidden + expect(app.root.querySelector('.kpi-card')).not.toBeNull(); + expect(app.root.querySelector('.dash-tile').classList.contains('is-kpi')).toBe(true); }); it('grid/logs tiles cap displayed rows at DASH_TABLE_DISPLAY_CAP with the in-body footer', async () => { diff --git a/tests/unit/format.test.js b/tests/unit/format.test.js index 85d69cdd..ac756cc0 100644 --- a/tests/unit/format.test.js +++ b/tests/unit/format.test.js @@ -169,9 +169,15 @@ describe('detectSqlFormat', () => { it('returns null without a trailing FORMAT clause', () => { expect(detectSqlFormat('SELECT 1')).toBeNull(); expect(detectSqlFormat("SELECT 'FORMAT JSON' AS x")).toBeNull(); // FORMAT not the trailing clause + expect(detectSqlFormat('SELECT * FROM (SELECT 1 FORMAT JSON)')).toBeNull(); + expect(detectSqlFormat('SELECT format(1)')).toBeNull(); expect(detectSqlFormat('')).toBeNull(); expect(detectSqlFormat(null)).toBeNull(); }); + it('ignores comments/strings and detects only a trailing top-level FORMAT', () => { + expect(detectSqlFormat("SELECT 'FORMAT CSV', 1 FORMAT JSON -- note")).toBe('JSON'); + expect(detectSqlFormat('SELECT 1 /* FORMAT TSV */ FORMAT CSV')).toBe('CSV'); + }); }); describe('prepareExportSql', () => { diff --git a/tests/unit/kpi-panel.test.js b/tests/unit/kpi-panel.test.js new file mode 100644 index 00000000..71be45ca --- /dev/null +++ b/tests/unit/kpi-panel.test.js @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { renderKpiPanel } from '../../src/ui/kpi-panel.js'; + +const item = (over = {}) => ({ + columnName: 'value', value: 12.4, valueType: 'Float64', delta: null, deltaType: null, + presentation: { displayName: 'Value', noValue: '—', delta: {} }, ...over, +}); + +describe('renderKpiPanel', () => { + it('renders accessible cards, descriptions, colors, and semantic deltas', () => { + const node = renderKpiPanel({ + items: [item({ + delta: -1.5, deltaType: 'Float64', + presentation: { displayName: 'Availability', description: 'Current service level', decimals: 1, unit: '%', color: '#123456', noValue: '—', delta: { displayName: 'Change', decimals: 1, unit: ' pp', positiveIsGood: false } }, + })], + diagnostics: [{ severity: 'warning', code: 'warn', message: 'Ignored region' }], + }); + expect(node.querySelector('.kpi-grid').getAttribute('aria-label')).toBe('Key performance indicators'); + expect(node.querySelector('.kpi-card').getAttribute('aria-label')).toBe('Availability'); + expect(node.querySelector('.kpi-card').style.getPropertyValue('--kpi-accent')).toBe('#123456'); + expect(node.querySelector('.kpi-value').textContent).toBe('12.4%'); + expect(node.querySelector('.kpi-description').textContent).toBe('Current service level'); + expect(node.querySelector('.kpi-delta').classList.contains('is-good')).toBe(true); + expect(node.querySelector('.kpi-delta').textContent).toBe('↓ Change 1.5 pp'); + expect(node.querySelector('.kpi-warnings').textContent).toContain('Ignored region'); + }); + it('renders no-data and errors as visible states', () => { + const noData = renderKpiPanel({ items: [], diagnostics: [{ severity: 'info', code: 'kpi-no-data', message: 'No data' }] }); + expect(noData.querySelector('[role="status"]').textContent).toBe('No data'); + const error = renderKpiPanel({ items: [], diagnostics: [{ severity: 'error', code: 'kpi-row-count', message: 'Expected 1 row, got 2' }] }); + expect(error.querySelector('[role="alert"]').textContent).toContain('got 2'); + }); + it('renders a neutral flat delta and tolerates a missing normalization result', () => { + const node = renderKpiPanel({ items: [item({ delta: 0, deltaType: 'Int8' })], diagnostics: [] }); + expect(node.querySelector('.kpi-delta').textContent).toBe('→ 0'); + expect(node.querySelector('.kpi-delta').classList.contains('is-neutral')).toBe(true); + expect(renderKpiPanel(null).querySelectorAll('.kpi-card')).toHaveLength(0); + }); + it('preserves exact large integer delta text', () => { + const node = renderKpiPanel({ + items: [item({ + delta: '-9007199254740993', deltaType: 'Int64', + presentation: { displayName: 'Value', noValue: '—', delta: { decimals: 0 } }, + })], diagnostics: [], + }); + expect(node.querySelector('.kpi-delta').textContent).toBe('↓ 9007199254740993'); + expect(node.querySelector('.kpi-delta').getAttribute('aria-label')).toContain('9007199254740993'); + }); +}); diff --git a/tests/unit/kpi.test.js b/tests/unit/kpi.test.js new file mode 100644 index 00000000..720b90ec --- /dev/null +++ b/tests/unit/kpi.test.js @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { formatKpiValue, isKpiNumericType, kpiDeltaState, parseKpiTupleType, readKpiFields, resolveKpiPresentation } from '../../src/core/kpi.js'; + +describe('KPI ClickHouse types', () => { + it('recognizes numeric families and nullable wrappers only', () => { + for (const type of ['Int8', 'UInt256', 'Float32', 'Float64', 'BFloat16', 'Decimal(20, 4)', 'Decimal128(2)', 'Nullable(Nullable(UInt64))']) expect(isKpiNumericType(type), type).toBe(true); + for (const type of ['', 'String', 'Array(UInt8)', 'DateTime', 'Tuple(UInt8, UInt8)']) expect(isKpiNumericType(type), type).toBe(false); + }); + it('parses named tuples with nested types and rejects positional tuples', () => { + expect(parseKpiTupleType('Tuple(value Decimal(10, 2), delta Nullable(Float64), extra Array(UInt8))')).toEqual([ + { name: 'value', type: 'Decimal(10, 2)' }, { name: 'delta', type: 'Nullable(Float64)' }, { name: 'extra', type: 'Array(UInt8)' }, + ]); + expect(parseKpiTupleType('Nullable(Tuple(`value` UInt64, "delta" Int8))')).toEqual([{ name: 'value', type: 'UInt64' }, { name: 'delta', type: 'Int8' }]); + expect(parseKpiTupleType('Tuple(UInt64, Float64)')).toBeNull(); + expect(parseKpiTupleType('String')).toBeNull(); + expect(parseKpiTupleType('Tuple()')).toBeNull(); + }); +}); + +describe('KPI presentation and formatting', () => { + it('clones defaults and overrides while merging delta independently', () => { + const fieldConfig = { defaults: { decimals: 1, noValue: 'n/a', future: { x: 1 }, delta: { unit: ' pp', positiveIsGood: true } }, columns: { score: { displayName: 'Score', unit: '%', future: { y: 2 }, delta: { decimals: 2 } } } }; + const out = resolveKpiPresentation({ fieldConfig, columnName: 'score' }); + expect(out).toEqual({ decimals: 1, noValue: 'n/a', displayName: 'Score', unit: '%', future: { y: 2 }, delta: { unit: ' pp', positiveIsGood: true, decimals: 2 } }); + out.future.y = 9; + expect(fieldConfig.columns.score.future.y).toBe(2); + expect(resolveKpiPresentation({ fieldConfig: null, columnName: 'x' })).toEqual({ displayName: 'x', noValue: '—', delta: {} }); + }); + it('formats compact integers, decimals, units, null, negative zero, and invalid values', () => { + expect(formatKpiValue({ value: 999, clickhouseType: 'UInt64' })).toBe('999'); + expect(formatKpiValue({ value: 1500, clickhouseType: 'UInt64' })).toBe('1.5K'); + expect(formatKpiValue({ value: 20_000, clickhouseType: 'Int64' })).toBe('20K'); + expect(formatKpiValue({ value: 999_500, clickhouseType: 'UInt64' })).toBe('1M'); + expect(formatKpiValue({ value: 999_999_999, clickhouseType: 'UInt64' })).toBe('1B'); + expect(formatKpiValue({ value: 1_500_000, clickhouseType: 'UInt64' })).toBe('1.5M'); + expect(formatKpiValue({ value: 2_000_000_000, clickhouseType: 'UInt64' })).toBe('2B'); + expect(formatKpiValue({ value: '12.40', clickhouseType: 'Decimal(10,2)' })).toBe('12.4'); + expect(formatKpiValue({ value: '9007199254740993', clickhouseType: 'UInt64', presentation: { decimals: 0 } })).toBe('9007199254740993'); + expect(formatKpiValue({ value: '-9007199254740993', clickhouseType: 'Int64', presentation: { decimals: 2 } })).toBe('-9007199254740993.00'); + expect(formatKpiValue({ value: '9007199254740993.255', clickhouseType: 'Decimal(30,3)', presentation: { decimals: 2 } })).toBe('9007199254740993.26'); + expect(formatKpiValue({ value: 12.345, clickhouseType: 'Float64', presentation: { decimals: 2, unit: '%' } })).toBe('12.35%'); + expect(formatKpiValue({ value: -0, clickhouseType: 'Float64' })).toBe('0'); + expect(formatKpiValue({ value: -0.001, clickhouseType: 'Float64' })).toBe('0'); + expect(formatKpiValue({ value: -0.001, clickhouseType: 'Float64', presentation: { decimals: 2 } })).toBe('0.00'); + expect(formatKpiValue({ value: '-0.001', clickhouseType: 'Decimal(8,3)', presentation: { decimals: 2 } })).toBe('0.00'); + expect(formatKpiValue({ value: null, clickhouseType: 'UInt64', presentation: { noValue: 'None' } })).toBe('None'); + expect(formatKpiValue({ value: Infinity, clickhouseType: 'Float64' })).toBe('—'); + expect(formatKpiValue({ value: 'nope', clickhouseType: 'Float64' })).toBe('—'); + expect(formatKpiValue({ value: false, clickhouseType: 'UInt8' })).toBe('—'); + expect(formatKpiValue({ value: 5n, clickhouseType: 'UInt64' })).toBe('5'); + }); + it('derives delta direction and good/bad/neutral semantics', () => { + const item = (delta, config = {}) => ({ delta, presentation: { delta: config } }); + expect(kpiDeltaState(item(2, { positiveIsGood: true }))).toEqual({ value: 2, direction: 'up', semantic: 'good' }); + expect(kpiDeltaState(item(-2, { positiveIsGood: true }))).toEqual({ value: -2, direction: 'down', semantic: 'bad' }); + expect(kpiDeltaState(item(2, { positiveIsGood: false })).semantic).toBe('bad'); + expect(kpiDeltaState(item(0, { positiveIsGood: false })).semantic).toBe('neutral'); + expect(kpiDeltaState(item(2)).semantic).toBe('neutral'); + expect(kpiDeltaState(item('-9007199254740993'))).toEqual({ value: '-9007199254740993', direction: 'down', semantic: 'neutral' }); + expect(kpiDeltaState(item(null))).toBeNull(); + expect(kpiDeltaState(item('bad'))).toBeNull(); + expect(kpiDeltaState(item(2, { show: false }))).toBeNull(); + }); +}); + +describe('readKpiFields', () => { + it('handles row counts before reading fields', () => { + expect(readKpiFields({ rowCount: 0 }).diagnostics[0]).toMatchObject({ code: 'kpi-no-data', severity: 'info' }); + expect(readKpiFields({ rowCount: 3 }).diagnostics[0]).toMatchObject({ code: 'kpi-row-count', message: 'Expected 1 row, got 3' }); + expect(readKpiFields().diagnostics[0].code).toBe('kpi-no-data'); + }); + it('reads scalar and tuple cards in result order with metadata', () => { + const columns = [{ name: 'users', type: 'UInt64' }, { name: 'availability', type: 'Tuple(delta Nullable(Float64), value Decimal(6,2), ignored String)' }, { name: 'region', type: 'String' }]; + const fieldConfig = { defaults: { decimals: 1, delta: { unit: ' pp' } }, columns: { users: { displayName: 'Active users' }, availability: { unit: '%' }, stale: { hidden: true } } }; + const out = readKpiFields({ columns, row: [42, { value: '99.95', delta: null, ignored: 'x' }, 'EU'], rowCount: 1, fieldConfig, serverVersion: '26.3' }); + expect(out.items.map((item) => [item.columnName, item.kind, item.value, item.delta])).toEqual([['users', 'scalar', 42, null], ['availability', 'tuple', '99.95', null]]); + expect(out.items[0].presentation.displayName).toBe('Active users'); + expect(out.items[1].presentation.delta.unit).toBe(' pp'); + expect(out.diagnostics.map((d) => d.code)).toEqual(['kpi-missing-field-metadata-target', 'kpi-unsupported-field']); + }); + it('skips hidden and invalid tuple fields with stable diagnostics', () => { + const columns = [{ name: 'hidden', type: 'UInt64' }, { name: 'missing', type: 'Tuple(delta Float64)' }, { name: 'bad_value', type: 'Tuple(value String)' }, { name: 'bad_delta', type: 'Tuple(value UInt64, delta String)' }, { name: 'positional', type: 'Tuple(UInt64, Float64)' }]; + const out = readKpiFields({ columns, row: [1, { delta: 1 }, { value: 'x' }, { value: 7, delta: 'x' }, [1, 2]], rowCount: 1, fieldConfig: { columns: { hidden: { hidden: true } } } }); + expect(out.items).toHaveLength(1); + expect(out.items[0]).toMatchObject({ columnName: 'bad_delta', value: 7, delta: null }); + expect(out.diagnostics.map((d) => d.code)).toEqual(['kpi-missing-tuple-value', 'kpi-nonnumeric-tuple-value', 'kpi-nonnumeric-delta', 'kpi-unsupported-field']); + }); + it('reports no eligible fields and supports object-shaped rows', () => { + const none = readKpiFields({ columns: [{ name: 's', type: 'String' }], row: { s: 'x' }, rowCount: 1 }); + expect(none.items).toEqual([]); + expect(none.diagnostics.at(-1).code).toBe('kpi-no-eligible-fields'); + const object = readKpiFields({ columns: [{ name: 'n', type: 'Nullable(Int32)' }], row: { n: null }, rowCount: 1 }); + expect(object.items[0].value).toBeNull(); + const tupleString = readKpiFields({ columns: [{ name: 't', type: 'Tuple(value UInt64)' }], row: ['(42)'], rowCount: 1, serverVersion: '24.3' }); + expect(tupleString.items).toEqual([]); + expect(tupleString.diagnostics.map((item) => item.code)).toEqual(['kpi-server-named-tuple-unsupported', 'kpi-no-eligible-fields']); + expect(tupleString.diagnostics[0].message).toContain('ClickHouse 24.3'); + }); +}); diff --git a/tests/unit/panel-cfg.test.js b/tests/unit/panel-cfg.test.js index 882e8155..521a7d77 100644 --- a/tests/unit/panel-cfg.test.js +++ b/tests/unit/panel-cfg.test.js @@ -128,6 +128,13 @@ describe('normalizePanelCfg', () => { }); describe('autoPanel', () => { + it('selects KPI only for an eligible one-row result, after logs', () => { + expect(autoPanel({ columns: [{ name: 'n', type: 'UInt64' }], rows: [[42]] }).cfg).toEqual({ type: 'kpi' }); + expect(autoPanel({ columns: [{ name: 'n', type: 'UInt64' }], rows: [[1], [2]] }).cfg.type).not.toBe('kpi'); + const logs = [{ name: 'event_time', type: 'DateTime' }, { name: 'message', type: 'String' }]; + expect(autoPanel({ columns: logs, rows: [['2026-01-01', 'x']] }).cfg.type).toBe('logs'); + expect(autoPanel({ columns: [{ name: 't', type: 'Tuple(value UInt64)' }], rows: [['(42)']] }).cfg.type).toBe('table'); + }); it('log-shaped outranks chartable (thread_id would auto-chart otherwise)', () => { const cols = [...logCols, { name: 'thread_id', type: 'UInt64' }]; const out = autoPanel(cols); @@ -191,6 +198,15 @@ describe('switchPanelType', () => { }); describe('resolvePanel', () => { + it('retains explicit KPI with normalized result diagnostics instead of falling back', () => { + const one = resolvePanel({ cfg: { type: 'kpi' }, fieldConfig: { columns: { n: { unit: '%' } } } }, { columns: [{ name: 'n', type: 'UInt64' }], rows: [[7]] }); + expect(one).toMatchObject({ cfg: { type: 'kpi' }, fallback: false }); + expect(one.kpi.items[0].presentation.unit).toBe('%'); + const many = resolvePanel({ cfg: { type: 'kpi' } }, { columns: [{ name: 'n', type: 'UInt64' }], rows: [[1], [2]] }); + expect(many.cfg.type).toBe('kpi'); + expect(many.kpi.diagnostics[0].code).toBe('kpi-row-count'); + expect(resolvePanel({ cfg: { type: 'kpi' } }, []).kpi).toBeNull(); + }); it('no saved panel → autoPanel, not a fallback', () => { const out = resolvePanel(undefined, chartCols); expect(out.cfg.type).toBe('hbar'); diff --git a/tests/unit/panel-execution.test.js b/tests/unit/panel-execution.test.js new file mode 100644 index 00000000..006d9e84 --- /dev/null +++ b/tests/unit/panel-execution.test.js @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { isKpiPanel, panelExecution } from '../../src/core/panel-execution.js'; + +describe('panel execution ownership', () => { + it('leaves non-KPI execution unchanged', () => { + expect(isKpiPanel(null)).toBe(false); + expect(isKpiPanel({ cfg: { type: 'table' } })).toBe(false); + expect(isKpiPanel({ cfg: { type: 'kpi' } })).toBe(true); + expect(panelExecution({ cfg: { type: 'chart' } }, 'SELECT 1', { format: 'Table', rowLimit: 99, params: { x: 1 } })).toEqual({ format: 'Table', rowLimit: 99, params: { x: 1 }, owned: false, error: null }); + expect(panelExecution(null, 'SELECT 1')).toEqual({ owned: false, error: null, params: {} }); + }); + it('selects bounded typed KPI streaming with named tuples as objects', () => { + expect(panelExecution({ cfg: { type: 'kpi' } }, 'SELECT 1', { format: 'Table', params: { readonly: 2 } })).toEqual({ format: 'KPI', rowLimit: 2, params: { readonly: 2, output_format_json_named_tuples_as_objects: 1, output_format_json_quote_decimals: 1 }, owned: true, error: null }); + expect(panelExecution({ cfg: { type: 'kpi' } }, 'SELECT 1')).toEqual({ format: 'KPI', rowLimit: 2, params: { output_format_json_named_tuples_as_objects: 1, output_format_json_quote_decimals: 1 }, owned: true, error: null }); + }); + it('blocks a trailing top-level authored FORMAT without changing defaults', () => { + const out = panelExecution({ cfg: { type: 'kpi' } }, 'SELECT 1 FORMAT CSV -- authored', { format: 'Table', params: { p: 1 } }); + expect(out).toMatchObject({ format: 'Table', owned: true, params: { p: 1 } }); + expect(out.error).toBe('KPI panel owns the result format. Remove FORMAT CSV from the SQL.'); + expect(panelExecution({ cfg: { type: 'kpi' } }, 'SELECT 1 FORMAT JSON').params).toEqual({}); + }); +}); diff --git a/tests/unit/panels.test.js b/tests/unit/panels.test.js index fd8f01d8..f2193985 100644 --- a/tests/unit/panels.test.js +++ b/tests/unit/panels.test.js @@ -279,12 +279,12 @@ describe('Panel drawer tab', () => { expect(region(app).querySelector('.panel-note.is-fallback')).toBeNull(); expect(region(app).querySelector('.dash-logs .log-row')).not.toBeNull(); }); - it('rescue (#195): a chart fallback shows Logs as the picker type, stays read-only, and cannot write panelCfg', () => { + it('rescue (#195): a KPI fallback shows Logs as the picker type, stays read-only, and cannot write panelCfg', () => { const app = panelApp(noMessageChartResult(), { type: 'logs' }); renderResults(app); - // Fallback diagnostic + a chart (not Table) preview... + // Fallback diagnostic + the current auto-panel preview... expect(region(app).textContent).toContain('no time + message columns'); - expect(region(app).querySelector('.chart-view canvas')).not.toBeNull(); + expect(region(app).querySelector('.kpi-card')).not.toBeNull(); expect(region(app).querySelector('.res-table')).toBeNull(); // ...the toolbar picker still reads Logs, the authoring type... expect(region(app).querySelector('.result-panel-select').value).toBe('logs'); @@ -292,7 +292,7 @@ describe('Panel drawer tab', () => { const configRows = region(app).querySelectorAll('.panel-config .chart-config'); expect(configRows).toHaveLength(1); expect([...configRows[0].querySelectorAll('select')]).toHaveLength(3); - // ...and the fallback chart itself exposes no X/Y/Series controls. + // ...and the fallback renderer exposes no X/Y/Series controls. const labels = [...region(app).querySelectorAll('.chart-field-label')].map((s) => s.textContent); expect(labels).not.toContain('X'); expect(labels).not.toContain('Y'); @@ -302,6 +302,15 @@ describe('Panel drawer tab', () => { expect(app.activeTab().panelKey).toBeNull(); expect(app.activeTab().dirtySpec).toBe(false); }); + it('shows KPI field presentation guidance without adding tuning controls', () => { + const result = newResult('KPI'); + result.columns = [{ name: 'n', type: 'UInt64' }]; + result.rows = [[42]]; + const app = panelApp(result, { type: 'kpi' }); + renderResults(app); + expect(region(app).querySelector('.panel-authoring-hint').textContent).toContain('Spec → panel.fieldConfig'); + expect(region(app).querySelector('.panel-config select')).toBeNull(); + }); it('rescue (#195): repairing Message from a chart fallback preserves type:logs and ends the rescue', () => { const app = panelApp(noMessageChartResult(), { type: 'logs' }); renderResults(app); diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index 760d9b18..bd5b7562 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -751,6 +751,41 @@ describe('expandDataPane', () => { expect(app.actions.copySnapshot.mock.calls.at(-1)[0].rows).toEqual([['Warning']]); }); + it('Refresh gives an explicit KPI panel ownership of transport and the two-row guard', async () => { + const app = makeApp({ runReadInto: vi.fn(async (result) => result) }); + app.activeTab().specParsed.panel = { cfg: { type: 'kpi' } }; + app.state.varValues.level = 'Warning'; + expandDataPane(app, paramResult()); + const overlay = document.querySelector('.graph-overlay'); + click(refreshBtn(overlay)); + await tick(); + + const opts = app.runReadInto.mock.calls[0][1]; + expect(opts.format).toBe('KPI'); + expect(opts.rowLimit).toBe(2); + expect(opts.params).toEqual({ + param_level: 'Warning', + output_format_json_named_tuples_as_objects: 1, + output_format_json_quote_decimals: 1, + }); + }); + + it('Refresh blocks authored FORMAT when an explicit KPI panel owns transport', async () => { + const app = makeApp({ runReadInto: vi.fn(async (result) => result) }); + app.activeTab().specParsed.panel = { cfg: { type: 'kpi' } }; + const result = paramResult(); + result.source.sql += ' FORMAT CSV'; + app.state.varValues.level = 'Warning'; + expandDataPane(app, result); + const overlay = document.querySelector('.graph-overlay'); + click(refreshBtn(overlay)); + await tick(); + + expect(app.runReadInto).not.toHaveBeenCalled(); + expect(overlay.querySelector('.detached-status').textContent) + .toBe('KPI panel owns the result format. Remove FORMAT CSV from the SQL.'); + }); + it('blocks the rerun and keeps the previous result + a status when a required value is missing', async () => { const app = makeApp(); // default no-op runReadInto expandDataPane(app, paramResult()); // level unset → missing @@ -931,7 +966,7 @@ describe('expandDataPane', () => { expect(app.actions.copySnapshot.mock.calls.at(-1)[0].rows).toEqual([['NEW']]); // Copy = NEW result }); - it('Panel: streaming chunks do not churn the chart; a successful commit destroys the old chart once and creates one replacement', async () => { + it('Panel: streaming chunks do not churn the chart; a successful one-row commit switches once to KPI', async () => { const run = deferredRun(); const app = makeApp({ runReadInto: run.fn }); const instances = []; @@ -951,11 +986,12 @@ describe('expandDataPane', () => { run.last.chunk({ progress: { rows: 200, bytes: 0, elapsed_ns: 0 } }); expect(chart0.destroyed).toBe(false); // not churned by chunks expect(instances).toHaveLength(1); // no per-chunk chart rebuild - // resolve successfully → one destroy + one replacement. + // resolve successfully → one destroy; the eligible one-row result becomes KPI. run.last.finish({ columns: chartResult().columns, rows: [['B6', 'E', '30', '1.1']] }); await tick(); expect(chart0.destroyed).toBe(true); - expect(instances).toHaveLength(2); + expect(instances).toHaveLength(1); + expect(overlay.querySelector('.kpi-card')).not.toBeNull(); }); it('a current-generation cancelled result never replaces the committed result and records nothing', async () => { diff --git a/tests/unit/spec-completion.test.js b/tests/unit/spec-completion.test.js index 384191fd..f2d48708 100644 --- a/tests/unit/spec-completion.test.js +++ b/tests/unit/spec-completion.test.js @@ -39,7 +39,7 @@ describe('pure Spec completion', () => { positionKind: 'property-value', }); expect(items.filter((item) => item.kind === 'variant').map((item) => item.label)).toEqual([ - 'bar', 'hbar', 'line', 'area', 'pie', 'table', 'logs', 'text', + 'bar', 'hbar', 'line', 'area', 'pie', 'kpi', 'table', 'logs', 'text', ]); expect(items.map((item) => item.label)).not.toContain('future-panel'); expect(items.find((item) => item.label === 'line').documentation).toContain('Line series'); diff --git a/tests/unit/spec-schema.test.js b/tests/unit/spec-schema.test.js index 02b16e7b..f8ede4f0 100644 --- a/tests/unit/spec-schema.test.js +++ b/tests/unit/spec-schema.test.js @@ -11,6 +11,7 @@ const panels = [ { type: 'line', x: 0, y: [1], series: null }, { type: 'area', x: 0, y: [1], series: null }, { type: 'pie', x: 0, y: [1], series: null }, + { type: 'kpi' }, { type: 'table' }, { type: 'logs', time: 'event_time', msg: 'message', level: 'level' }, { type: 'text' }, @@ -42,6 +43,18 @@ describe('canonical query.spec schema', () => { expect(querySpecSchemaService.validate({ panel: { cfg: { type: 'future-gauge', custom: true } } })).toEqual([]); }); + it('validates the KPI presentation contract and rejects malformed known metadata', () => { + expect(querySpecSchemaService.validate({ panel: { cfg: { type: 'kpi', extension: true }, fieldConfig: { + defaults: { displayName: 'Value', description: 'Current', unit: '%', decimals: 2, color: '#123', noValue: '—', hidden: false, delta: { displayName: 'Change', unit: ' pp', decimals: 1, positiveIsGood: true, show: true, future: true } }, + } } })).toEqual([]); + expect(querySpecSchemaService.validate({ panel: { cfg: { type: 'kpi' }, fieldConfig: { defaults: { decimals: 21, delta: { decimals: -1, show: 'yes' } } } } }) + .map((item) => [item.path, item.code])).toEqual([ + [['panel', 'fieldConfig', 'defaults', 'decimals'], 'schema-number-range'], + [['panel', 'fieldConfig', 'defaults', 'delta', 'decimals'], 'schema-number-range'], + [['panel', 'fieldConfig', 'defaults', 'delta', 'show'], 'schema-invalid-type'], + ]); + }); + it('enforces known root, field, dashboard, and panel shapes without rejecting unknown fields', () => { const diagnostics = querySpecSchemaService.validate({ name: ' ', description: 1, favorite: 'yes', view: 'raw', @@ -112,11 +125,11 @@ describe('schema lookup', () => { it('retains ordered candidates while a discriminator is incomplete', () => { const root = { panel: { cfg: {} } }; const schema = querySpecSchemaService.schemaAtPath({ root, path: ['panel', 'cfg'] }); - expect(schema.candidates).toHaveLength(9); + expect(schema.candidates).toHaveLength(10); expect(schema.common['x-altinity-discriminator']).toBe('type'); const typeProperty = querySpecSchemaService.propertiesAtPath({ root, path: ['panel', 'cfg'] })[0]; expect(typeProperty.name).toBe('type'); - expect(typeProperty.schemas).toHaveLength(9); + expect(typeProperty.schemas).toHaveLength(10); expect(typeProperty.schemas.every((candidate) => candidate.title === 'Panel type' && candidate.type === 'string' && candidate.minLength === 1)).toBe(true); const unknown = querySpecSchemaService.annotationsAtPath({ @@ -126,7 +139,7 @@ describe('schema lookup', () => { expect(querySpecSchemaService.variantsAtPath({ root: { panel: { cfg: { type: 'line' } } }, path: ['panel', 'cfg', 'type'], }).map((variant) => variant.value)).toEqual([ - 'bar', 'hbar', 'line', 'area', 'pie', 'table', 'logs', 'text', + 'bar', 'hbar', 'line', 'area', 'pie', 'kpi', 'table', 'logs', 'text', ]); expect(querySpecSchemaService.variantsAtPath({ root: {}, path: [] })).toEqual([]); expect(querySpecSchemaService.variantsAtPath({ root: {}, path: [0] })).toEqual([]); diff --git a/tests/unit/stream.test.js b/tests/unit/stream.test.js index e9469853..2bf29045 100644 --- a/tests/unit/stream.test.js +++ b/tests/unit/stream.test.js @@ -46,6 +46,12 @@ describe('applyStreamLine', () => { applyStreamLine({ row: { a: '1', b: 'x' } }, r); expect(r.rows).toEqual([['1', 'x']]); }); + it('preserves quoted Decimal tuple members exactly', () => { + const r = newResult('KPI'); + applyStreamLine({ meta: [{ name: 'metric', type: 'Tuple(value Decimal(38, 2), delta Decimal(38, 2))' }] }, r); + applyStreamLine({ row: { metric: { value: '9007199254740993.25', delta: '-9007199254740993.25' } } }, r); + expect(r.rows[0][0]).toEqual({ value: '9007199254740993.25', delta: '-9007199254740993.25' }); + }); it('accumulates progress and pct', () => { const r = newResult('Table'); applyStreamLine({ progress: { read_rows: '50', read_bytes: '500', elapsed_ns: '1000', total_rows_to_read: '100' } }, r);