diff --git a/CHANGELOG.md b/CHANGELOG.md index c29502f1..21f93728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,34 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **`grafana-grid@1` — a rowless Grafana-style tile-grid Dashboard layout** + (#291, building on #280). A second layout engine next to the normative + `flow@1`: a responsive 12-column tile grid with deterministic packing — + per-tile `{span: 1–12, height: 1–16 row units}` (`px = 32 + 88×units`; + units 1/2/3 ≈ the flow tiers, unit 16 = 1440 px ≈ 5× flow's tallest; + the legacy `compact|medium|large` strings stay readable and upgrade to + 1/2/3 on the next edit) persisted in `layout.items`, vertical position + derived purely from the + canonical `dashboard.tiles[]` order, and a container-width column clamp + (12/6/4/2 at ≥1160/≥720/≥470 px of *content-box* width) that never mutates + persisted spans. No rows, no folding (owner decision). The engine registers + lazily in the layout registry (`schemas/dashboard-layout-grafana-grid-v1. + schema.json` + generated types/validator; the `fallback` slot stays pinned + to `flow@1`), and every grid mutation deterministically regenerates a + **valid `flow@1` fallback** (grid→flow span 1–4→1, 5–8→2, 9–12→3). The + edit-mode layout select gains a **Grafana grid** option: switching from + flow seeds spans from the current flow placements (1→4, 2→6, 3→12) and + snapshots the flow layout as the fallback; switching back restores it. + Workbench-only (edit-mode) placement editing per the mock: whole-card + drag-reorder, **corner-drag resize** (span snaps to columns, height snaps + per row unit across all 16 stops; the tile is pinned to its column during + the drag so the persisted placement always matches the pointer), and tile + delete — every change propagates immediately (no Save/Undo, owner + decision). KPI tiles place like any other tile (no KPI band). Filters, + panel/KPI rendering, and view-mode read-only rules (#306/ADR-0003) are + reused unchanged. Includes a real-browser e2e harness + (`tests/e2e/dashboard-grid.*`) covering packing, row-unit heights, the + responsive clamp, 360 px overflow, resize, and hover chrome. - **Direct + full-screen Dashboard viewing with a one-time cross-tab handoff** (#288, Dashboard v1 Phase 6 — the final phase of #280; also lands #302). Two explicit viewing modes on the standalone `/dashboard` route (ADR-0003): an diff --git a/build/schema-manifest.mjs b/build/schema-manifest.mjs index 37468895..95900a8a 100644 --- a/build/schema-manifest.mjs +++ b/build/schema-manifest.mjs @@ -33,6 +33,19 @@ export const SCHEMA_MANIFEST = [ validatorExport: 'validateFlowLayoutV1', typeExport: 'FlowLayoutV1', }, + // grafana-grid@1 (#291): a second layout engine, sibling to flow@1. The + // generic layout envelope in dashboard-v1.schema.json is already open + // (type/version/items are unconstrained there), so this schema needs no + // $ref from dashboard-v1 — it is its own manifest root purely to get a + // compiled validator + generated types, exactly like flow's own root. The + // `fallback` slot stays pinned to flow@1 only; this schema is never a + // fallback target. + { + path: 'schemas/dashboard-layout-grafana-grid-v1.schema.json', + schemaExport: 'grafanaGridLayoutV1Schema', + validatorExport: 'validateGrafanaGridLayoutV1', + typeExport: 'GrafanaGridLayoutV1', + }, { path: 'schemas/dashboard-v1.schema.json', schemaExport: 'dashboardV1Schema', diff --git a/schemas/dashboard-layout-grafana-grid-v1.schema.json b/schemas/dashboard-layout-grafana-grid-v1.schema.json new file mode 100644 index 00000000..dec3bcc1 --- /dev/null +++ b/schemas/dashboard-layout-grafana-grid-v1.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json", + "title": "Altinity SQL Browser Dashboard grafana-grid@1 layout", + "description": "The normative grafana-grid@1 Dashboard layout: rowless deterministic packing driven by dashboard.tiles order into a single 12-column grid, and per-tile span/height placements keyed by tile ID. Unlike flow@1 this engine has no preset. A grafana-grid primary layout always pairs with a valid flow@1 DashboardLayoutFallbackV1 document, regenerated on every mutation.", + "x-altinity-kind": "dashboard-layout-grafana-grid", + "x-altinity-version": 1, + "type": "object", + "required": ["type", "version", "items"], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier; always grafana-grid for this contract.", + "type": "string", + "const": "grafana-grid" + }, + "version": { + "title": "Layout engine version", + "description": "grafana-grid contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "items": { + "title": "Tile placements", + "description": "Per-tile placement keyed by tile ID. A missing placement uses span 6 and medium height.", + "type": "object", + "maxProperties": 100, + "propertyNames": { "minLength": 1, "maxLength": 256 }, + "additionalProperties": { "$ref": "#/$defs/grafanaGridTilePlacementV1" } + } + }, + "additionalProperties": false, + "x-altinity-order": ["type", "version", "items"], + "$defs": { + "grafanaGridHeightV1": { + "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"] } + ] + }, + "grafanaGridTilePlacementV1": { + "title": "Tile placement", + "description": "Closed placement contract: unknown fields fail validation. Future extension requires grafana-grid@2 or an explicit extension namespace.", + "type": "object", + "properties": { + "span": { + "title": "Column span", + "description": "Columns (of 12) the tile occupies; the effective span is clamped to the active column count at each responsive breakpoint.", + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "height": { "$ref": "#/$defs/grafanaGridHeightV1" } + }, + "additionalProperties": false, + "x-altinity-order": ["span", "height"] + } + } +} diff --git a/schemas/generated/library-v2.bundle.schema.json b/schemas/generated/library-v2.bundle.schema.json index f83fb337..ecd91cc0 100644 --- a/schemas/generated/library-v2.bundle.schema.json +++ b/schemas/generated/library-v2.bundle.schema.json @@ -1298,6 +1298,96 @@ } } }, + "dashboard-layout-grafana-grid-v1": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json", + "title": "Altinity SQL Browser Dashboard grafana-grid@1 layout", + "description": "The normative grafana-grid@1 Dashboard layout: rowless deterministic packing driven by dashboard.tiles order into a single 12-column grid, and per-tile span/height placements keyed by tile ID. Unlike flow@1 this engine has no preset. A grafana-grid primary layout always pairs with a valid flow@1 DashboardLayoutFallbackV1 document, regenerated on every mutation.", + "x-altinity-kind": "dashboard-layout-grafana-grid", + "x-altinity-version": 1, + "type": "object", + "required": [ + "type", + "version", + "items" + ], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier; always grafana-grid for this contract.", + "type": "string", + "const": "grafana-grid" + }, + "version": { + "title": "Layout engine version", + "description": "grafana-grid contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "items": { + "title": "Tile placements", + "description": "Per-tile placement keyed by tile ID. A missing placement uses span 6 and medium height.", + "type": "object", + "maxProperties": 100, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "$ref": "#/$defs/grafanaGridTilePlacementV1" + } + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "type", + "version", + "items" + ], + "$defs": { + "grafanaGridHeightV1": { + "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" + ] + } + ] + }, + "grafanaGridTilePlacementV1": { + "title": "Tile placement", + "description": "Closed placement contract: unknown fields fail validation. Future extension requires grafana-grid@2 or an explicit extension namespace.", + "type": "object", + "properties": { + "span": { + "title": "Column span", + "description": "Columns (of 12) the tile occupies; the effective span is clamped to the active column count at each responsive breakpoint.", + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "height": { + "$ref": "#/$defs/grafanaGridHeightV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "span", + "height" + ] + } + } + }, "dashboard-v1": { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json", diff --git a/schemas/generated/schema-catalog.json b/schemas/generated/schema-catalog.json index 73caa733..8976fe48 100644 --- a/schemas/generated/schema-catalog.json +++ b/schemas/generated/schema-catalog.json @@ -27,6 +27,12 @@ "id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json", "path": "../dashboard-layout-flow-v1.schema.json" }, + { + "kind": "dashboard-layout-grafana-grid", + "version": 1, + "id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json", + "path": "../dashboard-layout-grafana-grid-v1.schema.json" + }, { "kind": "dashboard", "version": 1, diff --git a/src/dashboard/application/dashboard-authoring-session.ts b/src/dashboard/application/dashboard-authoring-session.ts index aad14518..b8680c67 100644 --- a/src/dashboard/application/dashboard-authoring-session.ts +++ b/src/dashboard/application/dashboard-authoring-session.ts @@ -29,7 +29,7 @@ import { diagnostic, sortDiagnostics } from '../model/workspace-diagnostics.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; import { resolveDashboardPresentations } from '../model/presentation-resolver.js'; import { buildDashboardExportBundle } from '../model/dashboard-export.js'; -import { resolveActiveLayoutPlugin } from '../layouts/flow-layout.js'; +import { defaultLayoutRegistry } from '../layouts/layout-registry.js'; import { applyCommand } from './dashboard-commands.js'; import type { DashboardCommand, DashboardCommandResult } from './dashboard-commands.js'; import { createQueryResolver } from './dashboard-query-resolver.js'; @@ -145,16 +145,20 @@ export function createDashboardAuthoringSession( `Command expected draft version ${options.expectedDraftVersion} but the draft is at ${baseVersion}`)], baseVersion); } - // Resolve the active layout plugin — for change-layout, the NEW layout. + // Resolve the active layout plugin through the full registry (#291: the + // grafana-grid@1 engine, not just flow@1) — for change-layout, the NEW + // layout; for every other command, the document's CURRENTLY active + // layout, so `update-placement`/`add-query` seeding and validation route + // through whichever engine (grid or flow) is actually active. const layoutForPlugin = command.type === 'change-layout' ? command.layout : current.document.layout; - const load = resolveActiveLayoutPlugin(layoutForPlugin); - if (!load.ok) return returnFail(load.diagnostics, baseVersion); + const resolved = await defaultLayoutRegistry.resolve(layoutForPlugin); + if (!resolved.ok) return returnFail(resolved.diagnostics, baseVersion); const resolver = createQueryResolver(queries); - const applied = applyCommand(current.document, command, { resolver, genTileId: genId, plugin: load.plugin }); + const applied = applyCommand(current.document, command, { resolver, genTileId: genId, plugin: resolved.plugin }); if (!applied.ok) return returnFail(applied.diagnostics, baseVersion); - const normalized = load.plugin.normalize(applied.dashboard); + const normalized = resolved.plugin.normalize(applied.dashboard); const diagnostics = validateCandidate(normalized); if (diagnostics.length) return returnFail(diagnostics, baseVersion); diff --git a/src/dashboard/application/dashboard-commands.ts b/src/dashboard/application/dashboard-commands.ts index 3947a976..829a6fe0 100644 --- a/src/dashboard/application/dashboard-commands.ts +++ b/src/dashboard/application/dashboard-commands.ts @@ -17,8 +17,12 @@ import { cloneJson } from '../../core/saved-query.js'; import { diagnostic } from '../model/workspace-diagnostics.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; -import { deriveFlowPlacement, setFlowPlacement } from '../layouts/flow-layout.js'; +import { deriveFlowPlacement, resolvePlacement, setFlowPlacement } from '../layouts/flow-layout.js'; import type { DashboardLayoutPlugin } from '../layouts/flow-layout.js'; +import { + deriveGrafanaGridPlacement, gridHeightUnitsFromFlowHeight, gridSpanFromFlowSpan, + regenerateGridFallback as regenerateGridLayoutFallback, setGridPlacement, +} from '../layouts/grafana-grid-layout.js'; import type { QueryResolver } from './dashboard-query-resolver.js'; import type { DashboardDocumentV1, DashboardLayoutDocumentV1, DashboardTileV1, @@ -67,6 +71,29 @@ const missingTile = (tileId: string): WorkspaceDiagnostic => const tileIndex = (tiles: unknown[], tileId: string): number => tiles.findIndex((tile) => isObject(tile) && tile.id === tileId); +/** Command types whose tile/placement mutation must regenerate a + * grafana-grid@1 layout's flow@1 `fallback` (#291 "every grid mutation + * regenerates the flow@1 fallback deterministically") — every command that + * changes `tiles[]` membership/order or a grid placement. `change-layout` + * manages `fallback` itself (see its case below, both engine-switch + * directions); `update-tile` never touches tiles[] membership/order or + * placements, so it is deliberately excluded. */ +const GRID_FALLBACK_COMMANDS = new Set([ + 'add-query', 'add-query-instance', 'remove-tile', 'move-tile', 'update-placement', +]); + +/** Set one tile's placement through whichever engine plugin is ACTIVE + * (`ctx.plugin`): grid → `setGridPlacement`, flow → `setFlowPlacement`. The + * one shared primitive both the `add-query`/`add-query-instance` seed step + * and `update-placement` use (#291 review F10 — this dispatch was + * duplicated at both call sites). */ +function setPlacementForActiveEngine( + plugin: DashboardLayoutPlugin, layout: unknown, tileId: string, placement: unknown, +): void { + if (plugin.type === 'grafana-grid') setGridPlacement(layout, tileId, placement); + else setFlowPlacement(layout, tileId, placement); +} + /** One-level merge of an `update-tile` presentation patch onto the tile's * existing presentation. A non-object patch replaces the whole field; else * each patch sub-field replaces (or, when `null`, deletes) the same sub-field @@ -86,11 +113,24 @@ function mergeTilePresentation(existing: unknown, patch: unknown): unknown { /** Apply one command to an ISOLATED clone of `draft`, returning the candidate * dashboard or command-level diagnostics. The input `draft` is never - * mutated. */ + * mutated. On success, when the ACTIVE command type touches tiles[] or grid + * placements while grafana-grid@1 is the primary layout, the flow@1 + * `fallback` is regenerated as one centralized post-step (#291; + * `change-layout` is excluded — it manages `fallback` itself). */ export function applyCommand( draft: DashboardDocumentV1, command: DashboardCommand, ctx: ApplyCommandContext, ): ApplyCommandResult { const dashboard = cloneJson(draft); + const result = applyCommandToClone(dashboard, command, ctx); + if (result.ok && GRID_FALLBACK_COMMANDS.has(command.type)) { + regenerateGridLayoutFallback(result.dashboard.layout, result.dashboard.tiles); + } + return result; +} + +function applyCommandToClone( + dashboard: DashboardDocumentV1, command: DashboardCommand, ctx: ApplyCommandContext, +): ApplyCommandResult { const tiles = dashboard.tiles as unknown[]; switch (command.type) { @@ -115,8 +155,13 @@ export function applyCommand( const query = ctx.resolver.get(queryId); const sizeHints = isObject(query) && isObject(query.spec) && isObject(query.spec.dashboard) ? query.spec.dashboard.sizeHints : undefined; - const placement = deriveFlowPlacement(sizeHints); - if (placement) setFlowPlacement(dashboard.layout, id, placement); + // Add-time placement seeding is engine-aware (#291): grafana-grid@1 + // always gets an explicit placement (its own default when there is no + // usable hint), matching `deriveGrafanaGridPlacement`'s "no opinion" + // contract being the grid default rather than flow's bare `undefined`. + const placement = ctx.plugin.type === 'grafana-grid' + ? deriveGrafanaGridPlacement(sizeHints) : deriveFlowPlacement(sizeHints); + if (placement) setPlacementForActiveEngine(ctx.plugin, dashboard.layout, id, placement); return { ok: true, dashboard, value: { tileId: id } }; } @@ -176,19 +221,94 @@ export function applyCommand( case 'update-placement': { const index = tileIndex(tiles, command.tileId); if (index < 0) return failWith(missingTile(command.tileId)); + // Validated through the ACTIVE engine's own plugin (`ctx.plugin`, + // resolved by the session from the current document — grid: span + // 1..12; flow: span 1..3) rather than a hardcoded flow plugin (#291). const placementDiags = ctx.plugin.validatePlacement(command.placement, ['layout', 'items', command.tileId]); if (placementDiags.length) return { ok: false, diagnostics: placementDiags }; - setFlowPlacement(dashboard.layout, command.tileId, cloneJson(command.placement)); + setPlacementForActiveEngine(ctx.plugin, dashboard.layout, command.tileId, cloneJson(command.placement)); return { ok: true, dashboard, value: undefined }; } // change-layout: the load/version/fallback failures are the session's - // `resolveActiveLayoutPlugin` step; a normalization that produces an - // invalid document is the session's validation step. Here we only install - // the (cloned) new layout. - default: + // registry-resolution step; a normalization that produces an invalid + // document is the session's validation step. Here we install the new + // layout AND, when it is an engine switch (#291 owner decision 3), + // convert placements between engines: + // - flow -> grafana-grid: seed every tile's grid placement from its + // current flow placement (`resolvePlacement` fills the flow default + // for a tile with none), span converted via `gridSpanFromFlowSpan` and + // height via `gridHeightUnitsFromFlowHeight` (flow's string height -> + // grid's numeric row units, #291 height-units follow-up), and snapshot + // the CURRENT flow layout (minus its own `fallback`, which a flow + // primary never carries) as the new `layout.fallback`. + // - grafana-grid -> flow: restore `layout.fallback` (kept continuously + // valid by the grid-mutation fallback regeneration above) as the new + // flow primary, dropping the `fallback` field itself (a flow primary + // IS the fallback engine, so it never carries one). Switching to a + // flow PRESET while grid is active is exactly this restore, then + // applying `command.layout.preset` on top — the caller (Wave 3 UI) + // only ever needs to send `{ type: 'flow', version: 1, preset? }`. + // - same engine (type+version unchanged) with the incoming layout + // carrying NO `items` field at all: a bare `{type,version}` (e.g. an + // engine-switch dispatch sent while that engine is ALREADY active — + // #291 review F5) must not silently reset `items` to `{}` and discard + // every placement. Preserve the CURRENT items/fallback, with whatever + // fields the command DOES carry (e.g. a flow `preset`) applied on top. + // - anything else (same-engine change WITH an explicit `items` field — + // e.g. a flow preset switch that also spreads its current items, or an + // unrecognized combination): install the (cloned) new layout document + // wholesale, as `change-layout` always did before #291. A regenerated + // fallback still backstops a wholesale grid layout that did not + // already carry one. + case 'change-layout': { + const currentType = dashboard.layout.type; + const targetType = command.layout.type; + + if (currentType === 'flow' && targetType === 'grafana-grid') { + const flowItems = dashboard.layout.items ?? {}; + const gridItems: Record = {}; + for (const tile of tiles) { + if (!isObject(tile) || typeof tile.id !== 'string') continue; + const flowPlacement = resolvePlacement(flowItems[tile.id]); + gridItems[tile.id] = { + span: gridSpanFromFlowSpan(flowPlacement.span), height: gridHeightUnitsFromFlowHeight(flowPlacement.height), + }; + } + // Drop the (never-present-on-a-flow-primary) `fallback` field before + // snapshotting — a flow primary IS the fallback engine, so it never + // carries one, but this stays defensive against a malformed input. + const { fallback: _droppedFallback, ...flowSnapshot } = dashboard.layout; + dashboard.layout = { + type: 'grafana-grid', version: 1, items: gridItems, fallback: flowSnapshot, + } as unknown as DashboardLayoutDocumentV1; + return { ok: true, dashboard, value: undefined }; + } + + if (currentType === 'grafana-grid' && targetType === 'flow') { + const fallback = dashboard.layout.fallback; + if (!fallback) { + return failWith(diagnostic(['layout'], 'dashboard-command-layout-fallback-missing', + 'No flow@1 fallback to restore for this Dashboard')); + } + const restored = cloneJson(fallback) as unknown as Record; + const preset = typeof command.layout.preset === 'string' ? command.layout.preset : undefined; + if (preset !== undefined) restored.preset = preset; + dashboard.layout = restored as unknown as DashboardLayoutDocumentV1; + return { ok: true, dashboard, value: undefined }; + } + + if (currentType === targetType && dashboard.layout.version === command.layout.version + && !Object.hasOwn(command.layout, 'items')) { + dashboard.layout = { ...cloneJson(dashboard.layout), ...cloneJson(command.layout) } as DashboardLayoutDocumentV1; + regenerateGridLayoutFallback(dashboard.layout, dashboard.tiles); + return { ok: true, dashboard, value: undefined }; + } + dashboard.layout = cloneJson(command.layout); + regenerateGridLayoutFallback(dashboard.layout, dashboard.tiles); return { ok: true, dashboard, value: undefined }; + } } } diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index e53616d6..167e6419 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -48,6 +48,9 @@ import { diagnostic as wsDiagnostic } from '../model/workspace-diagnostics.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; import { computeFlowLayout } from '../layouts/flow-layout.js'; import type { FlowLayoutModel } from '../layouts/flow-layout.js'; +import { computeGrafanaGridLayout } from '../layouts/grafana-grid-layout.js'; +import type { GrafanaGridLayoutModel } from '../layouts/grafana-grid-layout.js'; +import { resolveLayoutPluginSync } from '../layouts/layout-registry.js'; import type { DashboardLayoutRegistry } from '../layouts/layout-registry.js'; import type { DashboardDocumentV1, DashboardTileV1, DashboardFilterDefinitionV1, Panel, SavedQueryV2, @@ -98,10 +101,20 @@ export interface ViewerFilterState { options: FilterHelperOption[] | null; } +/** The Dashboard's per-render layout view (#291) — a discriminated union over + * the active layout ENGINE (`resolveLayoutPluginSync`, layout-registry.ts): + * `flow` keeps every `FlowLayoutModel` field verbatim (bit-identical to the + * pre-#291 shape) with an `engine` tag added; `grafana-grid` nests its own + * render model under `grid` instead of spreading it, so the two engines' + * same-named fields (both have `columns`) never collide on one object. */ +export type DashboardLayoutView = + | (FlowLayoutModel & { engine: 'flow' }) + | { engine: 'grafana-grid'; grid: GrafanaGridLayoutModel }; + export interface DashboardViewState { tiles: ViewerTileState[]; filters: ViewerFilterState[]; - layout: FlowLayoutModel; + layout: DashboardLayoutView; /** Count of ACTIVE filter DEFINITIONS (not non-empty stored values, #188). */ activeFilterCount: number; running: boolean; @@ -152,6 +165,13 @@ export interface DashboardViewerDeps { wallNow(): number; /** True at/below the mobile breakpoint — normalizes the flow to one column. */ isMobile?(): boolean; + /** Rendering container width in px, for the grafana-grid engine's own + * responsive effective-columns clamp (12/6/4/2 at the 1160/720/470 + * breakpoints, `effectiveGridColumns`) — flow's responsive behavior stays + * the coarser `isMobile` binary flip above, unaffected by this. Absent or + * non-finite (not yet measured, or a non-DOM consumer) renders at the + * widest desktop breakpoint (12). */ + containerWidth?(): number | undefined; /** Fired when the token preflight fails (the shell wires sign-out). */ onAuthFailed?(): void; /** #171 bound-param recording on a successful tile. */ @@ -397,10 +417,26 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa function buildState(running: boolean, updatedAt: number | null): DashboardViewState { const mobile = !!deps.isMobile?.(); const visible = tiles.map((runtime) => ({ id: runtime.tile.id, isKpi: runtime.isKpi })); + // #291: route to whichever engine the CURRENT document's layout resolves + // to (`resolveLayoutPluginSync` — the same sync helper the application + // layer's other non-awaitable call sites use, since this runs on every + // publish and cannot await the async registry). An unsupported/foreign + // primary with a valid flow@1 fallback still resolves to the flow plugin + // here, exactly as before #291 (`computeFlowLayout`'s own fallback + // handling, untouched) — flow behavior stays bit-identical. + const plugin = resolveLayoutPluginSync(documentRef.layout); + const layout: DashboardLayoutView = plugin.type === 'grafana-grid' + ? { + engine: 'grafana-grid', + grid: computeGrafanaGridLayout({ + tiles: visible, layout: documentRef.layout, containerWidth: deps.containerWidth?.(), + }), + } + : { engine: 'flow', ...computeFlowLayout({ tiles: visible, layout: documentRef.layout, mobile }) }; return { tiles: tiles.map((runtime) => ({ ...runtime.state })), filters: filters.map((filter) => ({ ...filter.state })), - layout: computeFlowLayout({ tiles: visible, layout: documentRef.layout, mobile }), + layout, activeFilterCount: filters.filter((filter) => filter.state.active).length, running, updatedAt, diagnostics: presentationDiagnostics, }; diff --git a/src/dashboard/application/saved-query-mutation.ts b/src/dashboard/application/saved-query-mutation.ts index c3bd4bdf..9b3c8bb7 100644 --- a/src/dashboard/application/saved-query-mutation.ts +++ b/src/dashboard/application/saved-query-mutation.ts @@ -20,7 +20,8 @@ import type { JsonSchemaValidationService } from '../../core/json-schema-validat import type { SpecSchemaService } from '../../core/spec-schema.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; import { resolveDashboardPresentations } from '../model/presentation-resolver.js'; -import { flowLayoutPlugin } from '../layouts/flow-layout.js'; +import { resolveLayoutPluginSync } from '../layouts/layout-registry.js'; +import { regenerateGridFallback } from '../layouts/grafana-grid-layout.js'; import { validateStoredWorkspaceDocument } from '../../workspace/stored-workspace.js'; import type { DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV1, @@ -166,7 +167,15 @@ export function planSavedQueryMutation( const queries = applyQueryMutation(workspace.queries, mutation); let dashboard: DashboardDocumentV1 | null = workspace.dashboard ? cloneJson(workspace.dashboard) : null; if (dashboard && repair) dashboard = applyRepair(dashboard, mutation.queryId, repair); - if (dashboard) dashboard = flowLayoutPlugin.normalize(dashboard); + // Normalize through the ACTIVE layout engine's own plugin (#291: flow@1 or + // grafana-grid@1, resolved from the document's own `layout.type`) rather + // than a hardcoded flow plugin, then regenerate the flow@1 fallback when + // grafana-grid@1 is active (a repair can add/remove tiles, exactly like the + // authoring commands do) — a no-op under flow@1. + if (dashboard) { + dashboard = resolveLayoutPluginSync(dashboard.layout).normalize(dashboard); + regenerateGridFallback(dashboard.layout, dashboard.tiles); + } const candidate: StoredWorkspaceV1 = { storageVersion: 1, id: workspace.id, name: workspace.name, queries: cloneJson(queries), dashboard, diff --git a/src/dashboard/application/tile-membership.ts b/src/dashboard/application/tile-membership.ts index 31349ee8..05424237 100644 --- a/src/dashboard/application/tile-membership.ts +++ b/src/dashboard/application/tile-membership.ts @@ -16,7 +16,8 @@ // folds the result into the same commit candidate as the favorite patch. import { queryDashboardRole } from '../model/workspace-semantics.js'; -import { flowLayoutPlugin } from '../layouts/flow-layout.js'; +import { resolveLayoutPluginSync } from '../layouts/layout-registry.js'; +import { regenerateGridFallback } from '../layouts/grafana-grid-layout.js'; import type { DashboardDocumentV1, SavedQueryV2 } from '../../generated/json-schema.types.js'; /** Remove every tile referencing `queryId`, and scrub those tile ids out of @@ -47,9 +48,15 @@ function removeTilesForQuery(dashboard: DashboardDocumentV1, queryId: string): D * ids from every filter's `targets`. * - `dashboard` null in → `null` out (no Dashboard yet; favorite flip only). * - * The result is always run through `flowLayoutPlugin.normalize` (a new tile - * gets the flow default placement lazily — nothing to add up front — and a - * removed tile's placement item, if any, is dropped) and is always a fresh + * The result is always run through the ACTIVE layout engine's own + * `normalize` (#291: flow@1 or grafana-grid@1, resolved from the document's + * OWN `layout.type` via `resolveLayoutPluginSync` rather than a hardcoded + * flow plugin) — a new tile gets its engine's default placement lazily + * (nothing to add up front), and a removed tile's placement item, if any, is + * dropped. When grafana-grid@1 is active, the flow@1 `fallback` is then + * regenerated too (#291 "every grid mutation regenerates the flow@1 + * fallback"; a no-op under flow@1) — this membership change adds/removes a + * tile just like the authoring commands do. The result is always a fresh * copy; `dashboard` is never mutated. */ export function toggleTileMembership( @@ -68,5 +75,7 @@ export function toggleTileMembership( } else if (hasTile) { next = removeTilesForQuery(dashboard, query.id); } - return flowLayoutPlugin.normalize(next); + const normalized = resolveLayoutPluginSync(next.layout).normalize(next); + regenerateGridFallback(normalized.layout, normalized.tiles); + return normalized; } diff --git a/src/dashboard/layouts/flow-layout.ts b/src/dashboard/layouts/flow-layout.ts index b4b518c2..59ecae45 100644 --- a/src/dashboard/layouts/flow-layout.ts +++ b/src/dashboard/layouts/flow-layout.ts @@ -13,7 +13,7 @@ import { diagnostic } from '../model/workspace-diagnostics.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; -import { isSupportedLayout } from '../model/workspace-semantics.js'; +import { isFlowLayout } from '../model/workspace-semantics.js'; import { cloneJson } from '../../core/saved-query.js'; import { partitionKpiBands } from '../../core/dashboard.js'; import type { @@ -47,12 +47,12 @@ export interface DashboardLayoutPlugin { * `null` (no flow surface to normalize). */ function flowItemsHost(layout: unknown): Record | null { if (!isObject(layout)) return null; - if (isSupportedLayout(layout.type, layout.version)) { + if (isFlowLayout(layout.type, layout.version)) { if (!isObject(layout.items)) { layout.items = {}; } return layout.items as Record; } const fallback = layout.fallback; - if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) { + if (isObject(fallback) && isFlowLayout(fallback.type, fallback.version)) { if (!isObject(fallback.items)) { fallback.items = {}; } return fallback.items as Record; } @@ -119,10 +119,6 @@ export const flowLayoutPlugin: DashboardLayoutPlugin = { type: 'flow', version: 1, normalize, validatePlacement, }; -export type LoadLayoutPluginResult = - | { ok: true; plugin: DashboardLayoutPlugin } - | { ok: false; diagnostics: WorkspaceDiagnostic[] }; - // ── Phase 4: the normative flow@1 render model (#280 "Normative flow@1 // contract") ──────────────────────────────────────────────────────────────── // Pure layout MATH shared by the viewer, the authoring preview, print/export, @@ -175,9 +171,9 @@ export function resolvePlacement(placement: unknown): Required | null { if (!isObject(layout)) return null; - if (isSupportedLayout(layout.type, layout.version)) return layout; + if (isFlowLayout(layout.type, layout.version)) return layout; const fallback = layout.fallback; - if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) return fallback; + if (isObject(fallback) && isFlowLayout(fallback.type, fallback.version)) return fallback; return null; } @@ -278,23 +274,3 @@ export function computeFlowLayout(input: ComputeFlowLayoutInput): FlowLayoutMode return { preset, columns, mobile, rows, order: tiles.map((tile) => tile.id) }; } - -/** Resolve the active layout plugin for one layout document. flow@1 primary → - * the flow plugin; an unsupported primary WITH a valid flow@1 fallback → the - * flow plugin (operating on the fallback); otherwise a load failure — the - * `change-layout` "plugin cannot load / unsupported version without a valid - * fallback" path. */ -export function resolveActiveLayoutPlugin(layout: unknown, path: Path = ['layout']): LoadLayoutPluginResult { - if (isObject(layout)) { - if (isSupportedLayout(layout.type, layout.version)) return { ok: true, plugin: flowLayoutPlugin }; - const fallback = layout.fallback; - if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) { - return { ok: true, plugin: flowLayoutPlugin }; - } - } - return { - ok: false, - diagnostics: [diagnostic(path, 'dashboard-layout-load-failed', - 'Dashboard layout cannot be loaded and has no valid flow@1 fallback')], - }; -} diff --git a/src/dashboard/layouts/grafana-grid-layout.ts b/src/dashboard/layouts/grafana-grid-layout.ts new file mode 100644 index 00000000..3ae79b8f --- /dev/null +++ b/src/dashboard/layouts/grafana-grid-layout.ts @@ -0,0 +1,491 @@ +// The grafana-grid@1 layout plugin (#291): a second Dashboard layout engine, +// sibling to flow@1 (flow-layout.ts). Same `DashboardLayoutPlugin` contract +// (`normalize`, `validatePlacement`) plus its own pure render math. Pure — no +// DOM, no globals; the DOM reconciliation (Wave 3) is a separate module. +// +// Design choices this module makes (owner decisions round, #291 plan): +// - Rowless: unlike flow@1's row-major `FlowRow[]`, grafana-grid@1 lays every +// visible tile directly onto a single flat 12-column grid, in canonical +// `dashboard.tiles[]` order — no row grouping type at all (`FlowRow` is +// deliberately NOT reused/widened). `computeGrafanaGridLayout` still runs a +// real, pure, deterministic packing simulation (row/colStart per tile) so +// the model is testable and usable by a non-CSS-grid consumer (print/export, +// a future canvas renderer) without depending on the browser's own grid +// auto-placement to reproduce identical wrapping. +// - KPI tiles get no special banding (no "kpi-band" concept exists in a +// rowless grid): a KPI tile is placed exactly like any other tile, using +// its own `{span, height}` placement, in canonical order. `isKpi` is +// carried through to the render model only so a renderer can still style a +// KPI tile differently (chrome, not placement). +// - Heights are NUMERIC ROW UNITS, 1..16 (#291 height-units follow-up, owner +// override): `px = 32 + 88*units` is the one canonical formula every +// height→px conversion in this module and the renderer uses — units 1/2/3 +// land close to the legacy compact/medium/large tiers (120/208/296px vs the +// old fixed 118/210/296, "close enough" per the owner decision, not required +// to be exact) and unit 16 reaches 1440px, ~5x the old 296px max. The legacy +// `compact|medium|large` strings stay valid on read (schema `anyOf`) for +// backward compatibility with already-persisted documents; `normalize` +// canonicalizes them to 1/2/3 so persisted docs converge to numeric, and +// every OTHER function in this module (`resolveGridPlacement`, +// `deriveGrafanaGridPlacement`, `deriveFlowFallback`, `snapGridHeight`, +// `computeGrafanaGridLayout`) works with the canonical numeric form only — +// the legacy string is never produced, only ever accepted as input. +// - `gridHeightUnitsFromFlowHeight`/`gridHeightUnitsToFlowHeight` are the +// grid-units ↔ flow-height conversion pair (mirroring +// `gridSpanFromFlowSpan`/`flowSpanFromGridSpan` for span): flow's OWN height +// vocabulary (`compact|medium|large`) is untouched by this change — only the +// grid engine's own persisted `height` moved to numeric units. The mapping +// is deliberately not symmetric (3 flow values, 16 grid units): units 1→ +// compact, 2→medium, ≥3→large going grid→flow, so every unit above 3 still +// maps somewhere sensible in the fallback instead of only unit 3 being valid. +// - `deriveGrafanaGridPlacement` reuses flow's own `sizeHints` → span mapping +// (`deriveFlowPlacement`) and then converts through `gridSpanFromFlowSpan`, +// rather than duplicating the sizeHints interpretation. +// - `deriveFlowFallback` ALWAYS resolves every known tile's EFFECTIVE grid +// placement (via `resolveGridPlacement`, which fills in the grid default +// span 6/medium when a tile has no persisted grid item) before converting +// to a flow item. This is a deliberate choice over "skip tiles with no +// persisted grid placement" (which would let them fall through to flow's +// OWN different default, span 1): every grid mutation regenerates a fully +// explicit, deterministic flow@1 fallback for every tile, so a default-sized +// grid tile (span 6) maps to its flow equivalent (span 2), not flow's own +// unrelated default. + +import { diagnostic } from '../model/workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; +import { cloneJson } from '../../core/saved-query.js'; +import { deriveFlowPlacement } from './flow-layout.js'; +import type { DashboardLayoutPlugin } from './flow-layout.js'; +import type { + DashboardDocumentV1, FlowHeightV1, FlowLayoutV1, FlowTilePlacementV1, + GrafanaGridHeightV1, +} from '../../generated/json-schema.types.js'; + +type Path = (string | number)[]; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const PLACEMENT_FIELDS = new Set(['span', 'height']); + +/** The maximum column count grafana-grid@1 ever resolves to (its widest + * responsive breakpoint, ≥1160px container width). */ +export const GRAFANA_GRID_MAX_COLUMNS = 12; + +const isValidGridSpan = (value: unknown): value is number => + Number.isInteger(value) && (value as number) >= 1 && (value as number) <= GRAFANA_GRID_MAX_COLUMNS; + +// ── Height as numeric row units (#291 height-units follow-up) ────────────── + +/** The valid numeric row-unit range for a grid tile's height. */ +export const GRID_HEIGHT_UNIT_MIN = 1; +export const GRID_HEIGHT_UNIT_MAX = 16; + +/** The canonical units→px formula, the ONE source of truth for every + * height→pixel conversion (the renderer's inline height, and the fixed + * point `snapGridHeight` is built around): `px = 32 + 88*units`. */ +export const GRID_HEIGHT_PX_BASE = 32; +export const GRID_HEIGHT_PX_PER_UNIT = 88; + +/** The grid default height, in row units — the numeric equivalent of the + * legacy "medium" tier. */ +export const DEFAULT_GRID_HEIGHT_UNITS = 2; + +const isValidGridHeightUnits = (value: unknown): value is number => + Number.isInteger(value) && (value as number) >= GRID_HEIGHT_UNIT_MIN && (value as number) <= GRID_HEIGHT_UNIT_MAX; + +/** The legacy `compact|medium|large` string aliases the schema still accepts + * on read, and their numeric row-unit equivalents. */ +const LEGACY_GRID_HEIGHT_UNITS: Record<'compact' | 'medium' | 'large', number> = { compact: 1, medium: 2, large: 3 }; + +const isLegacyGridHeight = (value: unknown): value is keyof typeof LEGACY_GRID_HEIGHT_UNITS => + typeof value === 'string' && Object.hasOwn(LEGACY_GRID_HEIGHT_UNITS, value); + +/** True for anything the schema's `grafanaGridHeightV1` `anyOf` accepts: an + * integer 1..16, or one of the three legacy alias strings. */ +const isValidGridHeightValue = (value: unknown): value is GrafanaGridHeightV1 => + isValidGridHeightUnits(value) || isLegacyGridHeight(value); + +/** Canonicalize one height value to its numeric row-unit form: a legacy + * `compact|medium|large` alias maps to 1/2/3; anything else (including an + * already-numeric or a genuinely invalid value) passes through UNCHANGED — + * this only resolves the KNOWN legacy vocabulary, it is not a validator + * (`validatePlacement` owns rejecting an invalid value). */ +function canonicalGridHeightUnits(value: unknown): unknown { + return isLegacyGridHeight(value) ? LEGACY_GRID_HEIGHT_UNITS[value] : value; +} + +/** Canonicalize + default one height value to a valid numeric row-unit + * count: a legacy alias converts, an already-valid integer passes through, + * anything else (missing, out of range, malformed) falls back to + * `DEFAULT_GRID_HEIGHT_UNITS`. Always returns a valid `1..16` integer. */ +export function normalizeGridHeightUnits(value: unknown): number { + const canonical = canonicalGridHeightUnits(value); + return isValidGridHeightUnits(canonical) ? canonical : DEFAULT_GRID_HEIGHT_UNITS; +} + +/** Row units → px, the canonical formula (`32 + 88*units`); a non-finite + * input still returns a finite number (`32`) rather than propagating NaN. */ +export function gridHeightUnitsToPx(units: number): number { + const safe = Number.isFinite(units) ? units : 0; + return GRID_HEIGHT_PX_BASE + GRID_HEIGHT_PX_PER_UNIT * safe; +} + +const GRID_HEIGHT_UNITS_FROM_FLOW_HEIGHT: Record = { compact: 1, medium: 2, large: 3 }; + +/** flow height (compact|medium|large) → grid row units (1|2|3), used when + * seeding a grid placement from a flow one (flow→grid engine switch, + * `deriveGrafanaGridPlacement`'s size-hints derivation) — the height-units + * mirror of `gridSpanFromFlowSpan`. */ +export function gridHeightUnitsFromFlowHeight(flowHeight: FlowHeightV1): number { + return GRID_HEIGHT_UNITS_FROM_FLOW_HEIGHT[flowHeight]; +} + +/** grid row units → flow height (compact|medium|large): units 1→compact, + * 2→medium, ≥3→large. Used to regenerate the flow@1 fallback on every grid + * mutation — the height-units mirror of `flowSpanFromGridSpan`. An + * invalid/out-of-range input is normalized first, so this always returns a + * valid `FlowHeightV1`. */ +export function gridHeightUnitsToFlowHeight(units: unknown): FlowHeightV1 { + const normalized = normalizeGridHeightUnits(units); + return normalized <= 1 ? 'compact' : normalized === 2 ? 'medium' : 'large'; +} + +/** The grafana-grid@1 default placement (#291): span 6 (half the 12-column + * grid), height 2 row units (the numeric equivalent of flow@1's own + * "medium" default). */ +export const DEFAULT_GRID_PLACEMENT: { span: number; height: number } = { span: 6, height: DEFAULT_GRID_HEIGHT_UNITS }; + +/** The object holding the active grid placements — the primary layout's + * `items` (grafana-grid@1 is never a fallback target, so there is no + * fallback-surface duck-typing here unlike flow's `flowItemsHost`). */ +function gridItemsHost(layout: unknown): Record | null { + if (!isObject(layout)) return null; + if (!isObject(layout.items)) { layout.items = {}; } + return layout.items as Record; +} + +/** Set one tile's grid placement on a layout document (mutates in place). + * No-op when the layout is not an object. */ +export function setGridPlacement(layout: unknown, tileId: string, placement: unknown): void { + const items = gridItemsHost(layout); + if (items) items[tileId] = placement; +} + +/** Derive an initial grid placement from a query's `sizeHints.preferred`, + * reusing flow@1's own `compact|medium|wide` → span mapping + * (`deriveFlowPlacement`) and converting the result through + * `gridSpanFromFlowSpan`. Always returns a complete placement — grafana-grid + * has an explicit default (span 6, medium) rather than flow's "no opinion" + * `undefined`. */ +export function deriveGrafanaGridPlacement(sizeHints: unknown): { span: number; height: number } { + const flowPlacement = deriveFlowPlacement(sizeHints); + if (!flowPlacement || flowPlacement.span === undefined) return { ...DEFAULT_GRID_PLACEMENT }; + // `deriveFlowPlacement`'s own contract: whenever it returns a placement + // (span defined), height is always set too (it never returns a bare span). + return { span: gridSpanFromFlowSpan(flowPlacement.span), height: gridHeightUnitsFromFlowHeight(flowPlacement.height!) }; +} + +const GRID_SPAN_FROM_FLOW_SPAN: Record<1 | 2 | 3, 4 | 6 | 12> = { 1: 4, 2: 6, 3: 12 }; + +/** flow span (1|2|3) → grid span (4|6|12), used when seeding a grid layout + * from an existing flow placement (flow→grid engine switch). */ +export function gridSpanFromFlowSpan(flowSpan: 1 | 2 | 3): 4 | 6 | 12 { + return GRID_SPAN_FROM_FLOW_SPAN[flowSpan]; +} + +/** grid span (1..12) → flow span (1|2|3): 1-4→1, 5-8→2, 9-12→3. Used to + * regenerate the flow@1 fallback on every grid mutation. An invalid/missing + * grid span is treated as the grid default (6), which maps to flow span 2. */ +export function flowSpanFromGridSpan(gridSpan: unknown): 1 | 2 | 3 { + const span = isValidGridSpan(gridSpan) ? gridSpan : DEFAULT_GRID_PLACEMENT.span; + return span <= 4 ? 1 : span <= 8 ? 2 : 3; +} + +/** Merge one stored grid placement with `DEFAULT_GRID_PLACEMENT`: a + * missing/invalid span falls back to the default; height is always + * canonicalized + defaulted through `normalizeGridHeightUnits` (a legacy + * alias converts, an already-numeric value passes through, anything else + * defaults) — so the result is always a complete, NUMERIC `{span, height}` + * (mirrors flow's `resolvePlacement`). */ +export function resolveGridPlacement(placement: unknown): { span: number; height: number } { + const p = isObject(placement) ? placement : {}; + return { + span: isValidGridSpan(p.span) ? (p.span as number) : DEFAULT_GRID_PLACEMENT.span, + height: normalizeGridHeightUnits(p.height), + }; +} + +/** Normalize a candidate document's grid placements: prune a placement whose + * tile no longer exists (as before #291 height-units), AND canonicalize + * every remaining placement's `height` — a legacy `compact|medium|large` + * alias converts to its numeric row-unit equivalent (1/2/3) so a persisted + * document converges to the numeric vocabulary over time; a value already + * numeric (valid or not — validation is `validatePlacement`'s job, not + * this normalization step) is left untouched. */ +function normalize(dashboard: DashboardDocumentV1): DashboardDocumentV1 { + const next = cloneJson(dashboard); + const tileIds = new Set(); + for (const tile of Array.isArray(next.tiles) ? next.tiles : []) { + if (isObject(tile) && typeof tile.id === 'string') tileIds.add(tile.id); + } + const items = gridItemsHost(next.layout); + if (items) { + for (const key of Object.keys(items)) { + if (!tileIds.has(key)) { delete items[key]; continue; } + const item = items[key]; + if (isObject(item) && Object.hasOwn(item, 'height')) { + item.height = canonicalGridHeightUnits(item.height); + } + } + } + return next; +} + +function validatePlacement(placement: unknown, path: Path = []): WorkspaceDiagnostic[] { + if (!isObject(placement)) { + return [diagnostic(path, 'layout-placement-invalid', 'Placement must be an object')]; + } + const out: WorkspaceDiagnostic[] = []; + for (const key of Object.keys(placement)) { + if (!PLACEMENT_FIELDS.has(key)) { + out.push(diagnostic([...path, key], 'layout-placement-unknown-field', + `Unknown grafana-grid placement field ${JSON.stringify(key)}`)); + } + } + if (Object.hasOwn(placement, 'span') && !isValidGridSpan(placement.span)) { + out.push(diagnostic([...path, 'span'], 'layout-placement-invalid-span', + 'Grafana-grid placement span must be an integer from 1 to 12')); + } + if (Object.hasOwn(placement, 'height') && !isValidGridHeightValue(placement.height)) { + out.push(diagnostic([...path, 'height'], 'layout-placement-invalid-height', + 'Grafana-grid placement height must be an integer from 1 to 16 (or the legacy compact, medium, or large)')); + } + return out; +} + +/** The single grafana-grid@1 plugin instance (stateless; safe to share). */ +export const grafanaGridLayoutPlugin: DashboardLayoutPlugin = { + type: 'grafana-grid', version: 1, normalize, validatePlacement, +}; + +// ── Pure render math: rowless packing (#291) ──────────────────────────────── + +/** Effective column count for a container width (#291 "Responsive clamp"): + * ≥1160px→12, ≥720px→6, ≥470px→4, else→2. An absent/non-finite width + * defaults to the widest desktop breakpoint (12) — the useful default for + * tests and non-DOM consumers (print/export) where no measured width exists. */ +export function effectiveGridColumns(containerWidth?: unknown): number { + const width = typeof containerWidth === 'number' && Number.isFinite(containerWidth) ? containerWidth : Infinity; + if (width >= 1160) return 12; + if (width >= 720) return 6; + if (width >= 470) return 4; + return 2; +} + +/** The effective span for one tile: `min(storedSpan ?? default, columns)`. + * An invalid/missing stored span is treated as the grid default (6); the + * persisted span itself is never mutated (mirrors flow's `effectiveSpan`). */ +export function effectiveGridSpan(storedSpan: unknown, columns: number): number { + const span = isValidGridSpan(storedSpan) ? storedSpan : DEFAULT_GRID_PLACEMENT.span; + return Math.min(span, Math.max(1, columns)); +} + +/** One tile as placed for one render pass — a flat position in a single + * grid, NOT a row-grouped `FlowRow` (#291 "rowless"). `row`/`colStart` are a + * real deterministic packing simulation (row-major, wraps when the next + * tile's span does not fit in the remaining columns), independent of + * whatever grid-auto-placement a DOM renderer may additionally rely on. */ +export interface GrafanaGridTileRender { + tileId: string; + index: number; + span: number; + /** Row units (1..16), already canonicalized/defaulted by + * `resolveGridPlacement` — never the legacy string form (#291 + * height-units follow-up: renamed from `height` so a discriminating + * consumer never mistakes this for flow's own string `FlowHeightV1`). */ + heightUnits: number; + isKpi: boolean; + row: number; + colStart: number; +} + +/** The computed grafana-grid@1 render model: a single flat grid, no rows + * type, tagged with `engine` so a caller can discriminate it against a flow + * `FlowLayoutModel` without either type needing to know about the other. */ +export interface GrafanaGridLayoutModel { + engine: 'grafana-grid'; + /** Effective columns for the given container width. */ + columns: number; + /** Every visible tile, positioned, in `dashboard.tiles[]` semantic order. */ + tiles: GrafanaGridTileRender[]; +} + +/** One visible tile the grid lays out, in `dashboard.tiles[]` semantic order. + * `isKpi` marks an explicitly-configured KPI panel (chrome only — grafana- + * grid@1 has no KPI band). */ +export interface GrafanaGridVisibleTile { + id: string; + isKpi?: boolean; +} + +export interface ComputeGrafanaGridLayoutInput { + tiles: readonly GrafanaGridVisibleTile[]; + /** The grafana-grid layout document (or any object whose `items` holds + * grid placements); tolerated when absent/non-object (every tile then + * renders at the grid default). */ + layout: unknown; + /** The rendering container's width in px; see `effectiveGridColumns`. */ + containerWidth?: number; +} + +function gridItemsFor(layout: unknown): Record { + return isObject(layout) && isObject(layout.items) ? (layout.items as Record) : {}; +} + +/** + * Compute the deterministic grafana-grid@1 render model (#291): a single + * flat 12-(or fewer-)column grid, tiles placed in `dashboard.tiles[]` order. + * Row-major packing: place each tile's effective span in the current row + * when it fits, else wrap to a new row — tiles never overlap, and there is + * no row-grouping type, band, or fold (rowless). Pure and non-mutating. + */ +export function computeGrafanaGridLayout(input: ComputeGrafanaGridLayoutInput): GrafanaGridLayoutModel { + const { tiles, layout, containerWidth } = input; + const columns = effectiveGridColumns(containerWidth); + const items = gridItemsFor(layout); + + let row = 0; + let cursor = 0; + const renders: GrafanaGridTileRender[] = tiles.map((tile, index) => { + const placement = resolveGridPlacement(items[tile.id]); + const span = effectiveGridSpan(placement.span, columns); + if (cursor + span > columns) { + row += 1; + cursor = 0; + } + const render: GrafanaGridTileRender = { + tileId: tile.id, index, span, heightUnits: placement.height, isKpi: !!tile.isKpi, row, colStart: cursor, + }; + cursor += span; + return render; + }); + + return { engine: 'grafana-grid', columns, tiles: renders }; +} + +// ── Engine conversion: grid → flow fallback (#291) ────────────────────────── + +/** One tile grafana-grid@1 needs only the stable ID of, to regenerate a + * flow@1 fallback for it. */ +export interface GrafanaGridFallbackTile { + id: string; +} + +/** + * Derive a complete, valid flow@1 layout document from a grafana-grid@1 + * layout, for use as the Dashboard's `fallback` (#291: "every grid mutation + * regenerates the flow@1 fallback deterministically"). Every known tile gets + * an explicit flow item — even one with no persisted grid placement, which + * resolves to the grid default (span 6) and maps to its flow equivalent + * (span 2), rather than silently falling through to flow's own unrelated + * default (span 1). `full-width` is the fallback preset: the closest single- + * column analog to a rowless grid with no fixed column count. + */ +export function deriveFlowFallback( + gridLayout: unknown, tiles: readonly GrafanaGridFallbackTile[], +): FlowLayoutV1 { + const items = gridItemsFor(gridLayout); + const flowItems: Record = {}; + for (const tile of tiles) { + const gridPlacement = resolveGridPlacement(items[tile.id]); + flowItems[tile.id] = { + span: flowSpanFromGridSpan(gridPlacement.span), height: gridHeightUnitsToFlowHeight(gridPlacement.height), + }; + } + return { type: 'flow', version: 1, preset: 'full-width', items: flowItems }; +} + +// ── Pure resize math (#291 Wave 3 — corner-drag resize): the DOM listener in +// ui/dashboard.ts stays a thin imperative adapter (rule 5); the snap/tier +// arithmetic lives here so it is 100%-covered without any DOM. ──────────────── + +/** The 8px gap between grid cells/rows — a single source of truth the CSS + * grid host (`gap: 8px`, styles.css) and the resize pointer math both use, so + * a corner-drag's column-width computation matches what the browser actually + * renders. */ +export const GRID_GAP_PX = 8; + +/** Snap a corner-drag's horizontal pixel delta to a column span: `round((dx + + * gap) / (colWidth + gap))`, clamped to `1..columns` — the same formula the + * design mock's reference implementation uses (`grafana-dashboard-behavior.js`). + * A non-finite/zero `colWidthPx` (no measured column width yet) still returns + * a clamped integer rather than NaN/Infinity. */ +export function snapGridSpan(dxPx: number, colWidthPx: number, gapPx: number, columns: number): number { + const safeColumns = Math.max(1, columns); + const denominator = colWidthPx + gapPx; + if (!Number.isFinite(denominator) || denominator <= 0) return 1; + const raw = Math.round((dxPx + gapPx) / denominator); + return Math.max(1, Math.min(safeColumns, raw)); +} + +/** Snap a corner-drag's vertical pixel delta to the nearest row-unit height: + * `round((dy - 32) / 88)`, clamped to `1..16` — the inverse of + * `gridHeightUnitsToPx`, so dragging a tile to exactly its OWN current px + * height is a stable fixed point (`snapGridHeight(gridHeightUnitsToPx(u)) + * === u` for every valid `u`), not just a nearby tier. */ +export function snapGridHeight(dyPx: number): number { + const raw = Math.round((dyPx - GRID_HEIGHT_PX_BASE) / GRID_HEIGHT_PX_PER_UNIT); + return Math.max(GRID_HEIGHT_UNIT_MIN, Math.min(GRID_HEIGHT_UNIT_MAX, raw)); +} + +/** Extract `{id}` refs from a RAW `dashboard.tiles[]`-shaped array — a + * malformed entry (non-object, or a non-string `id`) is dropped, never + * thrown. Lets every #291 call site hand `regenerateGridFallback` its own + * `dashboard.tiles` array directly instead of pre-mapping/filtering it + * itself (#291 review F9: three call sites duplicated this exact + * `filter(isObject...).map(...)`). */ +function tileRefsOf(tiles: readonly unknown[]): GrafanaGridFallbackTile[] { + const out: GrafanaGridFallbackTile[] = []; + for (const tile of tiles) { + if (isObject(tile) && typeof tile.id === 'string') out.push({ id: tile.id }); + } + return out; +} + +/** Regenerate a grafana-grid@1 layout's flow@1 `fallback` IN PLACE from its + * current `items` + the given RAW `dashboard.tiles[]` array (mutates + * `layout.fallback`, mirroring `setGridPlacement`'s own mutate-in-place + * contract) — a no-op when `layout` is not a grafana-grid@1 document. The + * single shared primitive every #291 application-layer mutation path + * (authoring commands, tile-membership star toggle, saved-query mutation + * planning) calls so "every grid mutation regenerates the flow@1 fallback + * deterministically" is enforced once, not duplicated per call site. The + * non-grid guard runs BEFORE the tiles→refs mapping/allocation (#291 review + * F9) — calling this on the far-more-common flow-engine document costs only + * the guard check, never a `tiles[]` walk that would just be thrown away. */ +export function regenerateGridFallback(layout: unknown, tiles: readonly unknown[]): void { + if (!isObject(layout) || layout.type !== 'grafana-grid') return; + layout.fallback = deriveFlowFallback(layout, tileRefsOf(tiles)); +} + +// ── Measurement math (#291 review F2): the grid host's `clientWidth` INCLUDES +// its own horizontal padding (`.dash-grid`'s `padding: 18px 20px 40px`, +// styles.css), but CSS grid TRACKS occupy the CONTENT box — using +// `clientWidth` directly for the responsive breakpoint clamp or the resize +// column-width math misclassifies tiers near a threshold and skews the +// column width by the same amount. `ui/dashboard.ts` pairs this with a thin +// DOM reader (`getComputedStyle(el).paddingLeft/Right`) for both call sites. ─ + +/** The grid host's CONTENT-box width: `clientWidth` minus its own horizontal + * padding, clamped to a minimum of 0. A non-finite padding read (e.g. an + * empty computed-style string under a no-stylesheet test environment, which + * `parseFloat` turns into `NaN`) is treated as 0 rather than propagating NaN + * — the un-padded `clientWidth` itself, exactly today's (pre-fix) behavior + * when no padding can be read. Pure — the DOM `getComputedStyle` read is the + * caller's job. */ +export function contentBoxWidth(clientWidth: number, padLeft: number, padRight: number): number { + const left = Number.isFinite(padLeft) ? padLeft : 0; + const right = Number.isFinite(padRight) ? padRight : 0; + return Math.max(0, clientWidth - left - right); +} diff --git a/src/dashboard/layouts/layout-registry.ts b/src/dashboard/layouts/layout-registry.ts index 88d2f07e..3a497491 100644 --- a/src/dashboard/layouts/layout-registry.ts +++ b/src/dashboard/layouts/layout-registry.ts @@ -16,9 +16,10 @@ import { diagnostic } from '../model/workspace-diagnostics.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; -import { isSupportedLayout } from '../model/workspace-semantics.js'; +import { isFlowLayout } from '../model/workspace-semantics.js'; import { flowLayoutPlugin } from './flow-layout.js'; import type { DashboardLayoutPlugin } from './flow-layout.js'; +import { grafanaGridLayoutPlugin } from './grafana-grid-layout.js'; const isObject = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); @@ -51,13 +52,32 @@ export interface DashboardLayoutRegistry { resolve(layout: unknown, path?: (string | number)[]): Promise; } +/** The built-in engines with synchronous, already-constructed plugin + * instances (#291 review F6) — the SINGLE source of truth both + * `resolveLayoutPluginSync` (below) and `defaultLayoutRegistry`'s + * registrations derive their dispatch from, so a third built-in engine is + * added in exactly one place instead of two independent, driftable lists + * (a hardcoded `if (type === 'grafana-grid')` alongside a separately + * maintained registration array used to silently default an unhandled new + * engine to flow in the sync path). `load` is registration-time-lazy only + * for every entry, matching flow's original contract — the plugin modules + * are still inlined in the one bundle (CLAUDE.md hard rule 4 forbids network + * code-splitting); a plugin is simply not CONSTRUCTED/imported by a caller + * until a Dashboard document actually requests its `type`. */ +const BUILTIN_SYNC_PLUGINS: readonly DashboardLayoutPlugin[] = [flowLayoutPlugin, grafanaGridLayoutPlugin]; + +/** One registration for a built-in sync plugin — `load` needs no async work + * since the instance already exists. */ +function syncRegistration(plugin: DashboardLayoutPlugin): DashboardLayoutRegistration { + return { id: plugin.type, versions: [plugin.version], load: () => Promise.resolve(plugin) }; +} + /** The always-available flow@1 registration — its `load` returns the shared, * stateless plugin instance (no code-splitting needed for the built-in). */ -export const flowLayoutRegistration: DashboardLayoutRegistration = { - id: 'flow', - versions: [1], - load: () => Promise.resolve(flowLayoutPlugin), -}; +export const flowLayoutRegistration: DashboardLayoutRegistration = syncRegistration(flowLayoutPlugin); + +/** The grafana-grid@1 registration (#291): a second built-in engine. */ +export const grafanaGridLayoutRegistration: DashboardLayoutRegistration = syncRegistration(grafanaGridLayoutPlugin); /** Build a registry from a set of registrations. flow@1 is always present (a * passed `flow` registration is ignored in favour of the built-in, so the @@ -90,7 +110,7 @@ export function createLayoutRegistry( const flowFallbackPlugin = async (layout: Record): Promise => { const fallback = layout.fallback; - if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) { + if (isObject(fallback) && isFlowLayout(fallback.type, fallback.version)) { return load('flow', 1); } return null; @@ -113,6 +133,28 @@ export function createLayoutRegistry( return { supports, load, resolve }; } -/** The default registry: only the built-in flow@1 engine (v1 ships no other - * layout). */ -export const defaultLayoutRegistry: DashboardLayoutRegistry = createLayoutRegistry(); +/** The default registry: every `BUILTIN_SYNC_PLUGINS` entry, registered — + * flow@1 plus grafana-grid@1 (#291), the first second-engine consumer of + * this registry. Derived from the same table `resolveLayoutPluginSync` + * reads (#291 review F6), rather than a separately maintained array. */ +export const defaultLayoutRegistry: DashboardLayoutRegistry = + createLayoutRegistry(BUILTIN_SYNC_PLUGINS.map(syncRegistration)); + +/** Synchronous plugin resolution for pure call sites that mutate a Dashboard + * document but cannot await the async registry (`tile-membership.ts`, + * `saved-query-mutation.ts` — #291): looked up in `BUILTIN_SYNC_PLUGINS` by + * exact `{type, version}` match, else the flow@1 plugin. Both built-in + * plugins are stateless, already-constructed values (`load()` never truly + * defers for either — see the module doc comment above), so no async is + * needed to pick between them. Falling back to the flow plugin for any + * OTHER primary (unknown/unsupported, or a genuine flow@1 primary) matches + * `resolve()`'s own fallback behavior: `flowLayoutPlugin.normalize` already + * knows how to operate against a `fallback` when the primary isn't flow@1 + * itself (`flowItemsHost`/`flowSurface` in flow-layout.ts). */ +export function resolveLayoutPluginSync(layout: unknown): DashboardLayoutPlugin { + if (isObject(layout)) { + const match = BUILTIN_SYNC_PLUGINS.find((plugin) => plugin.type === layout.type && plugin.version === layout.version); + if (match) return match; + } + return flowLayoutPlugin; +} diff --git a/src/dashboard/model/workspace-semantics.ts b/src/dashboard/model/workspace-semantics.ts index a49a49af..a8897017 100644 --- a/src/dashboard/model/workspace-semantics.ts +++ b/src/dashboard/model/workspace-semantics.ts @@ -17,10 +17,34 @@ import { scanParamDeclarations } from '../../core/param-scan.js'; export const FLOW_LAYOUT_V1_SCHEMA_ID = 'https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json'; +export const GRAFANA_GRID_LAYOUT_V1_SCHEMA_ID = + 'https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json'; -/** The layout engines this build can render. flow@1 is the only v1 engine; - * anything else must carry a valid flow@1 fallback or fail before execution. */ +/** Every primary layout engine this build can render, and the compiled schema + * that validates its own document shape (#291 adds grafana-grid@1 as a + * second engine alongside flow@1). */ +const SUPPORTED_LAYOUT_SCHEMAS: Record = { + flow: { versions: [1], schemaId: FLOW_LAYOUT_V1_SCHEMA_ID }, + 'grafana-grid': { versions: [1], schemaId: GRAFANA_GRID_LAYOUT_V1_SCHEMA_ID }, +}; + +/** True for any registered primary engine at a version this build renders + * (flow@1 or grafana-grid@1 in v1). Anything else must carry a valid flow@1 + * fallback or fail before execution. NOT the right check for "is this + * specifically the flow@1 fallback slot" — that stays pinned to flow@1 only + * (`isFlowLayout`) and never widens to another engine, even when that engine + * is itself supported as a primary. */ export const isSupportedLayout = (type: unknown, version: unknown): boolean => + typeof type === 'string' && typeof version === 'number' + && Object.hasOwn(SUPPORTED_LAYOUT_SCHEMAS, type) + && SUPPORTED_LAYOUT_SCHEMAS[type].versions.includes(version); + +/** flow@1 specifically. Used wherever a value must be THE flow engine itself: + * the flow plugin's own item host/render surface (flow-layout.ts), and the + * Dashboard layout's `fallback` slot, which is pinned to flow@1 and must + * never accept another engine even once that engine is a supported primary + * (#291's "do not widen the fallback slot"). */ +export const isFlowLayout = (type: unknown, version: unknown): boolean => type === 'flow' && version === 1; type Path = (string | number)[]; @@ -292,7 +316,10 @@ export function validateDashboardSemantics(dashboard: unknown, { } }; checkItems(layout.items, [...layoutPath, 'items']); - if (isSupportedLayout(layout.type, layout.version)) { + if (isFlowLayout(layout.type, layout.version)) { + // flow@1 IS the fallback engine: a flow primary needs no fallback of + // its own, validated directly against its own schema (unchanged from + // pre-#291 behavior). for (const schemaError of validationService.validate(FLOW_LAYOUT_V1_SCHEMA_ID, layout)) { out.push({ ...schemaError, @@ -301,10 +328,38 @@ export function validateDashboardSemantics(dashboard: unknown, { }); } } else { + // Any other primary — a second known engine (grafana-grid@1) or a + // truly unsupported/unknown one — always requires its own valid flow@1 + // fallback (#291): the fallback slot is pinned to flow@1 and never + // widens, so it stays the one universal safety net regardless of which + // other engine is primary. A known non-flow engine ALSO gets its own + // items validated against its own schema, in addition to the fallback. + const primarySchema = isSupportedLayout(layout.type, layout.version) + ? SUPPORTED_LAYOUT_SCHEMAS[layout.type as string].schemaId : undefined; + if (primarySchema !== undefined) { + // Validate only the engine's own declared shape: `fallback`/`config` + // are envelope-only slots (dashboardLayoutDocumentV1 in + // dashboard-v1.schema.json), not part of any concrete per-engine + // schema (flow@1's own schema doesn't declare them either) — passing + // them through would spuriously fail the engine's closed + // `additionalProperties: false` schema. + const primaryOwn = Object.fromEntries( + Object.entries(layout).filter(([key]) => key !== 'fallback' && key !== 'config'), + ); + for (const schemaError of validationService.validate(primarySchema, primaryOwn)) { + out.push({ + ...schemaError, + path: [...layoutPath, ...schemaError.path], + ...(dashboardId === undefined ? {} : { resource: dashboardId }), + }); + } + } const fallback = layout.fallback; if (fallback === undefined || fallback === null) { emit(layoutPath, 'layout-unsupported-without-fallback', - `Layout ${JSON.stringify(layout.type)}@${JSON.stringify(layout.version)} is unsupported and has no flow@1 fallback`); + primarySchema !== undefined + ? `Layout ${JSON.stringify(layout.type)}@${JSON.stringify(layout.version)} requires a valid flow@1 fallback` + : `Layout ${JSON.stringify(layout.type)}@${JSON.stringify(layout.version)} is unsupported and has no flow@1 fallback`); } else { for (const schemaError of validationService.validate(FLOW_LAYOUT_V1_SCHEMA_ID, fallback)) { out.push({ diff --git a/src/generated/json-schema-validators.js b/src/generated/json-schema-validators.js index c2150bca..9bab9120 100644 --- a/src/generated/json-schema-validators.js +++ b/src/generated/json-schema-validators.js @@ -4085,11 +4085,322 @@ function validate46(data, { instancePath = "", parentData, parentDataProperty, r return errors === 0; } validate46.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -var validateDashboardV1 = validate49; -function validate52(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +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"] }] }; +function validate50(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; - const evaluated0 = validate52.evaluated; + const evaluated0 = validate50.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + for (const key0 in data) { + if (!(key0 === "span" || key0 === "height")) { + const err0 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + } + if (data.span !== void 0) { + let data0 = data.span; + if (!(typeof data0 == "number" && (!(data0 % 1) && !isNaN(data0)) && isFinite(data0))) { + const err1 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (typeof data0 == "number" && isFinite(data0)) { + if (data0 > 12 || isNaN(data0)) { + const err2 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/maximum", keyword: "maximum", params: { comparison: "<=", limit: 12 }, message: "must be <= 12" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + if (data0 < 1 || isNaN(data0)) { + const err3 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/minimum", keyword: "minimum", params: { comparison: ">=", limit: 1 }, message: "must be >= 1" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + } + } + if (data.height !== void 0) { + let data1 = data.height; + const _errs6 = errors; + let valid2 = false; + const _errs7 = errors; + if (!(typeof data1 == "number" && (!(data1 % 1) && !isNaN(data1)) && isFinite(data1))) { + const err4 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/grafanaGridHeightV1/anyOf/0/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + if (typeof data1 == "number" && isFinite(data1)) { + if (data1 > 16 || isNaN(data1)) { + const err5 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/grafanaGridHeightV1/anyOf/0/maximum", keyword: "maximum", params: { comparison: "<=", limit: 16 }, message: "must be <= 16" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + if (data1 < 1 || isNaN(data1)) { + const err6 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/grafanaGridHeightV1/anyOf/0/minimum", keyword: "minimum", params: { comparison: ">=", limit: 1 }, message: "must be >= 1" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + } + var _valid0 = _errs7 === errors; + valid2 = valid2 || _valid0; + const _errs9 = errors; + if (typeof data1 !== "string") { + const err7 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/grafanaGridHeightV1/anyOf/1/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + 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" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + var _valid0 = _errs9 === errors; + valid2 = valid2 || _valid0; + if (!valid2) { + const err9 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/grafanaGridHeightV1/anyOf", keyword: "anyOf", params: {}, message: "must match a schema in anyOf" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } else { + errors = _errs6; + if (vErrors !== null) { + if (_errs6) { + vErrors.length = _errs6; + } else { + vErrors = null; + } + } + } + } + } else { + const err10 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + validate50.errors = vErrors; + return errors === 0; +} +validate50.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate49(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + ; + let vErrors = null; + let errors = 0; + const evaluated0 = validate49.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.type === void 0) { + const err0 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + if (data.version === void 0) { + const err1 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "version" }, message: "must have required property 'version'" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (data.items === void 0) { + const err2 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "items" }, message: "must have required property 'items'" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + for (const key0 in data) { + if (!(key0 === "type" || key0 === "version" || key0 === "items")) { + const err3 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + } + if (data.type !== void 0) { + let data0 = data.type; + if (typeof data0 !== "string") { + const err4 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + if ("grafana-grid" !== data0) { + const err5 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/const", keyword: "const", params: { allowedValue: "grafana-grid" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + } + if (data.version !== void 0) { + let data1 = data.version; + if (!(typeof data1 == "number" && (!(data1 % 1) && !isNaN(data1)) && isFinite(data1))) { + const err6 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + if (1 !== data1) { + const err7 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/const", keyword: "const", params: { allowedValue: 1 }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + } + if (data.items !== void 0) { + let data2 = data.items; + if (data2 && typeof data2 == "object" && !Array.isArray(data2)) { + if (Object.keys(data2).length > 100) { + const err8 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/maxProperties", keyword: "maxProperties", params: { limit: 100 }, message: "must NOT have more than 100 properties" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + for (const key1 in data2) { + const _errs8 = errors; + if (typeof key1 === "string") { + if (func1(key1) > 256) { + const err9 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters", propertyName: key1 }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + if (func1(key1) < 1) { + const err10 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters", propertyName: key1 }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + } + var valid1 = _errs8 === errors; + if (!valid1) { + const err11 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames", keyword: "propertyNames", params: { propertyName: key1 }, message: "property name must be valid" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + } + for (const key2 in data2) { + if (!validate50(data2[key2], { instancePath: instancePath + "/items/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"), parentData: data2, parentDataProperty: key2, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate50.errors : vErrors.concat(validate50.errors); + errors = vErrors.length; + } + } + } else { + const err12 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + } + } else { + const err13 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + validate49.errors = vErrors; + return errors === 0; +} +validate49.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validateDashboardV1 = validate52; +function validate55(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + let vErrors = null; + let errors = 0; + const evaluated0 = validate55.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -4159,15 +4470,15 @@ function validate52(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate52.errors = vErrors; + validate55.errors = vErrors; return errors === 0; } -validate52.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -function validate51(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate55.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate54(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate51.evaluated; + const evaluated0 = validate54.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -4331,8 +4642,8 @@ function validate51(data, { instancePath = "", parentData, parentDataProperty, r } } for (const key2 in data3) { - if (!validate52(data3[key2], { instancePath: instancePath + "/items/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"), parentData: data3, parentDataProperty: key2, rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate52.errors : vErrors.concat(validate52.errors); + if (!validate55(data3[key2], { instancePath: instancePath + "/items/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"), parentData: data3, parentDataProperty: key2, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate55.errors : vErrors.concat(validate55.errors); errors = vErrors.length; } } @@ -4355,14 +4666,14 @@ function validate51(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate51.errors = vErrors; + validate54.errors = vErrors; return errors === 0; } -validate51.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -function validate50(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate54.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate53(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; - const evaluated0 = validate50.evaluated; + const evaluated0 = validate53.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -4555,8 +4866,8 @@ function validate50(data, { instancePath = "", parentData, parentDataProperty, r } } if (data.fallback !== void 0) { - if (!validate51(data.fallback, { instancePath: instancePath + "/fallback", parentData: data, parentDataProperty: "fallback", rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate51.errors : vErrors.concat(validate51.errors); + if (!validate54(data.fallback, { instancePath: instancePath + "/fallback", parentData: data, parentDataProperty: "fallback", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate54.errors : vErrors.concat(validate54.errors); errors = vErrors.length; } } @@ -4569,14 +4880,14 @@ function validate50(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate50.errors = vErrors; + validate53.errors = vErrors; return errors === 0; } -validate50.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -function validate57(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate53.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate60(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; - const evaluated0 = validate57.evaluated; + const evaluated0 = validate60.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -4648,14 +4959,14 @@ function validate57(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate57.errors = vErrors; + validate60.errors = vErrors; return errors === 0; } -validate57.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -function validate56(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate60.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate59(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; - const evaluated0 = validate56.evaluated; + const evaluated0 = validate59.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -4817,8 +5128,8 @@ function validate56(data, { instancePath = "", parentData, parentDataProperty, r } } if (data.presentation !== void 0) { - if (!validate57(data.presentation, { instancePath: instancePath + "/presentation", parentData: data, parentDataProperty: "presentation", rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate57.errors : vErrors.concat(validate57.errors); + if (!validate60(data.presentation, { instancePath: instancePath + "/presentation", parentData: data, parentDataProperty: "presentation", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate60.errors : vErrors.concat(validate60.errors); errors = vErrors.length; } } @@ -4831,15 +5142,15 @@ function validate56(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate56.errors = vErrors; + validate59.errors = vErrors; return errors === 0; } -validate56.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -function validate49(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate59.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate52(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate49.evaluated; + const evaluated0 = validate52.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -5050,8 +5361,8 @@ function validate49(data, { instancePath = "", parentData, parentDataProperty, r } } if (data.layout !== void 0) { - if (!validate50(data.layout, { instancePath: instancePath + "/layout", parentData: data, parentDataProperty: "layout", rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate50.errors : vErrors.concat(validate50.errors); + if (!validate53(data.layout, { instancePath: instancePath + "/layout", parentData: data, parentDataProperty: "layout", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate53.errors : vErrors.concat(validate53.errors); errors = vErrors.length; } } @@ -5346,8 +5657,8 @@ function validate49(data, { instancePath = "", parentData, parentDataProperty, r } const len2 = data15.length; for (let i3 = 0; i3 < len2; i3++) { - if (!validate56(data15[i3], { instancePath: instancePath + "/tiles/" + i3, parentData: data15, parentDataProperty: i3, rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate56.errors : vErrors.concat(validate56.errors); + if (!validate59(data15[i3], { instancePath: instancePath + "/tiles/" + i3, parentData: data15, parentDataProperty: i3, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate59.errors : vErrors.concat(validate59.errors); errors = vErrors.length; } } @@ -5370,16 +5681,16 @@ function validate49(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate49.errors = vErrors; + validate52.errors = vErrors; return errors === 0; } -validate49.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -var validateStoredWorkspaceV1 = validate60; -function validate60(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate52.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validateStoredWorkspaceV1 = validate63; +function validate63(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate60.evaluated; + const evaluated0 = validate63.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -5561,8 +5872,8 @@ function validate60(data, { instancePath = "", parentData, parentDataProperty, r let valid3 = false; let passing0 = null; const _errs13 = errors; - if (!validate49(data5, { instancePath: instancePath + "/dashboard", parentData: data, parentDataProperty: "dashboard", rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate49.errors : vErrors.concat(validate49.errors); + if (!validate52(data5, { instancePath: instancePath + "/dashboard", parentData: data, parentDataProperty: "dashboard", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate52.errors : vErrors.concat(validate52.errors); errors = vErrors.length; } var _valid0 = _errs13 === errors; @@ -5618,16 +5929,16 @@ function validate60(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate60.errors = vErrors; + validate63.errors = vErrors; return errors === 0; } -validate60.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -var validatePortableBundleV1 = validate63; -function validate63(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate63.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validatePortableBundleV1 = validate66; +function validate66(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate63.evaluated; + const evaluated0 = validate66.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -5867,8 +6178,8 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r } const len1 = data9.length; for (let i1 = 0; i1 < len1; i1++) { - if (!validate49(data9[i1], { instancePath: instancePath + "/dashboards/" + i1, parentData: data9, parentDataProperty: i1, rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate49.errors : vErrors.concat(validate49.errors); + if (!validate52(data9[i1], { instancePath: instancePath + "/dashboards/" + i1, parentData: data9, parentDataProperty: i1, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate52.errors : vErrors.concat(validate52.errors); errors = vErrors.length; } } @@ -5891,13 +6202,14 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate63.errors = vErrors; + validate66.errors = vErrors; return errors === 0; } -validate63.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +validate66.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; export { validateDashboardV1, validateFlowLayoutV1, + validateGrafanaGridLayoutV1, validateLibraryV2, validatePortableBundleV1, validateQuerySpecV1, @@ -5910,6 +6222,7 @@ export const validatorsById = { "https://altinity.com/schemas/altinity-sql-browser/saved-query-v2.schema.json": validateSavedQueryV2, "https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json": validateLibraryV2, "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json": validateFlowLayoutV1, + "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json": validateGrafanaGridLayoutV1, "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json": validateDashboardV1, "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json": validateStoredWorkspaceV1, "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json": validatePortableBundleV1, diff --git a/src/generated/json-schema.types.ts b/src/generated/json-schema.types.ts index 742b920a..c1bd9908 100644 --- a/src/generated/json-schema.types.ts +++ b/src/generated/json-schema.types.ts @@ -681,6 +681,56 @@ export interface FlowLayoutV1 { items: Record; } +// dashboard-layout-grafana-grid v1 — https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json + +/** + * Tile height + * + * 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. + */ +export type GrafanaGridHeightV1 = number | "compact" | "medium" | "large"; + +/** + * Tile placement + * + * Closed placement contract: unknown fields fail validation. Future extension requires grafana-grid@2 or an explicit extension namespace. + */ +export interface GrafanaGridTilePlacementV1 { + /** + * Column span + * + * Columns (of 12) the tile occupies; the effective span is clamped to the active column count at each responsive breakpoint. + */ + span?: number; + height?: GrafanaGridHeightV1; +} + +/** + * Altinity SQL Browser Dashboard grafana-grid@1 layout + * + * The normative grafana-grid@1 Dashboard layout: rowless deterministic packing driven by dashboard.tiles order into a single 12-column grid, and per-tile span/height placements keyed by tile ID. Unlike flow@1 this engine has no preset. A grafana-grid primary layout always pairs with a valid flow@1 DashboardLayoutFallbackV1 document, regenerated on every mutation. + */ +export interface GrafanaGridLayoutV1 { + /** + * Layout engine + * + * Layout engine identifier; always grafana-grid for this contract. + */ + type: "grafana-grid"; + /** + * Layout engine version + * + * grafana-grid contract version; always 1 for this contract. + */ + version: 1; + /** + * Tile placements + * + * Per-tile placement keyed by tile ID. A missing placement uses span 6 and medium height. + */ + items: Record; +} + // dashboard v1 — https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json /** diff --git a/src/generated/json-schemas.js b/src/generated/json-schemas.js index a6409390..51acf5a6 100644 --- a/src/generated/json-schemas.js +++ b/src/generated/json-schemas.js @@ -1298,6 +1298,97 @@ export const flowLayoutV1Schema = { } }; +export const grafanaGridLayoutV1Schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json", + "title": "Altinity SQL Browser Dashboard grafana-grid@1 layout", + "description": "The normative grafana-grid@1 Dashboard layout: rowless deterministic packing driven by dashboard.tiles order into a single 12-column grid, and per-tile span/height placements keyed by tile ID. Unlike flow@1 this engine has no preset. A grafana-grid primary layout always pairs with a valid flow@1 DashboardLayoutFallbackV1 document, regenerated on every mutation.", + "x-altinity-kind": "dashboard-layout-grafana-grid", + "x-altinity-version": 1, + "type": "object", + "required": [ + "type", + "version", + "items" + ], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier; always grafana-grid for this contract.", + "type": "string", + "const": "grafana-grid" + }, + "version": { + "title": "Layout engine version", + "description": "grafana-grid contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "items": { + "title": "Tile placements", + "description": "Per-tile placement keyed by tile ID. A missing placement uses span 6 and medium height.", + "type": "object", + "maxProperties": 100, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "$ref": "#/$defs/grafanaGridTilePlacementV1" + } + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "type", + "version", + "items" + ], + "$defs": { + "grafanaGridHeightV1": { + "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" + ] + } + ] + }, + "grafanaGridTilePlacementV1": { + "title": "Tile placement", + "description": "Closed placement contract: unknown fields fail validation. Future extension requires grafana-grid@2 or an explicit extension namespace.", + "type": "object", + "properties": { + "span": { + "title": "Column span", + "description": "Columns (of 12) the tile occupies; the effective span is clamped to the active column count at each responsive breakpoint.", + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "height": { + "$ref": "#/$defs/grafanaGridHeightV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "span", + "height" + ] + } + } +}; + export const dashboardV1Schema = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json", @@ -1755,6 +1846,7 @@ export const schemasById = { "https://altinity.com/schemas/altinity-sql-browser/saved-query-v2.schema.json": savedQueryV2Schema, "https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json": libraryV2Schema, "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json": flowLayoutV1Schema, + "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json": grafanaGridLayoutV1Schema, "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json": dashboardV1Schema, "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json": storedWorkspaceV1Schema, "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json": portableBundleV1Schema, diff --git a/src/styles.css b/src/styles.css index 6c5e7904..f0c8f1ab 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2427,3 +2427,77 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } min-width: 0; max-width: none; width: 100%; } } + +/* ── Grafana-grid layout engine (#291) ───────────────────────────────────── + A second, ROWLESS Dashboard layout: one flat CSS grid host (no `.dash-row` + sub-grids, no KPI band — every tile, KPI or not, is placed the same way). + `.dash-grid.dash-gg-grid` overrides `.dash-grid`'s own `display:flex` with + `display:grid` — same override pattern already used by `.is-report`/ + `.is-wide` above (two classes on the host beat one on specificity), reusing + its outer padding/max-width. Every grid-only rule below is scoped under the + `.dash-gg-grid` ancestor (or the class is explicitly removed by the flow + path, ui/dashboard.ts) so a tile card CACHED across an engine switch never + keeps stray grid chrome once flow renders again. Density/relationships are + the design mock's own (8px gap, semantic tile heights, hover-revealed + dense chrome); colors are this app's own theme tokens (both themes). */ +.dash-grid.dash-gg-grid { + display: grid; grid-auto-rows: min-content; gap: 8px; align-items: stretch; +} +.dash-gg-grid .dash-tile { min-height: 0; } +/* Tile height (#291 height-units follow-up): a NUMERIC row-unit height + (1..16, px = 32 + 88*units — grafana-grid-layout.ts's `gridHeightUnitsToPx`) + applied as a direct inline `height` style per tile (ui/dashboard.ts), not a + fixed-tier CSS class — there is no longer a closed set of tiers to + enumerate as classes. */ +/* Dense Grafana chrome: a tighter head than flow's own tile head (13px name, + 11px/9px padding) — smaller, ellipsized title, hover/focus-revealed grip + + delete (both build only in edit mode, ui/dashboard.ts; CSS additionally + keeps them invisible while flow renders a cached card). */ +.dash-gg-grid .dash-tile-head { + display: flex; align-items: center; gap: 6px; padding: 6px 8px 4px; min-height: 22px; +} +.dash-gg-grid .dash-tile-name { font-size: 12px; font-weight: 600; } +.dash-gg-grip { + display: none; flex-shrink: 0; color: var(--fg-faint); cursor: grab; font-size: 11px; line-height: 1; +} +.dash-gg-grid .dash-gg-grip { display: inline-flex; } +.dash-gg-grip::before { content: '⠿'; } +.dash-gg-del { + display: none; margin-left: auto; flex-shrink: 0; width: 20px; height: 20px; + align-items: center; justify-content: center; padding: 0; color: var(--fg-faint); + background: transparent; border: none; border-radius: 5px; cursor: pointer; opacity: 0; +} +.dash-gg-grid .dash-gg-del { display: inline-flex; } +.dash-gg-grid .dash-gg-tile:hover .dash-gg-del, +.dash-gg-del:focus-visible { opacity: 1; } +.dash-gg-del:hover { color: var(--error-fg); background: var(--error-bg); } +/* Corner-drag resize handle (bottom-right, edit mode only): nwse-resize + cursor, hover-revealed diagonal glyph, accent highlight while resizing. */ +.dash-gg-tile { position: relative; } +.dash-gg-resize { display: none; } +.dash-gg-grid .dash-gg-resize { + display: block; position: absolute; right: 0; bottom: 0; width: 14px; height: 14px; cursor: nwse-resize; +} +.dash-gg-resize::after { + content: ''; position: absolute; right: 3px; bottom: 3px; width: 6px; height: 6px; + border-right: 2px solid var(--fg-faint); border-bottom: 2px solid var(--fg-faint); + opacity: 0; +} +.dash-gg-grid .dash-gg-tile:hover .dash-gg-resize::after, +.dash-gg-resize:hover::after { opacity: 1; } +.dash-gg-resize:hover::after { border-color: var(--accent); } +.dash-gg-tile.dash-gg-resizing { + border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); transition: none; +} +/* A grid tile's KPI content (#291: KPI tiles render inline, no band) fills the + tile body like a chart/table does. */ +.dash-gg-tile .dash-tile-body { flex-wrap: wrap; gap: 8px; } +.dash-gg-tile .dash-tile-body > .kpi-card { flex: 1; min-width: 0; max-width: none; inline-size: auto; } +/* No `@media (max-width: 768px)` override here (#291 review finding F1): unlike + flow's binary mobile flip, grafana-grid@1's ENTIRE responsive behavior is the + JS container-width clamp (`effectiveGridColumns`, 12/6/4/2 at ≥1160/≥720/≥470) + which already sets an inline `grid-template-columns: repeat(N, 1fr)` on this + host and clamps every tile's `grid-column: span N` to fit. A `!important` + media override here does not know about a tile's span — a span>1 tile whose + host was forced to `1fr` still tries to occupy N implicit tracks, rendering + side-by-side/overflowing instead of the model's actual N-column layout. */ diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 3cc24727..0f764017 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -48,8 +48,13 @@ import { createDashboardViewerSession } from '../dashboard/application/dashboard import type { DashboardViewerSession, DashboardViewState, ViewerTileState, ViewerFilterState, } from '../dashboard/application/dashboard-viewer-session.js'; -import { defaultLayoutRegistry } from '../dashboard/layouts/layout-registry.js'; -import { flowLayoutPlugin } from '../dashboard/layouts/flow-layout.js'; +import { defaultLayoutRegistry, resolveLayoutPluginSync } from '../dashboard/layouts/layout-registry.js'; +import type { FlowLayoutModel } from '../dashboard/layouts/flow-layout.js'; +import { + DEFAULT_GRID_HEIGHT_UNITS, GRAFANA_GRID_MAX_COLUMNS, GRID_GAP_PX, contentBoxWidth, gridHeightUnitsToPx, + snapGridHeight, snapGridSpan, +} from '../dashboard/layouts/grafana-grid-layout.js'; +import type { GrafanaGridLayoutModel } from '../dashboard/layouts/grafana-grid-layout.js'; import { applyCommand } from '../dashboard/application/dashboard-commands.js'; import { createQueryResolver } from '../dashboard/application/dashboard-query-resolver.js'; import { resolveDashboardMode } from '../dashboard/application/session-bundle.js'; @@ -60,7 +65,8 @@ import type { DashboardFilterBag } from '../dashboard/model/dashboard-filter-sto import { loadJSON } from '../core/storage.js'; import { KEYS } from '../state.js'; import type { - DashboardDocumentV1, DashboardFilterDefinitionV1, FlowPresetV1, SavedQueryV2, StoredWorkspaceV1, + DashboardDocumentV1, DashboardFilterDefinitionV1, DashboardLayoutDocumentV1, FlowPresetV1, + SavedQueryV2, StoredWorkspaceV1, } from '../generated/json-schema.types.js'; import type { App, AppDom, ActionsRegistry } from './app.types.js'; import type { DashboardOpenSource } from '../dashboard/application/dashboard-open-source.js'; @@ -80,6 +86,7 @@ const Icon: { sun(): SVGElement; moon(): SVGElement; arrow(): SVGElement; + trash(): SVGElement; } = IconUntyped; const formatRows: (n: number | null | undefined) => string = formatRowsUntyped; @@ -117,6 +124,15 @@ export interface DashboardApp { const valueString = (value: unknown): string => (typeof value === 'string' ? value : value == null ? '' : String(value)); +/** #291 review F4: `renderDashboard` can run more than once against the SAME + * window — `app.reloadDashboardRoute()` (app.ts) re-invokes it in place after + * an import-commit while already on `/dashboard` (file-menu.ts's Import + * flow). Module-level so a later call can find and remove the PRIOR call's + * resize listener before installing its own; without this, repeated renders + * stack listeners that all still close over their own render's now-stale + * `session`/`currentDoc`/`containerWidthPx`. */ +let installedGridResizeListener: { win: Window; handler: () => void } | null = null; + /** * Build the flow preset switcher as a compact `