From 5dc9a0c49488b3b64b309d2e1277d2d8a830c5a9 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 22 Jul 2026 18:59:36 +0000 Subject: [PATCH 1/8] feat: add synchronized dashboard time ranges --- CHANGELOG.md | 13 + build/emit-schema-types.mjs | 12 +- .../generated/library-v2.bundle.schema.json | 40 ++- schemas/query-spec-v1.schema.json | 23 +- src/application/saved-query-service.ts | 14 +- src/core/library-codec.ts | 21 +- src/core/param-type.ts | 35 +++ src/core/query-time-range.ts | 93 ++++++ src/core/relative-time.ts | 5 +- src/core/spec-draft.ts | 19 +- src/core/time-range.ts | 177 +++++++++++- .../application/dashboard-viewer-session.ts | 165 ++++++++--- src/dashboard/model/canonical-json.ts | 3 +- src/dashboard/model/workspace-semantics.ts | 5 + src/generated/json-schema-validators.js | 161 +++++++++-- src/generated/json-schema.types.ts | 18 ++ src/generated/json-schemas.js | 40 ++- src/state.ts | 17 +- src/styles.css | 1 + src/ui/app.ts | 4 +- src/ui/chart-render.ts | 9 +- src/ui/dashboard-chart-interaction.ts | 268 ++++++++++++++++++ src/ui/dashboard.ts | 114 ++++++-- src/ui/panels.ts | 7 +- tests/e2e/time-range.html | 67 ++++- tests/e2e/time-range.spec.js | 77 +++++ tests/unit/canonical-json.test.ts | 3 + tests/unit/chart-render.test.ts | 8 + .../unit/dashboard-chart-interaction.test.ts | 266 +++++++++++++++++ tests/unit/dashboard-viewer-session.test.ts | 223 ++++++++++++++- tests/unit/dashboard.test.ts | 126 +++++++- tests/unit/filter-bar.test.ts | 1 + tests/unit/library-codec.test.ts | 12 +- tests/unit/param-type.test.ts | 29 ++ tests/unit/query-time-range.test.ts | 61 ++++ tests/unit/saved-query-service.test.ts | 56 ++++ tests/unit/schema-build.test.js | 3 + tests/unit/spec-completion.test.ts | 4 +- tests/unit/spec-draft.test.ts | 8 +- tests/unit/spec-schema.test.ts | 10 + tests/unit/state.test.ts | 58 ++++ tests/unit/time-range-field.test.ts | 1 + tests/unit/time-range.test.ts | 88 ++++++ tests/unit/workspace-semantics.test.ts | 9 + 44 files changed, 2243 insertions(+), 131 deletions(-) create mode 100644 src/core/query-time-range.ts create mode 100644 src/ui/dashboard-chart-interaction.ts create mode 100644 tests/unit/dashboard-chart-interaction.test.ts create mode 100644 tests/unit/query-time-range.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d763f1a1..fda2ac9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,19 @@ auto-generated per-PR notes; this file is the curated, human-readable history. full validation, with a nightly cross-browser E2E safety run and stable gate. ### Added +- **Saved-query time-range metadata and synchronized Dashboard chart + interaction** (#334). Query Spec v1 now carries zero or one authoritative + `{from,to}` parameter pair. Normal saved-query create/SQL-update flows infer + one conservative recognized date/time pair when metadata is absent, while + explicit `[]` opts out and Dashboard viewing never writes metadata. The + Dashboard derives runtime groups from that saved metadata, synchronizes an + exact-time vertical crosshair across compatible temporal charts, and lets a + plain mouse drag select a forward or reverse range. Selection formats each + bound through its declared Date/Date32/DateTime/DateTime64 contract, commits + and activates both filters atomically, reserves affected generations before + asynchronous preflight/source work, and reruns each dependent tile once; + Command/Ctrl-drag remains tile movement and manual filter inputs remain the + keyboard-accessible fallback. - **Cross-tab workspace consistency: refresh before write and stale-tab invalidation** (#343). Workbench and editable-Dashboard tabs editing the same workspace now stay consistent under normal sequential editing. Every diff --git a/build/emit-schema-types.mjs b/build/emit-schema-types.mjs index 2569eb82..abb74b0d 100644 --- a/build/emit-schema-types.mjs +++ b/build/emit-schema-types.mjs @@ -19,13 +19,14 @@ const IGNORED_KEYWORDS = new Set([ ...ANNOTATION_KEYWORDS, '$schema', '$id', 'title', 'description', 'default', 'examples', 'deprecated', 'minLength', 'maxLength', 'pattern', 'format', - 'minItems', 'maxItems', 'uniqueItems', + 'uniqueItems', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'minProperties', 'maxProperties', 'propertyNames', ]); const HANDLED_KEYWORDS = new Set([ 'type', 'const', 'enum', 'anyOf', 'oneOf', 'allOf', 'not', '$ref', 'properties', 'required', 'additionalProperties', 'unevaluatedProperties', 'items', + 'minItems', 'maxItems', ]); const pascalCase = (name) => String(name).split(/[^A-Za-z0-9]+/).filter(Boolean) @@ -76,7 +77,8 @@ function renderJsdoc(doc, extra = []) { // branch's maxItems refinement of the inherited measure list). Merging keeps // the earlier, richer declaration instead. function isConstraintOnly(schema) { - const keys = Object.keys(schema).filter((key) => !IGNORED_KEYWORDS.has(key)); + const keys = Object.keys(schema).filter((key) => !IGNORED_KEYWORDS.has(key) + && key !== 'minItems' && key !== 'maxItems'); return keys.length === 1 && keys[0] === 'type' && schema.type === 'array' && schema.items === undefined; } @@ -179,6 +181,12 @@ export function buildSchemaTypes(records) { if (type === 'array') { if (schema.items === undefined) return 'unknown[]'; const item = typeExpr(schema.items, record, `${where}.items`); + if (schema.maxItems === 1) { + if (schema.minItems !== undefined && schema.minItems !== 0 && schema.minItems !== 1) { + throw new Error(`Unsupported minItems ${JSON.stringify(schema.minItems)} with maxItems 1 at ${where}`); + } + return schema.minItems === 1 ? `[${item}]` : `[] | [${item}]`; + } return /[|&(]| /.test(item) ? `(${item})[]` : `${item}[]`; } if (type === 'object') return objectExpr(schema, record, where); diff --git a/schemas/generated/library-v2.bundle.schema.json b/schemas/generated/library-v2.bundle.schema.json index 9d665f8f..57699418 100644 --- a/schemas/generated/library-v2.bundle.schema.json +++ b/schemas/generated/library-v2.bundle.schema.json @@ -49,6 +49,15 @@ }, "dashboard": { "$ref": "#/$defs/queryDashboardPresentationV1" + }, + "timeRanges": { + "title": "Time-range parameter semantics", + "description": "Zero or one authoritative From/To parameter pair. Omission allows conservative authoring inference; an empty array explicitly disables inference.", + "type": "array", + "maxItems": 1, + "items": { + "$ref": "#/$defs/queryTimeRangeV1" + } } }, "additionalProperties": true, @@ -58,9 +67,38 @@ "favorite", "view", "panel", - "dashboard" + "dashboard", + "timeRanges" ], "$defs": { + "queryTimeRangeV1": { + "title": "Time-range parameter pair", + "description": "The exact saved-query parameter names used as the lower and upper bounds of one time range.", + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "title": "From parameter", + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "to": { + "title": "To parameter", + "type": "string", + "minLength": 1, + "pattern": "\\S" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "from", + "to" + ] + }, "columnName": { "title": "Result column", "description": "Exact top-level ClickHouse result-column name.", diff --git a/schemas/query-spec-v1.schema.json b/schemas/query-spec-v1.schema.json index 836edf56..b8531be7 100644 --- a/schemas/query-spec-v1.schema.json +++ b/schemas/query-spec-v1.schema.json @@ -34,11 +34,30 @@ "default": "table" }, "panel": { "$ref": "#/$defs/panel" }, - "dashboard": { "$ref": "#/$defs/queryDashboardPresentationV1" } + "dashboard": { "$ref": "#/$defs/queryDashboardPresentationV1" }, + "timeRanges": { + "title": "Time-range parameter semantics", + "description": "Zero or one authoritative From/To parameter pair. Omission allows conservative authoring inference; an empty array explicitly disables inference.", + "type": "array", + "maxItems": 1, + "items": { "$ref": "#/$defs/queryTimeRangeV1" } + } }, "additionalProperties": true, - "x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard"], + "x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard", "timeRanges"], "$defs": { + "queryTimeRangeV1": { + "title": "Time-range parameter pair", + "description": "The exact saved-query parameter names used as the lower and upper bounds of one time range.", + "type": "object", + "required": ["from", "to"], + "properties": { + "from": { "title": "From parameter", "type": "string", "minLength": 1, "pattern": "\\S" }, + "to": { "title": "To parameter", "type": "string", "minLength": 1, "pattern": "\\S" } + }, + "additionalProperties": false, + "x-altinity-order": ["from", "to"] + }, "columnName": { "title": "Result column", "description": "Exact top-level ClickHouse result-column name.", diff --git a/src/application/saved-query-service.ts b/src/application/saved-query-service.ts index 6ae9087d..74c47659 100644 --- a/src/application/saved-query-service.ts +++ b/src/application/saved-query-service.ts @@ -41,6 +41,7 @@ import { isQuerylessPanel } from '../core/panel-cfg.js'; import { encodeShare } from '../core/share.js'; import type { SavedQueryV2 } from '../generated/json-schema.types.js'; import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnostics.js'; +import type { QueryTimeRangeInferenceDiagnostic } from '../core/query-time-range.js'; // ── Construction deps ──────────────────────────────────────────────────────── @@ -78,7 +79,7 @@ export interface SavedQueryServiceDeps { // ── Result types ───────────────────────────────────────────────────────────── export type CreateSavedResult = - | { ok: true; entry: SavedQueryV2 } + | { ok: true; entry: SavedQueryV2; diagnostics?: QueryTimeRangeInferenceDiagnostic[] } /** `createSavedQuery` itself rejected the entry — either a pre-commit * compute guard (still-linked tab, blank SQL on a non-text panel, blank * name, or a blocking validation diagnostic — the pre-#287 inline code @@ -89,7 +90,7 @@ export type CreateSavedResult = | { ok: false; diagnostics?: WorkspaceDiagnostic[] }; export type CommitLinkedResult = - | { ok: true; entry: SavedQueryV2 } + | { ok: true; entry: SavedQueryV2; diagnostics?: QueryTimeRangeInferenceDiagnostic[] } | { ok: false; reason: @@ -160,7 +161,9 @@ export function createSavedQueryService(deps: SavedQueryServiceDeps): SavedQuery const result = await createSavedQuery( deps.state, tab, name, description, deps.mutateWorkspace, deps.now(), deps.specValidators, ); - return result.ok ? { ok: true, entry: result.entry } : { ok: false, diagnostics: result.diagnostics }; + return result.ok + ? { ok: true, entry: result.entry, ...(result.diagnostics?.length ? { diagnostics: result.diagnostics } : {}) } + : { ok: false, diagnostics: result.diagnostics }; } async function commit( @@ -176,7 +179,10 @@ export function createSavedQueryService(deps: SavedQueryServiceDeps): SavedQuery const result = await commitSavedQuery( deps.state, tab, evaluated.parsed as QuerySpecDraft | null, deps.mutateWorkspace, deps.specValidators, ); - if (result.ok) return { ok: true, entry: result.entry }; + if (result.ok) return { + ok: true, entry: result.entry, + ...(result.diagnostics?.length ? { diagnostics: result.diagnostics } : {}), + }; return result.deletedExternally ? { ok: false, reason: 'deleted' } : { ok: false, reason: 'rejected', diagnostics: result.diagnostics }; diff --git a/src/core/library-codec.ts b/src/core/library-codec.ts index 2e000ef3..847a6fb6 100644 --- a/src/core/library-codec.ts +++ b/src/core/library-codec.ts @@ -10,6 +10,7 @@ import { migrateLibraryV1ToV2, migrateSequential } from './library-migrations.js import type { MigratedLibraryV2, MigrateSequentialResult } from './library-migrations.js'; import { migrateSavedQuerySpec as migrateSpec } from './spec-migrations.js'; import type { MigratedSavedQuery } from './spec-migrations.js'; +import { hasSameTimeRangeParameter } from './query-time-range.js'; export const LIBRARY_FORMAT = 'altinity-sql-browser/saved-queries'; export const CURRENT_LIBRARY_VERSION = 2; @@ -58,6 +59,12 @@ type FailResult = { ok: false; diagnostics: LibraryDiagnostic[] }; const resultError = (...diagnostics: (LibraryDiagnostic | LibraryDiagnostic[])[]): FailResult => ({ ok: false, diagnostics: diagnostics.flat() }); +const timeRangeSemanticDiagnostics = (spec: unknown, path: (string | number)[] = []): LibraryDiagnostic[] => + hasSameTimeRangeParameter(spec) + ? [diagnostic([...path, 'timeRanges', 0, 'to'], 'time-range-same-parameter', + 'Time-range From and To parameters must be different.')] + : []; + function validateLegacyLibraryV1(document: unknown): LibraryDiagnostic[] { // Version identification establishes the v1 envelope before this codec runs. const queries = (document as { queries?: unknown }).queries; @@ -152,8 +159,11 @@ function validateLibraryV2Source(document: unknown, options?: unknown): LibraryD ? SAVED_QUERY_V2_SCHEMA_ID : LIBRARY_V2_SCHEMA_ID, })); + const semantics = queries ? queries.flatMap((query, index) => + isPlainObject(query) && !unsupportedIndexes.has(index) + ? timeRangeSemanticDiagnostics(query.spec, ['queries', index, 'spec']) : []) : []; const duplicates = queries ? duplicateIdDiagnostics(queries) : []; - return [...unsupportedSpecs, ...structural, ...duplicates]; + return [...unsupportedSpecs, ...structural, ...semantics, ...duplicates]; } /** One saved-query Spec version's codec — `library-migrations.ts`'s @@ -174,7 +184,10 @@ export const SPEC_CODECS: Map = new Map([ // same as `validateLibraryV2Source` above. const { validationService = jsonSchemaValidationService } = options as { validationService?: JsonSchemaValidationService }; - return validationService.validate(QUERY_SPEC_V1_SCHEMA_ID, value); + return [ + ...validationService.validate(QUERY_SPEC_V1_SCHEMA_ID, value), + ...timeRangeSemanticDiagnostics(value), + ]; }, migrateToNext: null, }], @@ -225,10 +238,12 @@ export function validateSavedQueryDocument( return [diagnostic(['specVersion'], 'spec-version-unsupported', `specVersion uses unsupported saved-query Spec version ${query.specVersion}`)]; } - return validationService.validate(SAVED_QUERY_V2_SCHEMA_ID, query).map((item) => ({ + const structural = validationService.validate(SAVED_QUERY_V2_SCHEMA_ID, query).map((item) => ({ ...item, schemaId: item.path[0] === 'spec' ? QUERY_SPEC_V1_SCHEMA_ID : SAVED_QUERY_V2_SCHEMA_ID, })); + const semantics = isPlainObject(query) ? timeRangeSemanticDiagnostics(query.spec, ['spec']) : []; + return [...structural, ...semantics]; } export function validateLibraryDocument( diff --git a/src/core/param-type.ts b/src/core/param-type.ts index 83cc638a..857b2d38 100644 --- a/src/core/param-type.ts +++ b/src/core/param-type.ts @@ -169,6 +169,41 @@ export function parseParamType(raw?: string | null): ParsedParamType { return { ...fromNode(node), raw: text }; } +/** Strict declaration predicate for saved-query time-range metadata. Unlike + * the permissive value pipeline, this accepts only syntactically valid scalar + * ClickHouse date/time declarations, valid wrapper ordering, DateTime's + * optional timezone, and DateTime64 precision 0..9 plus optional timezone. */ +export function isSupportedTimeRangeParamType(raw?: string | null): boolean { + const node = parseClickHouseType(String(raw || '').trim()); + if (!node) return false; + const mods = analyzeTypeModifiers(node); + if (!mods.valid) return false; + const value = mods.valueType; + if (value.name === 'Date' || value.name === 'Date32') return value.args.length === 0; + if (value.name === 'DateTime') { + return value.args.length === 0 + || (value.args.length === 1 && value.args[0].kind === 'string' && value.args[0].value.trim().length > 0); + } + if (value.name !== 'DateTime64' || value.args.length < 1 || value.args.length > 2) return false; + const precision = value.args[0]; + if (precision.kind !== 'number' || !/^\d+$/.test(precision.value)) return false; + const n = Number(precision.value); + if (!Number.isInteger(n) || n < 0 || n > 9) return false; + return value.args.length === 1 + || (value.args[1].kind === 'string' && value.args[1].value.trim().length > 0); +} + +/** Explicit IANA timezone carried by a valid DateTime/DateTime64 declaration, + * or null when the type uses the existing server-timezone default. */ +export function dateTimeTimeZone(type: string | ParsedParamType): string | null { + const parsed = typeof type === 'string' ? parseParamType(type) : type; + if (!parsed.node) return null; + const value = analyzeTypeModifiers(parsed.node).valueType; + if (value.name === 'DateTime' && value.args.length === 1 && value.args[0].kind === 'string') return value.args[0].value; + if (value.name === 'DateTime64' && value.args.length === 2 && value.args[1].kind === 'string') return value.args[1].value; + return null; +} + /** * The lexical family of a parsed (or raw) type, deciding how the typed * serializer emits an array *element* of that type: diff --git a/src/core/query-time-range.ts b/src/core/query-time-range.ts new file mode 100644 index 00000000..6e07a735 --- /dev/null +++ b/src/core/query-time-range.ts @@ -0,0 +1,93 @@ +// Saved-query time-range authoring inference (#334). Pure: SQL is analyzed +// through the shared parameter pipeline, and the supplied Spec is cloned only +// when one conservative pair can be materialized. + +import { analyzeParameterizedSources } from './param-pipeline.js'; +import type { ParameterAnalysis } from './param-pipeline.js'; +import { cloneJson } from './saved-query.js'; +import { isSupportedTimeRangeParamType } from './param-type.js'; +import type { QuerySpecV1 } from '../generated/json-schema.types.js'; + +export const TIME_RANGE_NAME_PAIRS: ReadonlyArray = [ + ['from', 'to'], + ['from_time', 'to_time'], + ['start', 'end'], + ['start_time', 'end_time'], +]; + +export interface QueryTimeRangeInferenceDiagnostic { + path: (string | number)[]; + severity: 'warning'; + code: 'time-range-inference-ambiguous'; + message: string; +} + +export interface QueryTimeRangeInferenceResult { + spec: QuerySpecV1; + inferred: boolean; + diagnostics: QueryTimeRangeInferenceDiagnostic[]; +} + +export function hasSameTimeRangeParameter(spec: unknown): boolean { + if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return false; + const ranges = (spec as { timeRanges?: unknown }).timeRanges; + if (!Array.isArray(ranges) || ranges.length !== 1) return false; + const pair = ranges[0]; + return !!pair && typeof pair === 'object' && !Array.isArray(pair) + && typeof (pair as { from?: unknown }).from === 'string' + && (pair as { from: string; to?: unknown }).from === (pair as { to?: unknown }).to; +} + +export function analyzeQueryTimeRangeSql(sql: string): ParameterAnalysis { + return analyzeParameterizedSources([{ + id: 'saved-query', kind: 'saved-query', sql, bindPolicy: 'row-returning', + }]); +} + +/** Infer one pair only when the property is absent. Candidate counting happens + * before type gating: two recognized name pairs are ambiguous even if only one + * later proves date-like, because choosing it would no longer be conservative. */ +export function inferQueryTimeRange( + spec: QuerySpecV1, + analysis: ParameterAnalysis, +): QueryTimeRangeInferenceResult { + if (Object.hasOwn(spec, 'timeRanges')) return { spec, inferred: false, diagnostics: [] }; + if (Object.keys(analysis.sourceErrors).length) return { spec, inferred: false, diagnostics: [] }; + + const namesByFolded = new Map(); + for (const name of Object.keys(analysis.fields)) { + const key = name.toLowerCase(); + const names = namesByFolded.get(key) || []; + names.push(name); + namesByFolded.set(key, names); + } + const candidates = TIME_RANGE_NAME_PAIRS.flatMap(([fromKey, toKey]) => { + const from = namesByFolded.get(fromKey) || []; + const to = namesByFolded.get(toKey) || []; + return from.length && to.length ? [{ from, to }] : []; + }); + if (candidates.length === 0) return { spec, inferred: false, diagnostics: [] }; + if (candidates.length !== 1 || candidates[0].from.length !== 1 || candidates[0].to.length !== 1) { + return { + spec, inferred: false, + diagnostics: [{ + path: ['timeRanges'], severity: 'warning', code: 'time-range-inference-ambiguous', + message: 'Several recognized time-range parameter pairs exist. Author spec.timeRanges explicitly, or use [] to opt out.', + }], + }; + } + + const from = candidates[0].from[0]; + const to = candidates[0].to[0]; + const fields = [analysis.fields[from], analysis.fields[to]]; + const supported = fields.every((field) => !field.conflict && field.declarations.length > 0 + && field.declarations.every((declaration) => isSupportedTimeRangeParamType(declaration.type))); + if (!supported) return { spec, inferred: false, diagnostics: [] }; + const next = cloneJson(spec); + next.timeRanges = [{ from, to }]; + return { spec: next, inferred: true, diagnostics: [] }; +} + +export function materializeQueryTimeRange(spec: QuerySpecV1, sql: string): QueryTimeRangeInferenceResult { + return inferQueryTimeRange(spec, analyzeQueryTimeRangeSql(sql)); +} diff --git a/src/core/relative-time.ts b/src/core/relative-time.ts index 96dd8512..b03c21f0 100644 --- a/src/core/relative-time.ts +++ b/src/core/relative-time.ts @@ -218,7 +218,8 @@ export function isDateLikeType(type: string | ParsedParamType): boolean { return base === 'Date' || base === 'Date32' || base === 'DateTime' || base === 'DateTime64'; } -function formatByType(epochMs: number, t: ParsedParamType): string { +export function formatTimeParamValue(epochMs: number, type: ParsedParamType | string): string { + const t = parsedType(type); if (t.base === 'Date' || t.base === 'Date32') return formatDate(epochMs); if (t.base === 'DateTime64') { const n = t.inner ? parseInt(t.inner, 10) || 0 : 0; @@ -316,7 +317,7 @@ export function resolveRelativeValue(expr: string, type: string | ParsedParamTyp // hand by this point; see `param-pipeline.ts`'s `resolveRelativeExpr` seam // comment for the one caller that deliberately leaves it optional. const instant = resolveInstant(parsed, nowMs!); - return { ok: true, value: formatByType(instant, t), matched: true }; + return { ok: true, value: formatTimeParamValue(instant, t), matched: true }; } /** `formatPreview`'s successful-resolution shape — see `ResolveRelativeOk` diff --git a/src/core/spec-draft.ts b/src/core/spec-draft.ts index a2a03549..96472573 100644 --- a/src/core/spec-draft.ts +++ b/src/core/spec-draft.ts @@ -11,6 +11,7 @@ import { } from './spec-schema.js'; import { filterSqlDiagnostics } from './filter-execution.js'; import type { QuerySpecV1 } from '../generated/json-schema.types.js'; +import { hasSameTimeRangeParameter } from './query-time-range.js'; const isDigit = (ch: string): boolean => ch >= '0' && ch <= '9'; const isHex = (ch: string): boolean => /[0-9a-f]/i.test(ch); @@ -248,10 +249,20 @@ function isValidatorEntryList(value: unknown): value is readonly SpecValidatorEn // Compatibility name for feature validators that predate the canonical // schema. Known static fields now live exclusively in query-spec-v1.schema.json. -export const CORE_SPEC_VALIDATORS: readonly SpecValidatorEntry[] = Object.freeze([{ - path: ['dashboard', 'role'], - validate: ({ value, context }: SpecValidatorArgs) => value === 'filter' ? filterSqlDiagnostics(context.sql) : [], -}]); +export const CORE_SPEC_VALIDATORS: readonly SpecValidatorEntry[] = Object.freeze([ + { + path: ['dashboard', 'role'], + validate: ({ value, context }: SpecValidatorArgs) => value === 'filter' ? filterSqlDiagnostics(context.sql) : [], + }, + { + path: ['timeRanges'], + validate: ({ value }: SpecValidatorArgs) => { + return hasSameTimeRangeParameter({ timeRanges: value }) + ? [{ path: ['timeRanges', 0, 'to'], code: 'time-range-same-parameter', message: 'Time-range From and To parameters must be different.' }] + : []; + }, + }, +]); export const defaultSpecValidationService: QuerySpecValidationService = createQuerySpecValidationService(CORE_SPEC_VALIDATORS); diff --git a/src/core/time-range.ts b/src/core/time-range.ts index 18033253..5d979938 100644 --- a/src/core/time-range.ts +++ b/src/core/time-range.ts @@ -1,5 +1,5 @@ -// Dashboard time-range control (#335) — a pure resolver that (a) discovers -// candidate From/To filter-id pairs by #334's interim name-pair table, (b) +// Dashboard time-range control (#335) — pure validation and grouping support +// for the authoritative saved-query metadata introduced by #334. It (a) // gates a candidate pair into a `DashboardTimeRangeGroup` only when BOTH // filters resolve to a scalar, date-like consumer contract (reusing // `filter-selection.ts`'s #189 consumer-resolution machinery rather than @@ -8,10 +8,9 @@ // session-scoped "Recently used" list. No DOM, no globals, no fetch — `nowMs` // is always injected (the repo's keystroke rule). // -// Pair discovery is a SEAM (`resolveTimeRangeGroups`'s optional `pairs` -// argument), not a hard dependency on `inferTimeRangePairs`: #334's saved- -// query `timeRanges` metadata is meant to replace the inference source -// without touching this module's gating logic or any UI built on top of it. +// Pair discovery remains a seam (`resolveTimeRangeGroups`'s optional `pairs` +// argument); runtime callers derive those pairs from saved-query `timeRanges` +// metadata without coupling that persistence concern to the validation logic. // // Group formation never mutates anything — it's a pure re-derivation over the // dashboard's current filter defs, analysis, and executable-tile set, exactly @@ -21,11 +20,12 @@ import { resolveFilterSelection } from './filter-selection.js'; import type { FilterSelectionFilterDef } from './filter-selection.js'; import type { ParameterAnalysis } from './param-pipeline.js'; import type { ParsedParamType } from './param-type.js'; -import { parseParamType } from './param-type.js'; +import { dateTimeTimeZone, isSupportedTimeRangeParamType, parseParamType } from './param-type.js'; import { parseRelativeExpr, resolveInstant, formatPreviewInstant, + formatTimeParamValue, parseAbsoluteInstant, isDateLikeType, } from './relative-time.js'; @@ -59,6 +59,19 @@ export interface DashboardTimeRangeGroup { toParameter: string; fromType: ParsedParamType; toType: ParsedParamType; + /** Every Dashboard tile whose saved query authoritatively declares this + * pair, irrespective of panel family. */ + tileIds: string[]; + /** Live-compatible charts are registered by the Dashboard interaction + * controller after real result columns/scales exist. */ + interactiveChartTileIds: string[]; +} + +export interface TimeRangeGroupDiagnostic { + tileId: string; + queryId: string; + code: 'time-range-filter-unresolved' | 'time-range-contract-invalid'; + message: string; } /** One candidate pair of filter ids — `inferTimeRangePairs`'s output, and the @@ -138,7 +151,7 @@ export function inferTimeRangePairs( * resolution will supply instead, without this gating logic changing. A pair * forms a group ONLY when BOTH filters resolve via `resolveFilterSelection` * with zero diagnostics, a non-null contract, `contract.array === false`, and - * `isDateLikeType(contract.type)` — a filter missing from `input.filters` + * `isSupportedTimeRangeParamType(contract.type)` — a filter missing from `input.filters` * (only possible when a caller supplies its own `pairs`) is skipped rather * than throwing. Emitted in `pairs`' own order. Pure — never mutates * `input.filters`/`input.analysis`. @@ -163,7 +176,8 @@ export function resolveTimeRangeGroups(input: { const toRes = resolveFilterSelection(toFilter, analysis, executableTileIds); if (fromRes.diagnostics.length || toRes.diagnostics.length || !fromRes.contract || !toRes.contract) continue; if (fromRes.contract.array || toRes.contract.array) continue; - if (!isDateLikeType(fromRes.contract.type) || !isDateLikeType(toRes.contract.type)) continue; + if (!isSupportedTimeRangeParamType(fromRes.contract.type.raw) + || !isSupportedTimeRangeParamType(toRes.contract.type.raw)) continue; groups.push({ key: `${fromFilter.id}\u0000${toFilter.id}`, @@ -173,11 +187,74 @@ export function resolveTimeRangeGroups(input: { toParameter: toFilter.parameter, fromType: fromRes.contract.type, toType: toRes.contract.type, + tileIds: [], + interactiveChartTileIds: [], }); } return groups; } +/** Resolve saved-query time-range metadata to Dashboard filter identities, + * then aggregate every participating tile by that ordered identity pair. + * Filter targeting is supplied by the viewer session's single authoritative + * resolver; this core function never reimplements target semantics. */ +export function resolveAuthoredTimeRangeGroups(input: { + filters: ReadonlyArray; + analysis: ParameterAnalysis; + executableTileIds: ReadonlySet; + filterTargetTileIds: ReadonlyMap>; + tiles: ReadonlyArray<{ id: string; queryId: string }>; + queries: ReadonlyArray<{ id: string; spec?: { timeRanges?: unknown } }>; +}): { groups: DashboardTimeRangeGroup[]; diagnostics: TimeRangeGroupDiagnostic[] } { + const queryById = new Map(input.queries.map((query) => [query.id, query] as const)); + const groupsByKey = new Map(); + const diagnostics: TimeRangeGroupDiagnostic[] = []; + + for (const tile of input.tiles) { + const query = queryById.get(tile.queryId); + const ranges = query?.spec?.timeRanges; + if (ranges === undefined || (Array.isArray(ranges) && ranges.length === 0)) continue; + const malformed = (): void => { + diagnostics.push({ + tileId: tile.id, queryId: tile.queryId, code: 'time-range-contract-invalid', + message: `Time range metadata for ${tile.queryId} is invalid and cannot participate in a Dashboard group.`, + }); + }; + if (!Array.isArray(ranges) || ranges.length !== 1) { malformed(); continue; } + const raw = ranges[0]; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { malformed(); continue; } + const pair = raw as Record; + if (typeof pair.from !== 'string' || typeof pair.to !== 'string' || pair.from === pair.to) { malformed(); continue; } + const matching = (parameter: string) => input.filters.filter((filter) => filter.parameter === parameter + && input.filterTargetTileIds.get(filter.id)?.has(tile.id)); + const from = matching(pair.from); + const to = matching(pair.to); + if (from.length !== 1 || to.length !== 1 || from[0].id === to[0].id) { + diagnostics.push({ + tileId: tile.id, queryId: tile.queryId, code: 'time-range-filter-unresolved', + message: `Time range for ${tile.queryId} could not resolve both parameters to one targeted Dashboard filter each.`, + }); + continue; + } + const resolved = resolveTimeRangeGroups({ + filters: input.filters, analysis: input.analysis, executableTileIds: input.executableTileIds, + pairs: [{ fromFilterId: from[0].id, toFilterId: to[0].id }], + }); + if (resolved.length !== 1) { + diagnostics.push({ + tileId: tile.id, queryId: tile.queryId, code: 'time-range-contract-invalid', + message: `Time range for ${tile.queryId} does not resolve to two compatible scalar date/time filter contracts.`, + }); + continue; + } + const candidate = resolved[0]; + const existing = groupsByKey.get(candidate.key); + if (existing) existing.tileIds.push(tile.id); + else groupsByKey.set(candidate.key, { ...candidate, tileIds: [tile.id] }); + } + return { groups: [...groupsByKey.values()], diagnostics }; +} + // ── Draft validation ───────────────────────────────────────────────────── /** One bound's (From's or To's) staged-draft resolution. `display` and @@ -257,6 +334,88 @@ export function validateTimeRangeDraft(input: { return { from, to, rangeOk, rangeError, applyEnabled: from.ok && to.ok && rangeOk }; } +/** Convert Chart.js's local-wall-clock epoch convention back to the UTC + * server-wall-clock convention used by the existing date/time parameter + * pipeline, then format each bound for its own declared type. Date/Date32 + * retain the selected calendar digits directly (no hidden day adjustment). */ +export function formatChartTimeRange(input: { + fromMs: number; + toMs: number; + fromType: ParsedParamType | string; + toType: ParsedParamType | string; +}): { ok: true; from: string; to: string; fromLabel: string; toLabel: string } | { ok: false; error: string } { + if (!Number.isFinite(input.fromMs) || !Number.isFinite(input.toMs)) { + return { ok: false, error: 'The selected time range is invalid.' }; + } + const lo = Math.min(input.fromMs, input.toMs); + const hi = Math.max(input.fromMs, input.toMs); + const format = (ms: number, type: ParsedParamType | string): string => { + const parsed = typeof type === 'string' ? parseParamType(type) : type; + if (parsed.base === 'Date' || parsed.base === 'Date32') { + const scaleValue = instantToChartScaleTime(ms, parsed); + return formatTimeParamValue(scaleValue ?? ms, parsed); + } + return formatTimeParamValue(ms, parsed); + }; + const from = format(lo, input.fromType); + const to = format(hi, input.toType); + const fromType = typeof input.fromType === 'string' ? parseParamType(input.fromType) : input.fromType; + const toType = typeof input.toType === 'string' ? parseParamType(input.toType) : input.toType; + return { + ok: true, from, to, + fromLabel: formatPreviewInstant(lo, fromType), + toLabel: formatPreviewInstant(hi, toType), + }; +} + +function zonedParts(epochMs: number, timeZone: string): number[] | null { + try { + const parts = new Intl.DateTimeFormat('en-CA-u-hc-h23', { + timeZone, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', + }).formatToParts(new Date(epochMs)); + const value = (name: string): number => Number(parts.find((part) => part.type === name)?.value); + const out = ['year', 'month', 'day', 'hour', 'minute', 'second'].map(value); + return out.every(Number.isFinite) ? out : null; + } catch { + return null; + } +} + +/** Convert Chart.js's local-wall-clock scale convention into one canonical + * epoch instant, honoring an explicit ClickHouse timezone when present. */ +export function chartScaleTimeToInstant(ms: number, type: ParsedParamType | string): number | null { + if (!Number.isFinite(ms)) return null; + const d = new Date(ms); + const desired = [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds()]; + const desiredUtc = Date.UTC(desired[0], desired[1] - 1, desired[2], desired[3], desired[4], desired[5], d.getMilliseconds()); + const zone = dateTimeTimeZone(type) || 'UTC'; + let candidate = desiredUtc; + for (let i = 0; i < 2; i++) { + const shown = zonedParts(candidate, zone); + if (!shown) return null; + const shownUtc = Date.UTC(shown[0], shown[1] - 1, shown[2], shown[3], shown[4], shown[5], d.getMilliseconds()); + candidate += desiredUtc - shownUtc; + } + return candidate; +} + +/** Convert a canonical instant to the pseudo-local epoch expected by the + * existing Chart.js wall-clock parser for a declared ClickHouse timezone. */ +export function instantToChartScaleTime(ms: number, type: ParsedParamType | string): number | null { + if (!Number.isFinite(ms)) return null; + const shown = zonedParts(ms, dateTimeTimeZone(type) || 'UTC'); + if (!shown) return null; + return new Date(shown[0], shown[1] - 1, shown[2], shown[3], shown[4], shown[5], new Date(ms).getUTCMilliseconds()).getTime(); +} + +/** Human label for one Chart.js time-scale value, preserving the server's + * wall-clock digits across browser timezones. */ +export function formatChartTimeLabel(ms: number, type: ParsedParamType | string): string { + const parsed = typeof type === 'string' ? parseParamType(type) : type; + return formatPreviewInstant(ms, parsed); +} + // ── Recently used ──────────────────────────────────────────────────────── /** One recorded From/To token pair — the RAW committed text of each bound, diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 4bb2d124..bb2cef4e 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -40,7 +40,7 @@ import type { FilterSourceAnalysis } from '../../core/filter-execution.js'; import { readFilterOptions } from '../../core/filter-options.js'; import { resolveFilterSelection, sameSelection } from '../../core/filter-selection.js'; import type { FilterSelectionFilterDef } from '../../core/filter-selection.js'; -import { resolveTimeRangeGroups } from '../../core/time-range.js'; +import { resolveAuthoredTimeRangeGroups } from '../../core/time-range.js'; import type { DashboardTimeRangeGroup } from '../../core/time-range.js'; import { mergeDashboardFilterHelpers } from '../../core/dashboard-filters.js'; import type { @@ -186,6 +186,8 @@ export interface DashboardViewState { * appear once by construction). Reset to `[]` at the start of each wave; * a pre-wave publish (session construction) also reads `[]`. */ filterDiagnostics: FilterDiagnostic[]; + /** Persistent saved-query time-range resolution diagnostics. */ + timeRangeDiagnostics: WorkspaceDiagnostic[]; /** #335: the ONE wall-clock snapshot (`deps.wallNow()`) the latest execution * wave resolved its relative tokens against — `null` before the first wave. * Every wave that reruns tiles (`refresh`/`runAffectedWave`, and the @@ -291,12 +293,11 @@ export interface DashboardViewerSession { /** #335: commit MULTIPLE filters atomically (the time-range control's * From/To pair, and #334's drag-to-select) in ONE execution wave over the * union of every changed parameter's resolved targets. Every `filterId` - * must resolve and be unique — an unknown OR duplicate id makes the whole - * call a silent no-op (nothing mutated, no publish, no wave), matching - * `applyFilter`'s posture (type-level value validation stays the pipeline's - * job). A call in which nothing actually changes (values+active equal the - * committed state) publishes nothing and runs no wave. */ - applyFilters(entries: Array<{ filterId: string; value: string | string[]; active: boolean }>): Promise; + * and parameter must resolve uniquely; the complete proposed batch is + * validated through the shared execution pipeline before either value is + * mutated. Returns a diagnostic failure for an invalid batch. A call in + * which nothing actually changes publishes nothing and runs no wave. */ + applyFilters(entries: Array<{ filterId: string; value: string | string[]; active: boolean }>): Promise; /** Deactivate one filter WITHOUT discarding its value (reactivation restores * it); one affected-panel wave (#188 clear-one). */ clearFilter(filterId: string): Promise; @@ -323,6 +324,10 @@ export interface DashboardViewerSession { destroy(): void; } +export type ApplyFiltersResult = + | { ok: true; changed: boolean } + | { ok: false; error: string }; + // ── Per-tile / per-filter runtime records (never published directly) ────────── interface TileRuntime { @@ -608,9 +613,11 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa * apart. An explicit `targets: []` (present but empty) deliberately * resolves to affecting NOTHING — it does not fall back to the declared * set — preserving the pre-#189 `affectedByFilterWave` behavior exactly. */ - function resolveFilterTargets(def: DashboardFilterDefinitionV1): Set { + function resolveFilterTargets( + def: DashboardFilterDefinitionV1, sourceAnalysis: ParameterAnalysis = analysis, + ): Set { if (Array.isArray(def.targets)) return new Set(def.targets.filter((id) => knownTileIds.has(id))); - const field = analysis.fields[def.parameter]; + const field = sourceAnalysis.fields[def.parameter]; return field ? new Set(field.requiredIn.concat(field.optionalIn)) : new Set(); } @@ -757,7 +764,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa for (const id of resolveFilterTargets(filter.def)) affectedByFilterWave.add(id); } - // #335: resolve the dashboard's time-range groups ONCE, here — AFTER the + // #334: resolve authored saved-query metadata ONCE, here — AFTER the // #189 source-fallback loop above — so `sourceQueryId` reflects each // filter's POST-resolution state (`filter.state.sourceId`), not the // structural `def.sourceQueryId`. `inferTimeRangePairs` (inside @@ -768,17 +775,37 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // like any other date-like scalar pair. Every other input matches the #189 // machinery (`analysis`, `executableTileIds`, and the same selection def) // so the gate's own `resolveFilterSelection` agrees with construction's. - const timeRangeGroups = resolveTimeRangeGroups({ - filters: filters.map((filter) => ({ + const timeRangeFilterDefs = filters.map((filter) => ({ id: filter.def.id, parameter: filter.def.parameter, targets: Array.isArray(filter.def.targets) ? filter.def.targets : undefined, selection: filter.def.selection, sourceQueryId: filter.state.sourceId ?? null, - })), - analysis, - executableTileIds, + })); + // Membership semantics include every panel family, even panels that do not + // execute in the current viewer. Analyze their saved SQL for parameter + // contracts without adding them to the execution planner. + const timeRangeAnalysis = analyzeParameterizedSources(tiles.map((runtime) => ({ + id: runtime.tile.id, label: runtime.state.title, kind: 'time-range-tile', + sql: runtime.query?.sql ?? '', bindPolicy: 'row-returning', + }))); + const filterTargetTileIds = new Map>( + filters.map((filter) => [filter.def.id, resolveFilterTargets(filter.def, timeRangeAnalysis)]), + ); + const authoredTimeRanges = resolveAuthoredTimeRangeGroups({ + filters: timeRangeFilterDefs, + analysis: timeRangeAnalysis, + executableTileIds: knownTileIds, + filterTargetTileIds, + tiles: tiles.map((runtime) => ({ id: runtime.tile.id, queryId: runtime.tile.queryId })), + queries, }); + const timeRangeGroups = authoredTimeRanges.groups; + const tileIndexById = new Map(tiles.map((runtime, index) => [runtime.tile.id, index] as const)); + const timeRangeDiagnostics: WorkspaceDiagnostic[] = authoredTimeRanges.diagnostics.map((item) => ({ + path: ['dashboard', 'tiles', tileIndexById.get(item.tileId) ?? 0, 'queryId'], + severity: 'error', code: item.code, message: item.message, resource: item.tileId, + })); // Curated option bundles from the last filter wave (param name → field). let curated: MergeDashboardFilterHelpersResult['fields'] = {}; @@ -874,6 +901,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // `filterDiagnostics` on every publish, rather than merged into that // mutable array, so nothing a wave does can ever drop them. filterDiagnostics: [...staticFilterDiagnostics, ...filterDiagnostics], + timeRangeDiagnostics, waveWallNowMs, }; } @@ -1262,7 +1290,10 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa * same instant, never a per-source read. */ async function executeFilterSourcePlan( sources: FilterSourceRuntime[], - opts: { clearOptions: boolean; resetDiagnostics: boolean; waveMs: number }, + opts: { + clearOptions: boolean; resetDiagnostics: boolean; waveMs: number; + onReconciled?: (parameters: string[]) => void; + }, ): Promise { if (opts.resetDiagnostics) filterDiagnostics = []; const plan = sources.map((source) => ({ source, generation: supersede(source) })); @@ -1281,7 +1312,9 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // sequencing, not collected. await runPool(plan, VIEWER_TILE_CONCURRENCY, ({ source, generation }) => runFilterSource(source, generation, opts.waveMs)); - return applyFilterProviders(plan); + const applied = applyFilterProviders(plan); + if (applied.status === 'applied') opts.onReconciled?.(applied.flipped); + return applied; } async function runFilterWave(waveMs: number): Promise { @@ -1316,7 +1349,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa * ONE combined affected-panel wave alongside `changedParams`, or to skip * that wave entirely when this plan was superseded. */ async function runFilterSourceWave( - changedParams: string[], waveMs: number, + changedParams: string[], waveMs: number, onReconciled?: (parameters: string[]) => void, ): Promise { // Entered only from `commitAndRerun`'s affected path, which preflights ONCE // for the whole commit (source wave + affected-panel wave) — avoiding a @@ -1330,7 +1363,9 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa source.analyzed.dependsOn.some((name) => changedParams.includes(name))); // Selective rerun: clear stale-for-new-inputs options, but keep the prior // wave's diagnostics until THIS wave's own merge settles. - return executeFilterSourcePlan(affected, { clearOptions: true, resetDiagnostics: false, waveMs }); + return executeFilterSourcePlan(affected, { + clearOptions: true, resetDiagnostics: false, waveMs, onReconciled, + }); } @@ -1414,8 +1449,25 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa } // Re-run only the tiles some active filter parameter feeds into. + function reserveAffected( + parameters: string[], reservations: Map = new Map(), + ): Map { + const affectedIds = new Set(); + for (const parameter of parameters) { + for (const id of targetsByParameter.get(parameter) ?? []) affectedIds.add(id); + } + for (const runtime of runnableTiles()) { + if (affectedIds.has(runtime.tile.id) && !reservations.has(runtime.tile.id)) { + reservations.set(runtime.tile.id, supersede(runtime)); + if (runtime.state.status === 'loading') runtime.state.status = 'idle'; + } + } + return reservations; + } + async function runAffectedWave( parameters: string[], preflighted: boolean, waveMs: number, + reservations: Map = new Map(), ): Promise { // Unconditional destroyed guard: the `preflighted: true` fast path (from // `commitAndRerun`'s affected branch) skips `preflight()` entirely below @@ -1426,7 +1478,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa /* v8 ignore next -- defense in depth for future preflighted callers; the current caller checks destroyed immediately before entering this path. */ if (destroyed) return; - if (!preflighted && !(await preflight())) return; + if (!preflighted && !(await preflight())) { publish(); return; } // #189: consult each parameter's RESOLVED targets (explicit `def.targets` // else declared-in tiles, `targetsByParameter` — built once at // construction, over EVERY filter definition, from the same @@ -1439,12 +1491,12 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // an entry, possibly an EMPTY one (an explicit `targets: []` affecting // nothing); the `?? []` is a cheap defensive guard only, never expected // to be exercised. - const affectedIds = new Set(); + reserveAffected(parameters, reservations); + const actualIds = new Set(); for (const parameter of parameters) { - for (const id of targetsByParameter.get(parameter) ?? []) affectedIds.add(id); + for (const id of targetsByParameter.get(parameter) ?? []) actualIds.add(id); } - const targets = runnableTiles().filter((runtime) => affectedIds.has(runtime.tile.id)); - const generations = new Map(targets.map((runtime) => [runtime.tile.id, supersede(runtime)])); + const targets = runnableTiles().filter((runtime) => actualIds.has(runtime.tile.id)); // #335: publish this wave's shared snapshot and resolve the batch's // relative tokens against it (the same instant `commitAndRerun`'s source // wave used, when there was one). @@ -1452,7 +1504,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa const prepared = sourcesById(prepareBatch('execute', undefined, undefined, waveMs).sources); publish(); await runPool(targets, VIEWER_TILE_CONCURRENCY, - (runtime) => runTile(runtime, prepared.get(runtime.tile.id)!, generations.get(runtime.tile.id)!)); + (runtime) => runTile(runtime, prepared.get(runtime.tile.id)!, reservations.get(runtime.tile.id)!)); } // #360: after committing a value (these four are commit paths only — never @@ -1473,7 +1525,9 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // (`applyFilterProviders`'s own stale-wave guard already discarded it), and // `destroy()` bumps every source generation too, so a source wave racing a // `destroy()` is caught by the same check. - async function commitAndRerun(changed: string[]): Promise { + async function commitAndRerun( + changed: string[], reservations: Map = reserveAffected(changed), + ): Promise { // #335: ONE wall-clock snapshot for the whole commit — the filter-source // wave (when there is one) and the affected-panel wave both resolve their // relative tokens against it, and it is published as `waveWallNowMs`. @@ -1481,16 +1535,20 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa waveWallNowMs = waveMs; const hasAffectedSource = [...filterSources.values()] .some((source) => source.analyzed.dependsOn.some((name) => changed.includes(name))); - if (!hasAffectedSource) { await runAffectedWave(changed, false, waveMs); return; } - if (!(await preflight())) return; - const result = await runFilterSourceWave(changed, waveMs); + if (!hasAffectedSource) { await runAffectedWave(changed, false, waveMs, reservations); return; } + if (!(await preflight())) { publish(); return; } + // Reconciliation is terminal and synchronous inside the source wave. Its + // callback reserves only consumers that actually changed, before the + // source-wave promise resolves to any competing continuation. + const result = await runFilterSourceWave(changed, waveMs, + (flipped) => reserveAffected(flipped, reservations)); // A `'superseded'` result means `applyFilterProviders` returned BEFORE // merging anything (its own stale-wave guard, `applyFilterProviders`'s // doc comment) — no consumer state changed, so there is nothing new to // `publish()`; the wave that superseded this one owns publishing the // eventual settled state. if (destroyed || result.status === 'superseded') return; - await runAffectedWave([...new Set([...changed, ...result.flipped])], true, waveMs); + await runAffectedWave([...new Set([...changed, ...result.flipped])], true, waveMs, reservations); } async function setFilter(filterId: string, value: unknown): Promise { @@ -1522,20 +1580,52 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa async function applyFilters( entries: Array<{ filterId: string; value: string | string[]; active: boolean }>, - ): Promise { - if (destroyed) return; + ): Promise { + if (destroyed) return { ok: false, error: 'Dashboard is no longer active.' }; // Resolve EVERY id up front, all-or-nothing: an unknown OR duplicate id // aborts the whole call before any mutation (atomicity — matches // `applyFilter`'s unknown-id no-op, extended across the batch so a partial // time-range commit can never leave one bound applied and the other not). const resolved: { filter: FilterRuntime; value: unknown; active: boolean }[] = []; const seen = new Set(); + const seenParameters = new Set(); for (const entry of entries) { const filter = filterById.get(entry.filterId); - if (!filter || seen.has(entry.filterId)) return; + if (!filter || seen.has(entry.filterId) || seenParameters.has(filter.def.parameter)) { + return { ok: false, error: 'The filter batch contains an unknown or duplicate filter.' }; + } seen.add(entry.filterId); + seenParameters.add(filter.def.parameter); resolved.push({ filter, value: entry.value, active: entry.active }); } + // Validate the complete proposed state before mutating either filter. We + // intentionally inspect only fields in this batch: an unrelated optional + // filter may still be empty and must not veto an otherwise valid range. + const proposedValues = rawValues(); + const proposedActive = activeMap(); + for (const { filter, value, active } of resolved) { + proposedValues[filter.def.parameter] = copyValue(value); + proposedActive[filter.def.parameter] = active; + } + const validationWallMs = deps.wallNow(); + for (const { filter, active } of resolved) { + if (!active) continue; + const targetIds = resolveFilterTargets(filter.def); + const scopedAnalysis = analyzeParameterizedSources(tiles + .filter((runtime) => targetIds.has(runtime.tile.id) && isRunnableTileRuntime(runtime)) + .map((runtime) => ({ + id: runtime.tile.id, label: runtime.state.title, kind: 'tile', + sql: runtime.query!.sql, bindPolicy: 'row-returning' as const, + }))); + const proposal = prepareParameterizedBatch(scopedAnalysis, { + values: proposedValues, active: proposedActive, wallNowMs: validationWallMs, validationMode: 'execute', + }); + const field = proposal.fields[filter.def.parameter]; + if (!field || field.state !== 'ok') { + return { ok: false, error: field?.reason || `${filter.def.parameter} is not a valid filter value.` }; + } + } + // Mutate every resolved entry (the filter bar owns activation, like // `applyFilter`; `copyValue` defends against aliasing the caller's array), // collecting the changed parameter names via the same `sameSelection` + @@ -1551,9 +1641,14 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa } // Nothing actually differs from the committed state → no publish, no wave // (an identical-pair Apply is a true no-op, never a spurious rerun). - if (!changed.length) return; + if (!changed.length) return { ok: true, changed: false }; + // Reserve generations synchronously with the atomic state commit, before + // token preflight or a dependent filter-source query can yield. This is + // the stale-result boundary for the whole batch. + const reservations = reserveAffected(changed); publish(); - await commitAndRerun(changed); + await commitAndRerun(changed, reservations); + return { ok: true, changed: true }; } async function clearFilter(filterId: string): Promise { diff --git a/src/dashboard/model/canonical-json.ts b/src/dashboard/model/canonical-json.ts index 859e3286..30da25e4 100644 --- a/src/dashboard/model/canonical-json.ts +++ b/src/dashboard/model/canonical-json.ts @@ -95,7 +95,7 @@ const PANEL_SHAPE: CanonicalShape = { }; export const QUERY_SPEC_SHAPE: CanonicalShape = { - order: ['name', 'description', 'favorite', 'view', 'panel', 'dashboard'], + order: ['name', 'description', 'favorite', 'view', 'panel', 'dashboard', 'timeRanges'], fields: { panel: PANEL_SHAPE, dashboard: { @@ -105,6 +105,7 @@ export const QUERY_SPEC_SHAPE: CanonicalShape = { sizeHints: { order: ['preferred', 'minimum', 'aspectRatio'] }, }, }, + timeRanges: { items: { order: ['from', 'to'] } }, }, }; diff --git a/src/dashboard/model/workspace-semantics.ts b/src/dashboard/model/workspace-semantics.ts index 9b103ebc..4a5b45e3 100644 --- a/src/dashboard/model/workspace-semantics.ts +++ b/src/dashboard/model/workspace-semantics.ts @@ -18,6 +18,7 @@ import { analyzeParameterizedSources } from '../../core/param-pipeline.js'; import type { ParameterAnalysis, ParameterizedSourceInput } from '../../core/param-pipeline.js'; import { resolveFilterSelection } from '../../core/filter-selection.js'; import type { FilterSelectionFilterDef, FilterSelectionDiagnostic } from '../../core/filter-selection.js'; +import { hasSameTimeRangeParameter } from '../../core/query-time-range.js'; import { resolvePresentation } from './presentation-resolver.js'; export const FLOW_LAYOUT_V1_SCHEMA_ID = @@ -188,6 +189,10 @@ export function validateQueryCollectionSemantics( } const spec = query.spec; if (!isObject(spec)) continue; + if (hasSameTimeRangeParameter(spec)) { + out.push(diagnostic([...path, index, 'spec', 'timeRanges', 0, 'to'], 'time-range-same-parameter', + 'Time-range From and To parameters must be different.', id)); + } if (typeof spec.name === 'string' && spec.name.length > PORTABLE_LIMITS.maxNameLength) { out.push(diagnostic([...path, index, 'spec', 'name'], 'limit-name-length', `Query name is ${spec.name.length} characters; the maximum is ${PORTABLE_LIMITS.maxNameLength}`, id)); diff --git a/src/generated/json-schema-validators.js b/src/generated/json-schema-validators.js index 1139faf8..dcdf933f 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/queryDashboardPresentationV1" } }, "additionalProperties": true, "x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard"], "$defs": { "columnName": { "title": "Result column", "description": "Exact top-level ClickHouse result-column name.", "type": "string", "minLength": 1, "x-altinity-completion": { "source": "resultColumns" } }, "resultColumnIndex": { "title": "Result column index", "description": "Zero-based index of a ClickHouse result column.", "type": "integer", "minimum": 0, "x-altinity-completion": { "source": "resultColumnIndexes" } }, "deltaPresentation": { "title": "Delta presentation", "description": "Display metadata for a runtime KPI delta value.", "type": "object", "properties": { "displayName": { "title": "Delta label", "description": "Optional visible label for the delta.", "type": "string" }, "unit": { "title": "Delta unit", "description": "Display-only suffix appended to the delta.", "type": "string" }, "decimals": { "title": "Delta decimal places", "description": "Requested display rounding for the delta.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [1] }, "positiveIsGood": { "title": "Positive is good", "description": "Whether a positive runtime delta has good semantics.", "type": "boolean" }, "show": { "title": "Show delta", "description": "Whether a present runtime delta is rendered.", "type": "boolean", "default": true } }, "additionalProperties": true, "x-altinity-order": ["displayName", "unit", "decimals", "positiveIsGood", "show"] }, "fieldConfigValue": { "title": "Field presentation metadata", "description": "Known presentation metadata for one result column. Unknown renderer extensions are retained.", "type": "object", "properties": { "displayName": { "title": "Display name", "description": "Rendered label for the field.", "type": "string" }, "decimals": { "title": "Decimal places", "description": "Requested number of decimal places for numeric display.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [2] }, "description": { "title": "Description", "description": "Supporting display text for the field.", "type": "string" }, "unit": { "title": "Unit", "description": "Display-only suffix appended to the value.", "type": "string", "examples": ["%"] }, "color": { "title": "Color", "description": "Theme token or CSS color hint interpreted by the renderer.", "type": "string" }, "noValue": { "title": "No-value text", "description": "Text shown for NULL or unavailable values.", "type": "string", "default": "\u2014" }, "hidden": { "title": "Hidden", "description": "Suppress this otherwise eligible result field.", "type": "boolean", "default": false }, "delta": { "$ref": "#/$defs/deltaPresentation" } }, "additionalProperties": true, "x-altinity-order": ["displayName", "description", "unit", "decimals", "color", "noValue", "hidden", "delta"] }, "fieldConfig": { "title": "Panel field configuration", "description": "Default and per-column display metadata.", "type": "object", "properties": { "defaults": { "$ref": "#/$defs/fieldConfigValue" }, "columns": { "title": "Column overrides", "description": "Display metadata keyed by exact result-column name.", "type": "object", "additionalProperties": { "$ref": "#/$defs/fieldConfigValue" }, "x-altinity-key-completion": { "source": "resultColumns" } } }, "additionalProperties": true, "x-altinity-order": ["defaults", "columns"] }, "queryPresentationPatchV1": { "title": "Presentation patch", "description": "RFC 7396 JSON Merge Patch applied over the saved query's base panel object. Object members merge recursively, arrays replace the previous array completely, and a null member deletes an optional property. A patch may not change panel.cfg.type, and the final resolved panel must validate like a normal saved-query panel.", "type": "object", "additionalProperties": true }, "dashboardSizeHints": { "title": "Size hints", "description": "Layout-neutral tile size hints. Authoring may derive an initial placement from them; they never own actual Dashboard placement.", "type": "object", "properties": { "preferred": { "title": "Preferred size", "description": "Preferred layout-neutral tile size.", "type": "string", "enum": ["compact", "medium", "wide"] }, "minimum": { "title": "Minimum size", "description": "Smallest layout-neutral tile size that still renders usefully.", "type": "string", "enum": ["compact", "medium", "wide"] }, "aspectRatio": { "title": "Aspect ratio", "description": "Preferred width/height ratio hint.", "type": "number", "exclusiveMinimum": 0 } }, "additionalProperties": true, "x-altinity-order": ["preferred", "minimum", "aspectRatio"] }, "queryDashboardPresentationV1": { "title": "Dashboard configuration", "description": "Reusable Dashboard presentation for this query: role, named presentation variants, and layout-neutral size hints. Actual Dashboard sequence, placement, and tile-local overrides live in the Dashboard document. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { "title": "Dashboard role (Panel, Filter, or Setup)", "description": "How the saved query participates in a dashboard. panel (the default) creates a visualization tile. filter returns exactly one row whose supported top-level Array, named Tuple Array, or Map columns provide option lists for parameters with the same exact names; it creates no tile and its SQL cannot declare parameters. setup is reserved for serialized Dashboard setup execution.", "type": "string", "enum": ["panel", "filter", "setup"], "default": "panel", "examples": ["filter"] }, "defaultVariant": { "title": "Default presentation variant", "description": "Variant applied when a tile does not select one. Must name an existing entry in variants.", "type": "string", "minLength": 1, "maxLength": 256 }, "variants": { "title": "Presentation variants", "description": "Named reusable presentation patches, each an RFC 7396 JSON Merge Patch over the base panel.", "type": "object", "maxProperties": 32, "propertyNames": { "minLength": 1, "maxLength": 256 }, "additionalProperties": { "$ref": "#/$defs/queryPresentationPatchV1" } }, "sizeHints": { "$ref": "#/$defs/dashboardSizeHints" } }, "additionalProperties": true, "x-altinity-order": ["role", "defaultVariant", "variants", "sizeHints"] }, "panel": { "title": "Panel configuration", "description": "Visualization and field metadata for the saved query.", "type": "object", "properties": { "cfg": { "$ref": "#/$defs/panelCfg" }, "key": { "title": "Result schema key", "description": "Saved result-column signature used to detect stale positional roles.", "type": ["string", "null"] }, "fieldConfig": { "$ref": "#/$defs/fieldConfig" } }, "additionalProperties": true, "x-altinity-order": ["cfg", "key", "fieldConfig"] }, "lineChartStyle": { "title": "Line style", "description": "Renderer-independent line presentation. Unknown fields and future string values remain storable.", "type": "object", "properties": { "curve": { "title": "Line curve", "description": "linear draws straight segments, smooth uses monotone interpolation, and stepped draws step-after segments.", "anyOf": [{ "type": "string", "enum": ["linear", "smooth", "stepped"] }, { "type": "string" }], "default": "linear" }, "points": { "title": "Point markers", "description": "auto shows markers only for sparse results, show always displays them, and hide retains hover targets without visible markers.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "scale": { "title": "Value scale", "description": "zero anchors the value axis at zero, data uses the data range, and auto uses the chart-type default.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "data" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["curve", "points", "scale", "legend", "grid", "axes"] }, "areaChartStyle": { "title": "Area style", "description": "Curve, marker, and additive stacking presentation for Area charts.", "allOf": [{ "$ref": "#/$defs/lineChartStyle" }, { "type": "object", "properties": { "stack": { "title": "Area stacking", "description": "overlay draws series independently; stacked uses one shared additive stack without normalization.", "anyOf": [{ "type": "string", "enum": ["overlay", "stacked"] }, { "type": "string" }], "default": "overlay" } }, "additionalProperties": true }], "x-altinity-order": ["curve", "points", "stack", "scale", "legend", "grid", "axes"] }, "barChartStyle": { "title": "Bar and Column style", "description": "Grouping and category-spacing presentation shared by horizontal Bar and vertical Column charts.", "type": "object", "properties": { "mode": { "title": "Bar grouping", "description": "grouped draws measures side by side; stacked adds them on one shared value stack.", "anyOf": [{ "type": "string", "enum": ["grouped", "stacked"] }, { "type": "string" }], "default": "grouped" }, "density": { "title": "Category spacing", "description": "normal uses standard spacing, compact reduces gaps, and joined removes category gaps.", "anyOf": [{ "type": "string", "enum": ["normal", "compact", "joined"] }, { "type": "string" }], "default": "normal" }, "scale": { "title": "Value scale", "description": "zero and auto anchor Bar/Column at zero; data uses the data range.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "zero" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["mode", "density", "scale", "legend", "grid", "axes"] }, "pieChartStyle": { "title": "Pie style", "description": "Pie or Donut shape presentation.", "type": "object", "properties": { "shape": { "title": "Pie shape", "description": "pie fills the center; donut uses a fixed 60% cutout.", "anyOf": [{ "type": "string", "enum": ["pie", "donut"] }, { "type": "string" }], "default": "pie" }, "legend": { "title": "Legend visibility", "description": "show renders the slice legend; hide relies on tooltips.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "show" }, "frame": { "title": "Chart frame", "description": "compact reduces Pie layout padding; normal retains the standard frame.", "anyOf": [{ "type": "string", "enum": ["normal", "compact"] }, { "type": "string" }], "default": "normal" } }, "additionalProperties": true, "x-altinity-order": ["shape", "legend", "frame"] }, "chartCfg": { "type": "object", "properties": { "x": { "$ref": "#/$defs/resultColumnIndex", "default": 0 }, "y": { "title": "Measure columns", "description": "One or more zero-based result-column indexes used as measures.", "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/resultColumnIndex" } }, "series": { "title": "Series column", "description": "Optional zero-based result-column index used to split series.", "oneOf": [{ "$ref": "#/$defs/resultColumnIndex" }, { "type": "null" }], "default": null, "x-altinity-completion": { "source": "resultColumnIndexes" } } }, "required": ["x", "y"], "additionalProperties": true, "x-altinity-order": ["type", "x", "y", "series"] }, "styledChartCfg": { "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "type": "object", "properties": { "style": { "type": "object" } } }], "x-altinity-order": ["type", "style", "x", "y", "series"] }, "panelCfg": { "title": "Panel type configuration", "description": "Discriminated visualization configuration. Unknown types remain storable for forward compatibility.", "type": "object", "required": ["type"], "properties": { "type": { "title": "Panel type", "description": "Visualization renderer identifier.", "type": "string", "minLength": 1 } }, "additionalProperties": true, "x-altinity-discriminator": "type", "x-altinity-order": ["type"], "oneOf": [{ "title": "Column chart", "description": "Vertical columns using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "bar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "bar" } }, "required": ["type"] }] }, { "title": "Horizontal bar chart", "description": "Horizontal bars using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "hbar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "hbar" } }, "required": ["type"] }] }, { "title": "Line chart", "description": "Line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "line", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/lineChartStyle" } } }, { "properties": { "type": { "const": "line" } }, "required": ["type"] }] }, { "title": "Area chart", "description": "Filled line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "area", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/areaChartStyle" } } }, { "properties": { "type": { "const": "area" } }, "required": ["type"] }] }, { "title": "Pie chart", "description": "Pie slices using one positional measure role.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "pie", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/pieChartStyle" } } }, { "properties": { "type": { "const": "pie" }, "y": { "type": "array", "maxItems": 1 } }, "required": ["type"] }] }, { "title": "KPI", "description": "One-row scalar and named-tuple KPI cards.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "kpi" }, "properties": { "type": { "const": "kpi" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type"] }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "table" }, "properties": { "type": { "const": "table" } }, "required": ["type"], "additionalProperties": true }, { "title": "Logs", "description": "Timestamped log messages with optional explicit result-column roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "logs", "time": "event_time", "msg": "message", "level": "level" }, "properties": { "type": { "const": "logs" }, "time": { "$ref": "#/$defs/columnName" }, "msg": { "$ref": "#/$defs/columnName" }, "level": { "$ref": "#/$defs/columnName" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "time", "msg", "level"] }, { "title": "Markdown text", "description": "Safe Markdown content that does not require a SQL result.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "text", "content": "# Heading\n\nMarkdown content." }, "properties": { "type": { "const": "text" }, "content": { "title": "Markdown content", "description": "Source text for the safe Markdown renderer.", "type": "string", "default": "" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "content"] }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", "x-altinity-status": "planned", "x-altinity-snippet": { "type": "future-panel" }, "properties": { "type": { "type": "string", "minLength": 1, "not": { "enum": ["bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text"] } } }, "required": ["type"], "additionalProperties": true }] } } }; +var schema31 = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://altinity.com/schemas/altinity-sql-browser/query-spec-v1.schema.json", "title": "Altinity SQL Browser saved-query Spec v1", "description": "The user-authored query.spec document. Saved-query envelope fields are intentionally outside this schema.", "x-altinity-kind": "query-spec", "x-altinity-version": 1, "type": "object", "properties": { "name": { "title": "Name", "description": "Panel, tile, and Library title.", "type": "string", "minLength": 1, "pattern": "\\S", "examples": ["Revenue by country"] }, "description": { "title": "Description", "description": "Optional authoring note shown with the saved query.", "type": "string" }, "favorite": { "title": "Favorite", "description": "Whether the query is included in favorite-driven surfaces.", "type": "boolean", "default": false }, "view": { "title": "Preferred result view", "description": "The result representation restored when the saved query opens.", "type": "string", "enum": ["table", "json", "panel"], "default": "table" }, "panel": { "$ref": "#/$defs/panel" }, "dashboard": { "$ref": "#/$defs/queryDashboardPresentationV1" }, "timeRanges": { "title": "Time-range parameter semantics", "description": "Zero or one authoritative From/To parameter pair. Omission allows conservative authoring inference; an empty array explicitly disables inference.", "type": "array", "maxItems": 1, "items": { "$ref": "#/$defs/queryTimeRangeV1" } } }, "additionalProperties": true, "x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard", "timeRanges"], "$defs": { "queryTimeRangeV1": { "title": "Time-range parameter pair", "description": "The exact saved-query parameter names used as the lower and upper bounds of one time range.", "type": "object", "required": ["from", "to"], "properties": { "from": { "title": "From parameter", "type": "string", "minLength": 1, "pattern": "\\S" }, "to": { "title": "To parameter", "type": "string", "minLength": 1, "pattern": "\\S" } }, "additionalProperties": false, "x-altinity-order": ["from", "to"] }, "columnName": { "title": "Result column", "description": "Exact top-level ClickHouse result-column name.", "type": "string", "minLength": 1, "x-altinity-completion": { "source": "resultColumns" } }, "resultColumnIndex": { "title": "Result column index", "description": "Zero-based index of a ClickHouse result column.", "type": "integer", "minimum": 0, "x-altinity-completion": { "source": "resultColumnIndexes" } }, "deltaPresentation": { "title": "Delta presentation", "description": "Display metadata for a runtime KPI delta value.", "type": "object", "properties": { "displayName": { "title": "Delta label", "description": "Optional visible label for the delta.", "type": "string" }, "unit": { "title": "Delta unit", "description": "Display-only suffix appended to the delta.", "type": "string" }, "decimals": { "title": "Delta decimal places", "description": "Requested display rounding for the delta.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [1] }, "positiveIsGood": { "title": "Positive is good", "description": "Whether a positive runtime delta has good semantics.", "type": "boolean" }, "show": { "title": "Show delta", "description": "Whether a present runtime delta is rendered.", "type": "boolean", "default": true } }, "additionalProperties": true, "x-altinity-order": ["displayName", "unit", "decimals", "positiveIsGood", "show"] }, "fieldConfigValue": { "title": "Field presentation metadata", "description": "Known presentation metadata for one result column. Unknown renderer extensions are retained.", "type": "object", "properties": { "displayName": { "title": "Display name", "description": "Rendered label for the field.", "type": "string" }, "decimals": { "title": "Decimal places", "description": "Requested number of decimal places for numeric display.", "type": "integer", "minimum": 0, "maximum": 20, "default": 0, "examples": [2] }, "description": { "title": "Description", "description": "Supporting display text for the field.", "type": "string" }, "unit": { "title": "Unit", "description": "Display-only suffix appended to the value.", "type": "string", "examples": ["%"] }, "color": { "title": "Color", "description": "Theme token or CSS color hint interpreted by the renderer.", "type": "string" }, "noValue": { "title": "No-value text", "description": "Text shown for NULL or unavailable values.", "type": "string", "default": "\u2014" }, "hidden": { "title": "Hidden", "description": "Suppress this otherwise eligible result field.", "type": "boolean", "default": false }, "delta": { "$ref": "#/$defs/deltaPresentation" } }, "additionalProperties": true, "x-altinity-order": ["displayName", "description", "unit", "decimals", "color", "noValue", "hidden", "delta"] }, "fieldConfig": { "title": "Panel field configuration", "description": "Default and per-column display metadata.", "type": "object", "properties": { "defaults": { "$ref": "#/$defs/fieldConfigValue" }, "columns": { "title": "Column overrides", "description": "Display metadata keyed by exact result-column name.", "type": "object", "additionalProperties": { "$ref": "#/$defs/fieldConfigValue" }, "x-altinity-key-completion": { "source": "resultColumns" } } }, "additionalProperties": true, "x-altinity-order": ["defaults", "columns"] }, "queryPresentationPatchV1": { "title": "Presentation patch", "description": "RFC 7396 JSON Merge Patch applied over the saved query's base panel object. Object members merge recursively, arrays replace the previous array completely, and a null member deletes an optional property. A patch may not change panel.cfg.type, and the final resolved panel must validate like a normal saved-query panel.", "type": "object", "additionalProperties": true }, "dashboardSizeHints": { "title": "Size hints", "description": "Layout-neutral tile size hints. Authoring may derive an initial placement from them; they never own actual Dashboard placement.", "type": "object", "properties": { "preferred": { "title": "Preferred size", "description": "Preferred layout-neutral tile size.", "type": "string", "enum": ["compact", "medium", "wide"] }, "minimum": { "title": "Minimum size", "description": "Smallest layout-neutral tile size that still renders usefully.", "type": "string", "enum": ["compact", "medium", "wide"] }, "aspectRatio": { "title": "Aspect ratio", "description": "Preferred width/height ratio hint.", "type": "number", "exclusiveMinimum": 0 } }, "additionalProperties": true, "x-altinity-order": ["preferred", "minimum", "aspectRatio"] }, "queryDashboardPresentationV1": { "title": "Dashboard configuration", "description": "Reusable Dashboard presentation for this query: role, named presentation variants, and layout-neutral size hints. Actual Dashboard sequence, placement, and tile-local overrides live in the Dashboard document. Feature-specific extensions remain forward compatible.", "type": "object", "properties": { "role": { "title": "Dashboard role (Panel, Filter, or Setup)", "description": "How the saved query participates in a dashboard. panel (the default) creates a visualization tile. filter returns exactly one row whose supported top-level Array, named Tuple Array, or Map columns provide option lists for parameters with the same exact names; it creates no tile and its SQL cannot declare parameters. setup is reserved for serialized Dashboard setup execution.", "type": "string", "enum": ["panel", "filter", "setup"], "default": "panel", "examples": ["filter"] }, "defaultVariant": { "title": "Default presentation variant", "description": "Variant applied when a tile does not select one. Must name an existing entry in variants.", "type": "string", "minLength": 1, "maxLength": 256 }, "variants": { "title": "Presentation variants", "description": "Named reusable presentation patches, each an RFC 7396 JSON Merge Patch over the base panel.", "type": "object", "maxProperties": 32, "propertyNames": { "minLength": 1, "maxLength": 256 }, "additionalProperties": { "$ref": "#/$defs/queryPresentationPatchV1" } }, "sizeHints": { "$ref": "#/$defs/dashboardSizeHints" } }, "additionalProperties": true, "x-altinity-order": ["role", "defaultVariant", "variants", "sizeHints"] }, "panel": { "title": "Panel configuration", "description": "Visualization and field metadata for the saved query.", "type": "object", "properties": { "cfg": { "$ref": "#/$defs/panelCfg" }, "key": { "title": "Result schema key", "description": "Saved result-column signature used to detect stale positional roles.", "type": ["string", "null"] }, "fieldConfig": { "$ref": "#/$defs/fieldConfig" } }, "additionalProperties": true, "x-altinity-order": ["cfg", "key", "fieldConfig"] }, "lineChartStyle": { "title": "Line style", "description": "Renderer-independent line presentation. Unknown fields and future string values remain storable.", "type": "object", "properties": { "curve": { "title": "Line curve", "description": "linear draws straight segments, smooth uses monotone interpolation, and stepped draws step-after segments.", "anyOf": [{ "type": "string", "enum": ["linear", "smooth", "stepped"] }, { "type": "string" }], "default": "linear" }, "points": { "title": "Point markers", "description": "auto shows markers only for sparse results, show always displays them, and hide retains hover targets without visible markers.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "scale": { "title": "Value scale", "description": "zero anchors the value axis at zero, data uses the data range, and auto uses the chart-type default.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "data" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["curve", "points", "scale", "legend", "grid", "axes"] }, "areaChartStyle": { "title": "Area style", "description": "Curve, marker, and additive stacking presentation for Area charts.", "allOf": [{ "$ref": "#/$defs/lineChartStyle" }, { "type": "object", "properties": { "stack": { "title": "Area stacking", "description": "overlay draws series independently; stacked uses one shared additive stack without normalization.", "anyOf": [{ "type": "string", "enum": ["overlay", "stacked"] }, { "type": "string" }], "default": "overlay" } }, "additionalProperties": true }], "x-altinity-order": ["curve", "points", "stack", "scale", "legend", "grid", "axes"] }, "barChartStyle": { "title": "Bar and Column style", "description": "Grouping and category-spacing presentation shared by horizontal Bar and vertical Column charts.", "type": "object", "properties": { "mode": { "title": "Bar grouping", "description": "grouped draws measures side by side; stacked adds them on one shared value stack.", "anyOf": [{ "type": "string", "enum": ["grouped", "stacked"] }, { "type": "string" }], "default": "grouped" }, "density": { "title": "Category spacing", "description": "normal uses standard spacing, compact reduces gaps, and joined removes category gaps.", "anyOf": [{ "type": "string", "enum": ["normal", "compact", "joined"] }, { "type": "string" }], "default": "normal" }, "scale": { "title": "Value scale", "description": "zero and auto anchor Bar/Column at zero; data uses the data range.", "anyOf": [{ "type": "string", "enum": ["auto", "zero", "data"] }, { "type": "string" }], "default": "zero" }, "legend": { "title": "Legend visibility", "description": "auto shows the legend for multiple datasets; show and hide override that behavior.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "grid": { "title": "Grid visibility", "description": "auto shows the value grid in the workbench and hides it on Dashboard; show and hide override the surface default.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "auto" }, "axes": { "title": "Axis visibility", "description": "show renders both axes; hide removes both axes while retaining chart interaction.", "anyOf": [{ "type": "string", "enum": ["show", "hide"] }, { "type": "string" }], "default": "show" } }, "additionalProperties": true, "x-altinity-order": ["mode", "density", "scale", "legend", "grid", "axes"] }, "pieChartStyle": { "title": "Pie style", "description": "Pie or Donut shape presentation.", "type": "object", "properties": { "shape": { "title": "Pie shape", "description": "pie fills the center; donut uses a fixed 60% cutout.", "anyOf": [{ "type": "string", "enum": ["pie", "donut"] }, { "type": "string" }], "default": "pie" }, "legend": { "title": "Legend visibility", "description": "show renders the slice legend; hide relies on tooltips.", "anyOf": [{ "type": "string", "enum": ["auto", "show", "hide"] }, { "type": "string" }], "default": "show" }, "frame": { "title": "Chart frame", "description": "compact reduces Pie layout padding; normal retains the standard frame.", "anyOf": [{ "type": "string", "enum": ["normal", "compact"] }, { "type": "string" }], "default": "normal" } }, "additionalProperties": true, "x-altinity-order": ["shape", "legend", "frame"] }, "chartCfg": { "type": "object", "properties": { "x": { "$ref": "#/$defs/resultColumnIndex", "default": 0 }, "y": { "title": "Measure columns", "description": "One or more zero-based result-column indexes used as measures.", "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/resultColumnIndex" } }, "series": { "title": "Series column", "description": "Optional zero-based result-column index used to split series.", "oneOf": [{ "$ref": "#/$defs/resultColumnIndex" }, { "type": "null" }], "default": null, "x-altinity-completion": { "source": "resultColumnIndexes" } } }, "required": ["x", "y"], "additionalProperties": true, "x-altinity-order": ["type", "x", "y", "series"] }, "styledChartCfg": { "allOf": [{ "$ref": "#/$defs/chartCfg" }, { "type": "object", "properties": { "style": { "type": "object" } } }], "x-altinity-order": ["type", "style", "x", "y", "series"] }, "panelCfg": { "title": "Panel type configuration", "description": "Discriminated visualization configuration. Unknown types remain storable for forward compatibility.", "type": "object", "required": ["type"], "properties": { "type": { "title": "Panel type", "description": "Visualization renderer identifier.", "type": "string", "minLength": 1 } }, "additionalProperties": true, "x-altinity-discriminator": "type", "x-altinity-order": ["type"], "oneOf": [{ "title": "Column chart", "description": "Vertical columns using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "bar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "bar" } }, "required": ["type"] }] }, { "title": "Horizontal bar chart", "description": "Horizontal bars using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "hbar", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/barChartStyle" } } }, { "properties": { "type": { "const": "hbar" } }, "required": ["type"] }] }, { "title": "Line chart", "description": "Line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "line", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/lineChartStyle" } } }, { "properties": { "type": { "const": "line" } }, "required": ["type"] }] }, { "title": "Area chart", "description": "Filled line series using positional X and measure roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "area", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/areaChartStyle" } } }, { "properties": { "type": { "const": "area" } }, "required": ["type"] }] }, { "title": "Pie chart", "description": "Pie slices using one positional measure role.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "pie", "x": 0, "y": [1], "series": null }, "allOf": [{ "$ref": "#/$defs/styledChartCfg" }, { "properties": { "style": { "$ref": "#/$defs/pieChartStyle" } } }, { "properties": { "type": { "const": "pie" }, "y": { "type": "array", "maxItems": 1 } }, "required": ["type"] }] }, { "title": "KPI", "description": "One-row scalar and named-tuple KPI cards.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "kpi" }, "properties": { "type": { "const": "kpi" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type"] }, { "title": "Table", "description": "Tabular result rendering with no required panel-specific fields.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "table" }, "properties": { "type": { "const": "table" } }, "required": ["type"], "additionalProperties": true }, { "title": "Logs", "description": "Timestamped log messages with optional explicit result-column roles.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "logs", "time": "event_time", "msg": "message", "level": "level" }, "properties": { "type": { "const": "logs" }, "time": { "$ref": "#/$defs/columnName" }, "msg": { "$ref": "#/$defs/columnName" }, "level": { "$ref": "#/$defs/columnName" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "time", "msg", "level"] }, { "title": "Markdown text", "description": "Safe Markdown content that does not require a SQL result.", "x-altinity-status": "implemented", "x-altinity-snippet": { "type": "text", "content": "# Heading\n\nMarkdown content." }, "properties": { "type": { "const": "text" }, "content": { "title": "Markdown content", "description": "Source text for the safe Markdown renderer.", "type": "string", "default": "" } }, "required": ["type"], "additionalProperties": true, "x-altinity-order": ["type", "content"] }, { "title": "Future panel type", "description": "Forward-compatible storage branch for a type implemented by a newer build.", "x-altinity-status": "planned", "x-altinity-snippet": { "type": "future-panel" }, "properties": { "type": { "type": "string", "minLength": 1, "not": { "enum": ["bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text"] } } }, "required": ["type"], "additionalProperties": true }] } } }; var func1 = require_ucs2length().default; var pattern4 = new RegExp("\\S", "u"); var schema32 = { "title": "Panel configuration", "description": "Visualization and field metadata for the saved query.", "type": "object", "properties": { "cfg": { "$ref": "#/$defs/panelCfg" }, "key": { "title": "Result schema key", "description": "Saved result-column signature used to detect stale positional roles.", "type": ["string", "null"] }, "fieldConfig": { "$ref": "#/$defs/fieldConfig" } }, "additionalProperties": true, "x-altinity-order": ["cfg", "key", "fieldConfig"] }; @@ -3421,12 +3421,139 @@ function validate20(data, { instancePath = "", parentData, parentDataProperty, r errors = vErrors.length; } } + if (data.timeRanges !== void 0) { + let data6 = data.timeRanges; + if (Array.isArray(data6)) { + if (data6.length > 1) { + const err7 = { instancePath: instancePath + "/timeRanges", schemaPath: "#/properties/timeRanges/maxItems", keyword: "maxItems", params: { limit: 1 }, message: "must NOT have more than 1 items" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + const len0 = data6.length; + for (let i0 = 0; i0 < len0; i0++) { + let data7 = data6[i0]; + if (data7 && typeof data7 == "object" && !Array.isArray(data7)) { + if (data7.from === void 0) { + const err8 = { instancePath: instancePath + "/timeRanges/" + i0, schemaPath: "#/$defs/queryTimeRangeV1/required", keyword: "required", params: { missingProperty: "from" }, message: "must have required property 'from'" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + if (data7.to === void 0) { + const err9 = { instancePath: instancePath + "/timeRanges/" + i0, schemaPath: "#/$defs/queryTimeRangeV1/required", keyword: "required", params: { missingProperty: "to" }, message: "must have required property 'to'" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + for (const key0 in data7) { + if (!(key0 === "from" || key0 === "to")) { + const err10 = { instancePath: instancePath + "/timeRanges/" + i0, schemaPath: "#/$defs/queryTimeRangeV1/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + } + if (data7.from !== void 0) { + let data8 = data7.from; + if (typeof data8 === "string") { + if (func1(data8) < 1) { + const err11 = { instancePath: instancePath + "/timeRanges/" + i0 + "/from", schemaPath: "#/$defs/queryTimeRangeV1/properties/from/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + if (!pattern4.test(data8)) { + const err12 = { instancePath: instancePath + "/timeRanges/" + i0 + "/from", schemaPath: "#/$defs/queryTimeRangeV1/properties/from/pattern", keyword: "pattern", params: { pattern: "\\S" }, message: 'must match pattern "\\S"' }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + } else { + const err13 = { instancePath: instancePath + "/timeRanges/" + i0 + "/from", schemaPath: "#/$defs/queryTimeRangeV1/properties/from/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + } + if (data7.to !== void 0) { + let data9 = data7.to; + if (typeof data9 === "string") { + if (func1(data9) < 1) { + const err14 = { instancePath: instancePath + "/timeRanges/" + i0 + "/to", schemaPath: "#/$defs/queryTimeRangeV1/properties/to/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err14]; + } else { + vErrors.push(err14); + } + errors++; + } + if (!pattern4.test(data9)) { + const err15 = { instancePath: instancePath + "/timeRanges/" + i0 + "/to", schemaPath: "#/$defs/queryTimeRangeV1/properties/to/pattern", keyword: "pattern", params: { pattern: "\\S" }, message: 'must match pattern "\\S"' }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + } else { + const err16 = { instancePath: instancePath + "/timeRanges/" + i0 + "/to", schemaPath: "#/$defs/queryTimeRangeV1/properties/to/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } + } + } else { + const err17 = { instancePath: instancePath + "/timeRanges/" + i0, schemaPath: "#/$defs/queryTimeRangeV1/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err17]; + } else { + vErrors.push(err17); + } + errors++; + } + } + } else { + const err18 = { instancePath: instancePath + "/timeRanges", schemaPath: "#/properties/timeRanges/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err18]; + } else { + vErrors.push(err18); + } + errors++; + } + } } else { - const err7 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err19 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err7]; + vErrors = [err19]; } else { - vErrors.push(err7); + vErrors.push(err19); } errors++; } @@ -3809,9 +3936,9 @@ function validate44(data, { instancePath = "", parentData, parentDataProperty, r } validate44.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; var validateFlowLayoutV1 = validate46; -var schema57 = { "title": "Flow preset", "description": "Desktop column arrangement: report renders one constrained-width centered column, columns-2 and columns-3 render equal columns.", "type": "string", "enum": ["report", "columns-2", "columns-3"] }; -var schema58 = { "title": "Tile placement", "description": "Closed placement contract: unknown fields fail validation. Future extension requires flow@2 or an explicit extension namespace.", "type": "object", "properties": { "span": { "title": "Column span", "description": "Columns the tile occupies; the effective span is clamped to the active column count.", "type": "integer", "enum": [1, 2, 3] }, "height": { "$ref": "#/$defs/flowHeightV1" } }, "additionalProperties": false, "x-altinity-order": ["span", "height"] }; -var schema59 = { "title": "Tile height", "description": "Normative height ordering is compact < medium < large; exact pixels are renderer-defined.", "type": "string", "enum": ["compact", "medium", "large"] }; +var schema58 = { "title": "Flow preset", "description": "Desktop column arrangement: report renders one constrained-width centered column, columns-2 and columns-3 render equal columns.", "type": "string", "enum": ["report", "columns-2", "columns-3"] }; +var schema59 = { "title": "Tile placement", "description": "Closed placement contract: unknown fields fail validation. Future extension requires flow@2 or an explicit extension namespace.", "type": "object", "properties": { "span": { "title": "Column span", "description": "Columns the tile occupies; the effective span is clamped to the active column count.", "type": "integer", "enum": [1, 2, 3] }, "height": { "$ref": "#/$defs/flowHeightV1" } }, "additionalProperties": false, "x-altinity-order": ["span", "height"] }; +var schema60 = { "title": "Tile height", "description": "Normative height ordering is compact < medium < large; exact pixels are renderer-defined.", "type": "string", "enum": ["compact", "medium", "large"] }; function validate47(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; @@ -3846,7 +3973,7 @@ function validate47(data, { instancePath = "", parentData, parentDataProperty, r errors++; } if (!(data0 === 1 || data0 === 2 || data0 === 3)) { - const err2 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/enum", keyword: "enum", params: { allowedValues: schema58.properties.span.enum }, message: "must be equal to one of the allowed values" }; + const err2 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/enum", keyword: "enum", params: { allowedValues: schema59.properties.span.enum }, message: "must be equal to one of the allowed values" }; if (vErrors === null) { vErrors = [err2]; } else { @@ -3867,7 +3994,7 @@ function validate47(data, { instancePath = "", parentData, parentDataProperty, r errors++; } if (!(data1 === "compact" || data1 === "medium" || data1 === "large")) { - const err4 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/flowHeightV1/enum", keyword: "enum", params: { allowedValues: schema59.enum }, message: "must be equal to one of the allowed values" }; + const err4 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/flowHeightV1/enum", keyword: "enum", params: { allowedValues: schema60.enum }, message: "must be equal to one of the allowed values" }; if (vErrors === null) { vErrors = [err4]; } else { @@ -4002,7 +4129,7 @@ function validate46(data, { instancePath = "", parentData, parentDataProperty, r errors++; } if (!(data2 === "report" || data2 === "columns-2" || data2 === "columns-3")) { - const err10 = { instancePath: instancePath + "/preset", schemaPath: "#/$defs/flowPresetV1/enum", keyword: "enum", params: { allowedValues: schema57.enum }, message: "must be equal to one of the allowed values" }; + const err10 = { instancePath: instancePath + "/preset", schemaPath: "#/$defs/flowPresetV1/enum", keyword: "enum", params: { allowedValues: schema58.enum }, message: "must be equal to one of the allowed values" }; if (vErrors === null) { vErrors = [err10]; } else { @@ -4086,7 +4213,7 @@ function validate46(data, { instancePath = "", parentData, parentDataProperty, r } validate46.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; var validateGrafanaGridLayoutV1 = validate49; -var schema62 = { "title": "Tile height", "description": "Tile height in numeric row units (1..16); px = 32 + 88*units, so units 1/2/3 land close to the legacy compact/medium/large tiers (120/208/296px) and unit 16 reaches 1440px. The legacy compact|medium|large strings are still accepted for backward compatibility and are normalized to their numeric equivalents (1/2/3) by the layout's `normalize` step; new writes should always be numeric.", "anyOf": [{ "type": "integer", "minimum": 1, "maximum": 16 }, { "type": "string", "enum": ["compact", "medium", "large"] }] }; +var schema63 = { "title": "Tile height", "description": "Tile height in numeric row units (1..16); px = 32 + 88*units, so units 1/2/3 land close to the legacy compact/medium/large tiers (120/208/296px) and unit 16 reaches 1440px. The legacy compact|medium|large strings are still accepted for backward compatibility and are normalized to their numeric equivalents (1/2/3) by the layout's `normalize` step; new writes should always be numeric.", "anyOf": [{ "type": "integer", "minimum": 1, "maximum": 16 }, { "type": "string", "enum": ["compact", "medium", "large"] }] }; function validate50(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; @@ -4188,7 +4315,7 @@ function validate50(data, { instancePath = "", parentData, parentDataProperty, r errors++; } if (!(data1 === "compact" || data1 === "medium" || data1 === "large")) { - const err8 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/grafanaGridHeightV1/anyOf/1/enum", keyword: "enum", params: { allowedValues: schema62.anyOf[1].enum }, message: "must be equal to one of the allowed values" }; + const err8 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/grafanaGridHeightV1/anyOf/1/enum", keyword: "enum", params: { allowedValues: schema63.anyOf[1].enum }, message: "must be equal to one of the allowed values" }; if (vErrors === null) { vErrors = [err8]; } else { @@ -4397,7 +4524,7 @@ function validate49(data, { instancePath = "", parentData, parentDataProperty, r } validate49.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; var validateDashboardV1 = validate52; -var schema69 = { "title": "Dashboard filter definition", "description": "One Dashboard filter: the targeted parameter name, an optional filter-role source query providing options, and optional explicit target tiles. Runtime filter values are never persisted here.", "type": "object", "required": ["id", "parameter"], "properties": { "id": { "title": "Filter identifier", "description": "Stable filter identity within this Dashboard.", "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" }, "parameter": { "title": "Parameter name", "description": "ClickHouse query parameter name this filter supplies. Target queries must declare the parameter with compatible types.", "type": "string", "minLength": 1, "maxLength": 256 }, "label": { "title": "Filter label", "description": "Optional user-visible filter label.", "type": "string", "maxLength": 512 }, "sourceQueryId": { "title": "Option source query", "description": "ID of a filter-role saved query whose result provides the option list. The source query never creates a tile.", "type": "string", "minLength": 1, "maxLength": 256 }, "targets": { "title": "Target tiles", "description": "Tile IDs this filter applies to. Absent means every compatible panel tile.", "type": "array", "maxItems": 100, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }, "defaultValue": { "title": "Default value", "description": "Optional default parameter value; any JSON value." }, "defaultActive": { "title": "Active by default", "description": "Whether the filter starts active.", "type": "boolean" }, "selection": { "title": "Selection mode override", "description": "Optional explicit selection-mode override for searchable multiselect filters (#189). Omitted means the runtime infers the mode from the agreed consumer parameter type across target queries: a scalar T infers single selection, an Array(T) infers multiselect. Inference is runtime-only and is never persisted here.", "type": "object", "properties": { "mode": { "title": "Selection mode", "description": 'Explicit override for the inferred selection mode: "single" forces one active value, "multiple" forces a searchable multiselect.', "enum": ["single", "multiple"] } }, "additionalProperties": false } }, "additionalProperties": false, "x-altinity-order": ["id", "parameter", "label", "sourceQueryId", "targets", "defaultValue", "defaultActive", "selection"] }; +var schema70 = { "title": "Dashboard filter definition", "description": "One Dashboard filter: the targeted parameter name, an optional filter-role source query providing options, and optional explicit target tiles. Runtime filter values are never persisted here.", "type": "object", "required": ["id", "parameter"], "properties": { "id": { "title": "Filter identifier", "description": "Stable filter identity within this Dashboard.", "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" }, "parameter": { "title": "Parameter name", "description": "ClickHouse query parameter name this filter supplies. Target queries must declare the parameter with compatible types.", "type": "string", "minLength": 1, "maxLength": 256 }, "label": { "title": "Filter label", "description": "Optional user-visible filter label.", "type": "string", "maxLength": 512 }, "sourceQueryId": { "title": "Option source query", "description": "ID of a filter-role saved query whose result provides the option list. The source query never creates a tile.", "type": "string", "minLength": 1, "maxLength": 256 }, "targets": { "title": "Target tiles", "description": "Tile IDs this filter applies to. Absent means every compatible panel tile.", "type": "array", "maxItems": 100, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }, "defaultValue": { "title": "Default value", "description": "Optional default parameter value; any JSON value." }, "defaultActive": { "title": "Active by default", "description": "Whether the filter starts active.", "type": "boolean" }, "selection": { "title": "Selection mode override", "description": "Optional explicit selection-mode override for searchable multiselect filters (#189). Omitted means the runtime infers the mode from the agreed consumer parameter type across target queries: a scalar T infers single selection, an Array(T) infers multiselect. Inference is runtime-only and is never persisted here.", "type": "object", "properties": { "mode": { "title": "Selection mode", "description": 'Explicit override for the inferred selection mode: "single" forces one active value, "multiple" forces a searchable multiselect.', "enum": ["single", "multiple"] } }, "additionalProperties": false } }, "additionalProperties": false, "x-altinity-order": ["id", "parameter", "label", "sourceQueryId", "targets", "defaultValue", "defaultActive", "selection"] }; function validate55(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; @@ -4432,7 +4559,7 @@ function validate55(data, { instancePath = "", parentData, parentDataProperty, r errors++; } if (!(data0 === 1 || data0 === 2 || data0 === 3)) { - const err2 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/enum", keyword: "enum", params: { allowedValues: schema58.properties.span.enum }, message: "must be equal to one of the allowed values" }; + const err2 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/enum", keyword: "enum", params: { allowedValues: schema59.properties.span.enum }, message: "must be equal to one of the allowed values" }; if (vErrors === null) { vErrors = [err2]; } else { @@ -4453,7 +4580,7 @@ function validate55(data, { instancePath = "", parentData, parentDataProperty, r errors++; } if (!(data1 === "compact" || data1 === "medium" || data1 === "large")) { - const err4 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/flowHeightV1/enum", keyword: "enum", params: { allowedValues: schema59.enum }, message: "must be equal to one of the allowed values" }; + const err4 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/flowHeightV1/enum", keyword: "enum", params: { allowedValues: schema60.enum }, message: "must be equal to one of the allowed values" }; if (vErrors === null) { vErrors = [err4]; } else { @@ -4588,7 +4715,7 @@ function validate54(data, { instancePath = "", parentData, parentDataProperty, r errors++; } if (!(data2 === "report" || data2 === "columns-2" || data2 === "columns-3")) { - const err10 = { instancePath: instancePath + "/preset", schemaPath: "#/$defs/flowPresetV1/enum", keyword: "enum", params: { allowedValues: schema57.enum }, message: "must be equal to one of the allowed values" }; + const err10 = { instancePath: instancePath + "/preset", schemaPath: "#/$defs/flowPresetV1/enum", keyword: "enum", params: { allowedValues: schema58.enum }, message: "must be equal to one of the allowed values" }; if (vErrors === null) { vErrors = [err10]; } else { @@ -5641,7 +5768,7 @@ function validate52(data, { instancePath = "", parentData, parentDataProperty, r if (data15.mode !== void 0) { let data16 = data15.mode; if (!(data16 === "single" || data16 === "multiple")) { - const err44 = { instancePath: instancePath + "/filters/" + i0 + "/selection/mode", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/selection/properties/mode/enum", keyword: "enum", params: { allowedValues: schema69.properties.selection.properties.mode.enum }, message: "must be equal to one of the allowed values" }; + const err44 = { instancePath: instancePath + "/filters/" + i0 + "/selection/mode", schemaPath: "#/$defs/dashboardFilterDefinitionV1/properties/selection/properties/mode/enum", keyword: "enum", params: { allowedValues: schema70.properties.selection.properties.mode.enum }, message: "must be equal to one of the allowed values" }; if (vErrors === null) { vErrors = [err44]; } else { diff --git a/src/generated/json-schema.types.ts b/src/generated/json-schema.types.ts index cd4535d3..37ef0b99 100644 --- a/src/generated/json-schema.types.ts +++ b/src/generated/json-schema.types.ts @@ -1,6 +1,18 @@ // Generated by build/compile-json-schemas.mjs. Do not edit. // query-spec v1 — https://altinity.com/schemas/altinity-sql-browser/query-spec-v1.schema.json +/** + * Time-range parameter pair + * + * The exact saved-query parameter names used as the lower and upper bounds of one time range. + */ +export interface QueryTimeRangeV1 { + /** From parameter */ + from: string; + /** To parameter */ + to: string; +} + /** * Result column * @@ -558,6 +570,12 @@ export interface QuerySpecV1 { view?: "table" | "json" | "panel"; panel?: Panel; dashboard?: QueryDashboardPresentationV1; + /** + * Time-range parameter semantics + * + * Zero or one authoritative From/To parameter pair. Omission allows conservative authoring inference; an empty array explicitly disables inference. + */ + timeRanges?: [] | [QueryTimeRangeV1]; [k: string]: unknown; } diff --git a/src/generated/json-schemas.js b/src/generated/json-schemas.js index f3316621..45911b17 100644 --- a/src/generated/json-schemas.js +++ b/src/generated/json-schemas.js @@ -45,6 +45,15 @@ export const querySpecV1Schema = { }, "dashboard": { "$ref": "#/$defs/queryDashboardPresentationV1" + }, + "timeRanges": { + "title": "Time-range parameter semantics", + "description": "Zero or one authoritative From/To parameter pair. Omission allows conservative authoring inference; an empty array explicitly disables inference.", + "type": "array", + "maxItems": 1, + "items": { + "$ref": "#/$defs/queryTimeRangeV1" + } } }, "additionalProperties": true, @@ -54,9 +63,38 @@ export const querySpecV1Schema = { "favorite", "view", "panel", - "dashboard" + "dashboard", + "timeRanges" ], "$defs": { + "queryTimeRangeV1": { + "title": "Time-range parameter pair", + "description": "The exact saved-query parameter names used as the lower and upper bounds of one time range.", + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "title": "From parameter", + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "to": { + "title": "To parameter", + "type": "string", + "minLength": 1, + "pattern": "\\S" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "from", + "to" + ] + }, "columnName": { "title": "Result column", "description": "Exact top-level ClickHouse result-column name.", diff --git a/src/state.ts b/src/state.ts index b47e86ee..ca5c4449 100644 --- a/src/state.ts +++ b/src/state.ts @@ -34,6 +34,8 @@ import type { WorkspaceMutationInput, WorkspaceMutationOutcome } from './ui/app. import type { WorkspaceDiagnostic } from './dashboard/model/workspace-diagnostics.js'; import { queryToken, reconcileLinkedTabs } from './workspace/workspace-sync.js'; import type { LinkedTabSnapshot } from './workspace/workspace-sync.js'; +import { materializeQueryTimeRange } from './core/query-time-range.js'; +import type { QueryTimeRangeInferenceDiagnostic } from './core/query-time-range.js'; // ── Persisted-data types (schema-generated) ───────────────────────────────── @@ -95,7 +97,7 @@ export type MutateWorkspace = ( * validation). Either way NOTHING is mutated — no `state`/`tabs` write happens * until the mutation resolves `ok: true`. */ export type SavedEntryResult = - | { ok: true; entry: SavedQueryV2 } + | { ok: true; entry: SavedQueryV2; diagnostics?: QueryTimeRangeInferenceDiagnostic[] } /** `deletedExternally: true` marks the one abort where the transform found * the target query missing from `latest.queries` (#343 — deleted in another * tab). Callers must surface it and refresh the tab association; every other @@ -864,7 +866,8 @@ export async function createSavedQuery( panel: panel || undefined, view, }); - const entry = asSavedEntry(withQuerySpec({ ...draft, id: makeId('s', now), sql }, normalizeSpec(draft.spec))); + const inferred = materializeQueryTimeRange(draft.spec, sql); + const entry = asSavedEntry(withQuerySpec({ ...draft, id: makeId('s', now), sql }, normalizeSpec(inferred.spec))); if (hasBlockingSpecErrors(validationService.validate(entry.spec, { sql, query: entry, tab }))) { return { ok: false, entry: null }; } @@ -897,7 +900,7 @@ export async function createSavedQuery( tab.lastCommittedQueryToken = queryToken(saved); tab.externalState = null; state.libraryDirty.value = true; - return { ok: true, entry: saved }; + return { ok: true, entry: saved, ...(inferred.diagnostics.length ? { diagnostics: inferred.diagnostics } : {}) }; } /** Atomically persist both documents of a linked tab in one strict aggregate @@ -918,7 +921,11 @@ export async function commitSavedQuery( // only the captured tab/spec (never `latest`), so they run before queueing. const view = state.resultView.value; const persistedView = SAVED_VIEWS.has(view) ? view as 'table' | 'json' | 'panel' : null; - const normalized = normalizeSpec(persistedView ? { ...spec, view: persistedView } : spec); + const sourceSpec = persistedView ? { ...spec, view: persistedView } : spec; + const inferred = tab.dirtySql + ? materializeQueryTimeRange(sourceSpec, String(tab.sqlDraft || '')) + : { spec: sourceSpec, inferred: false, diagnostics: [] }; + const normalized = normalizeSpec(inferred.spec); const sql = String(tab.sqlDraft || ''); const diagnostics = validationService.validate(normalized, { sql, tab }); if (hasBlockingSpecErrors(diagnostics)) return { ok: false, entry: null }; @@ -956,7 +963,7 @@ export async function commitSavedQuery( tab.lastCommittedQueryToken = queryToken(saved); tab.externalState = null; state.libraryDirty.value = true; - return { ok: true, entry: saved }; + return { ok: true, entry: saved, ...(inferred.diagnostics.length ? { diagnostics: inferred.diagnostics } : {}) }; } /** A pure transform folded into the SAME commit candidate as a `patchSavedSpec` diff --git a/src/styles.css b/src/styles.css index 3e17ad15..a7738d3f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,4 +1,5 @@ *, *::before, *::after { box-sizing: border-box; } +.dash-time-chart { cursor: crosshair; } html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; } :root { diff --git a/src/ui/app.ts b/src/ui/app.ts index b61e824f..c7963cc8 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -1329,7 +1329,7 @@ export function createApp(env: CreateAppEnv = {}): App { renderSavedHistory(app); renderResults(app); app.updateEditorModeUi!(); - flashToast('Saved', { document: doc }); + flashToast(result.diagnostics?.[0]?.message || 'Saved', { document: doc }); return result.entry; } @@ -1416,7 +1416,7 @@ export function createApp(env: CreateAppEnv = {}): App { app.updateEditorModeUi!(); app.actions.rerenderTabs(); renderSavedHistory(app); - flashToast('Saved', { document: doc }); + flashToast(result.diagnostics?.[0]?.message || 'Saved', { document: doc }); }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }); // In the multiline description, plain Enter inserts a newline; ⌘/Ctrl+Enter commits. diff --git a/src/ui/chart-render.ts b/src/ui/chart-render.ts index bbd8f971..a7b2a8ca 100644 --- a/src/ui/chart-render.ts +++ b/src/ui/chart-render.ts @@ -80,6 +80,7 @@ export interface RenderChartOpts { controls?: boolean; fieldConfig?: FieldConfig; hideGrid?: boolean; + chartPlugins?: readonly unknown[]; } /** @@ -210,12 +211,16 @@ export function renderChart( // contradicting the "first N rows" note. It would also sort up to the display // cap's rows just to discard all but the first `cap`. const ChartCtor = app.Chart as ChartConstructor; - const chart = new ChartCtor(canvas, chartJsConfig(r.columns, r.rows, cfg, chartColors(app.cssVar), { + const baseConfig = chartJsConfig(r.columns, r.rows, cfg, chartColors(app.cssVar), { fieldConfig: opts.fieldConfig, hideGrid: opts.hideGrid, measures, data: chartData, // aggregated once above — don't re-aggregate for the config - })); + }); + const chartConfig = opts.chartPlugins?.length + ? { ...baseConfig, plugins: [...opts.chartPlugins] } + : baseConfig; + const chart = new ChartCtor(canvas, chartConfig); setChart(chart); // Chart.js's own responsive sizing reads layout through APIs (getComputedStyle, // ResizeObserver) bound to the window the Chart.js module itself runs in — diff --git a/src/ui/dashboard-chart-interaction.ts b/src/ui/dashboard-chart-interaction.ts new file mode 100644 index 00000000..19732ff9 --- /dev/null +++ b/src/ui/dashboard-chart-interaction.ts @@ -0,0 +1,268 @@ +import { + chartScaleTimeToInstant, + instantToChartScaleTime, + type DashboardTimeRangeGroup, +} from '../core/time-range.js'; +import type { ParsedParamType } from '../core/param-type.js'; +import { movedPastThreshold } from '../core/tile-reorder.js'; + +interface TimeScale { + type?: string; + min?: number; + max?: number; + getValueForPixel(pixel: number): unknown; + getPixelForValue(value: number): number; +} + +interface InteractiveChart { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + width: number; + height: number; + chartArea?: { left: number; right: number; top: number; bottom: number }; + scales?: { x?: TimeScale }; + options?: { indexAxis?: string }; + draw(): void; +} + +export interface DashboardChartRegistration { + group: DashboardTimeRangeGroup; + tileId: string; + /** Declared type of the chart's horizontal time column. */ + xType: ParsedParamType | string; + onSelect(fromMs: number, toMs: number): void; +} + +export interface DashboardChartInteractionController { + pluginFor(registration: DashboardChartRegistration): unknown; + destroy(): void; +} + +interface InteractionColors { + crosshair: string; + selectionFill: string; + selectionStroke: string; + labelBackground: string; + labelText: string; +} + +interface RecordEntry { chart: InteractiveChart; registration: DashboardChartRegistration; onPointerDown: EventListener } + +const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); + +export function createDashboardChartInteractionController(opts: { + document: Document; + formatLabel(ms: number, type: ParsedParamType): string; + colors(): InteractionColors; +}): DashboardChartInteractionController { + const records = new Map(); + const hoverByGroup = new Map(); + let selection: { + chart: InteractiveChart; + registration: DashboardChartRegistration; + pointerId: number; + startClientX: number; + startClientY: number; + startX: number; + currentX: number; + active: boolean; + cleanup(): void; + } | null = null; + + const compatible = (chart: InteractiveChart): chart is InteractiveChart & { + chartArea: NonNullable; scales: { x: TimeScale }; + } => chart.options?.indexAxis !== 'y' && chart.scales?.x?.type === 'time' && !!chart.chartArea; + const eligible = (chart: InteractiveChart, registration: DashboardChartRegistration): chart is InteractiveChart & { + chartArea: NonNullable; scales: { x: TimeScale }; + } => compatible(chart) && instantToChartScaleTime(0, registration.xType) != null; + + const redrawGroup = (key: string): void => { + for (const entry of records.values()) if (entry.registration.group.key === key) entry.chart.draw(); + }; + + const clearHover = (key: string, chart?: InteractiveChart): void => { + const current = hoverByGroup.get(key); + if (!current || (chart && current.active !== chart)) return; + hoverByGroup.delete(key); + redrawGroup(key); + }; + + const chartX = (chart: InteractiveChart, clientX: number): number => { + const rect = chart.canvas.getBoundingClientRect(); + return rect.width > 0 ? ((clientX - rect.left) / rect.width) * chart.width : clientX - rect.left; + }; + + const clampedX = (chart: InteractiveChart & { chartArea: NonNullable }, x: number): number => + Math.max(chart.chartArea.left, Math.min(chart.chartArea.right, x)); + + const beginSelection = (chart: InteractiveChart, registration: DashboardChartRegistration, pe: PointerEvent): void => { + if (!eligible(chart, registration) || pe.pointerType !== 'mouse' || pe.button !== 0 || pe.isPrimary === false || pe.metaKey || pe.ctrlKey) return; + const x = chartX(chart, pe.clientX); + const rect = chart.canvas.getBoundingClientRect(); + const y = rect.height > 0 ? ((pe.clientY - rect.top) / rect.height) * chart.height : pe.clientY - rect.top; + if (x < chart.chartArea.left || x > chart.chartArea.right || y < chart.chartArea.top || y > chart.chartArea.bottom) return; + selection?.cleanup(); + const win = opts.document.defaultView; + if (!win) return; + const onMove = (event: PointerEvent): void => { + if (!selection || event.pointerId !== selection.pointerId) return; + selection.currentX = clampedX(chart, chartX(chart, event.clientX)); + if (!selection.active && movedPastThreshold( + event.clientX - selection.startClientX, event.clientY - selection.startClientY, + )) selection.active = true; + if (selection.active) { event.preventDefault(); chart.draw(); } + }; + const finish = (event: PointerEvent): void => { + if (!selection || event.pointerId !== selection.pointerId) return; + const current = selection; + const active = current.active; + current.currentX = clampedX(chart, chartX(chart, event.clientX)); + const scale = chart.scales.x; + const a = scale.getValueForPixel(current.startX); + const b = scale.getValueForPixel(current.currentX); + current.cleanup(); + const instantA = finite(a) ? chartScaleTimeToInstant(a, registration.xType) : null; + const instantB = finite(b) ? chartScaleTimeToInstant(b, registration.xType) : null; + if (active && instantA != null && instantB != null) { + registration.onSelect(Math.min(instantA, instantB), Math.max(instantA, instantB)); + } + }; + const cancel = (): void => selection?.cleanup(); + const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') cancel(); }; + const cleanup = (): void => { + win.removeEventListener('pointermove', onMove as EventListener); + win.removeEventListener('pointerup', finish as EventListener); + win.removeEventListener('pointercancel', cancel); + win.removeEventListener('blur', cancel); + opts.document.removeEventListener('keydown', onKey, true); + chart.canvas.removeEventListener('lostpointercapture', cancel); + if (selection?.chart === chart) selection = null; + chart.draw(); + }; + selection = { + chart, registration, pointerId: pe.pointerId, startClientX: pe.clientX, startClientY: pe.clientY, + startX: x, currentX: x, active: false, cleanup, + }; + win.addEventListener('pointermove', onMove as EventListener); + win.addEventListener('pointerup', finish as EventListener); + win.addEventListener('pointercancel', cancel); + win.addEventListener('blur', cancel); + opts.document.addEventListener('keydown', onKey, true); + chart.canvas.addEventListener('lostpointercapture', cancel); + }; + + const draw = (chart: InteractiveChart, registration: DashboardChartRegistration): void => { + if (!eligible(chart, registration)) return; + const { ctx, chartArea } = chart; + const colors = opts.colors(); + const hover = hoverByGroup.get(registration.group.key); + ctx.save(); + if (hover) { + const scale = chart.scales.x; + const scaleTimestamp = instantToChartScaleTime(hover.timestamp, registration.xType); + if (scaleTimestamp != null + && (!finite(scale.min) || scaleTimestamp >= scale.min) + && (!finite(scale.max) || scaleTimestamp <= scale.max)) { + const x = scale.getPixelForValue(scaleTimestamp); + if (finite(x) && x >= chartArea.left && x <= chartArea.right) { + ctx.strokeStyle = colors.crosshair; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(x, chartArea.top); ctx.lineTo(x, chartArea.bottom); ctx.stroke(); + } + } + } + if (selection?.chart === chart && selection.active) { + const left = Math.min(selection.startX, selection.currentX); + const right = Math.max(selection.startX, selection.currentX); + const from = chart.scales.x.getValueForPixel(left); + const to = chart.scales.x.getValueForPixel(right); + if (finite(from) && finite(to)) { + const fromInstant = chartScaleTimeToInstant(from, registration.xType); + const toInstant = chartScaleTimeToInstant(to, registration.xType); + if (fromInstant == null || toInstant == null) { ctx.restore(); return; } + ctx.fillStyle = colors.selectionFill; + ctx.strokeStyle = colors.selectionStroke; + ctx.fillRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top); + ctx.strokeRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top); + const labels = [ + opts.formatLabel(fromInstant, registration.group.fromType), + opts.formatLabel(toInstant, registration.group.toType), + ]; + ctx.font = '12px sans-serif'; + ctx.textBaseline = 'top'; + const widths = labels.map((label) => ctx.measureText(label).width + 10); + const clampLabel = (center: number, width: number): number => + Math.max(chartArea.left, Math.min(chartArea.right - width, center - width / 2)); + const xs = [clampLabel(left, widths[0]), clampLabel(right, widths[1])]; + labels.forEach((label, index) => { + ctx.fillStyle = colors.labelBackground; ctx.fillRect(xs[index], chartArea.top + 4, widths[index], 20); + ctx.fillStyle = colors.labelText; ctx.fillText(label, xs[index] + 5, chartArea.top + 8); + }); + } + } + ctx.restore(); + }; + + const syncCompatibility = (chart: InteractiveChart, registration: DashboardChartRegistration): void => { + if (!eligible(chart, registration)) return; + chart.canvas.classList.add('dash-time-chart'); + if (!registration.group.interactiveChartTileIds.includes(registration.tileId)) { + registration.group.interactiveChartTileIds.push(registration.tileId); + } + }; + + const pluginFor = (registration: DashboardChartRegistration): unknown => ({ + id: `asb-dashboard-time-range-${registration.tileId}`, + afterInit(chart: InteractiveChart) { + const onPointerDown = ((event: PointerEvent) => beginSelection(chart, registration, event)) as EventListener; + chart.canvas.addEventListener('pointerdown', onPointerDown); + records.set(chart, { chart, registration, onPointerDown }); + syncCompatibility(chart, registration); + }, + afterLayout(chart: InteractiveChart) { + // Real Chart.js constructs scales after `afterInit`; keep membership in + // sync once layout has materialized the actual horizontal scale. + syncCompatibility(chart, registration); + }, + afterEvent(chart: InteractiveChart, args: { event?: { type?: string; x?: number; y?: number }; inChartArea?: boolean }) { + if (!eligible(chart, registration)) return; + const event = args.event; + if (event?.type !== 'mousemove' && event?.type !== 'pointermove') { + clearHover(registration.group.key, chart); return; + } + if (args.inChartArea === false || !finite(event.x) || !finite(event.y) + || event.x < chart.chartArea.left || event.x > chart.chartArea.right + || event.y < chart.chartArea.top || event.y > chart.chartArea.bottom) { + clearHover(registration.group.key, chart); return; + } + const timestamp = chart.scales.x.getValueForPixel(event.x); + if (!finite(timestamp)) { clearHover(registration.group.key, chart); return; } + const instant = chartScaleTimeToInstant(timestamp, registration.xType); + if (instant == null) { clearHover(registration.group.key, chart); return; } + const current = hoverByGroup.get(registration.group.key); + if (current?.timestamp === instant && current.active === chart) return; + hoverByGroup.set(registration.group.key, { timestamp: instant, active: chart }); + redrawGroup(registration.group.key); + }, + afterDatasetsDraw(chart: InteractiveChart) { draw(chart, registration); }, + beforeDestroy(chart: InteractiveChart) { + if (selection?.chart === chart) selection.cleanup(); + const entry = records.get(chart); + if (entry) chart.canvas.removeEventListener('pointerdown', entry.onPointerDown); + chart.canvas.classList.remove('dash-time-chart'); + const interactiveIndex = registration.group.interactiveChartTileIds.indexOf(registration.tileId); + if (interactiveIndex >= 0) registration.group.interactiveChartTileIds.splice(interactiveIndex, 1); + records.delete(chart); + clearHover(registration.group.key, chart); + }, + }); + + return { + pluginFor, + destroy(): void { + selection?.cleanup(); + for (const entry of records.values()) entry.chart.canvas.removeEventListener('pointerdown', entry.onPointerDown); + records.clear(); hoverByGroup.clear(); + }, + }; +} diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 43fc416e..284efd5a 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -56,7 +56,11 @@ import { renderKpiCards, KPI_STREAM_ARIA } from './kpi-panel.js'; import { buildFilterBar } from './filter-bar.js'; import type { FilterBarApp, FilterBarHandle } from './filter-bar.js'; import { pushRecentRange } from '../core/time-range.js'; +import { formatChartTimeLabel, formatChartTimeRange } from '../core/time-range.js'; import type { DashboardTimeRangeGroup, TimeRangeRecent } from '../core/time-range.js'; +import { chartColors } from '../core/chart-data.js'; +import { createDashboardChartInteractionController } from './dashboard-chart-interaction.js'; +import type { DashboardChartInteractionController } from './dashboard-chart-interaction.js'; import { createDashboardViewerSession } from '../dashboard/application/dashboard-viewer-session.js'; import type { DashboardViewerSession, DashboardViewState, ViewerTileState, ViewerFilterState, @@ -118,6 +122,7 @@ const formatBytes: (n: number | null | undefined) => string = formatBytesUntyped export interface DashboardApp { document: Document; state: AppState; + cssVar(name: string): string; dom: AppDom; root: Element | null; toggleTheme(): void; @@ -191,6 +196,8 @@ let installedModifierListeners: // route render must cancel it before replacing the page so no stale gesture can // commit into the newly-rendered Dashboard. let installedGestureCancel: (() => void) | null = null; +let installedDashboardChartInteraction: DashboardChartInteractionController | null = null; +let installedDashboardCleanup: (() => void) | null = null; /** * Build the flow preset switcher as a compact `