Skip to content

Dashboards Platform

A Cinatra subsystem for interactive analytics dashboards backed by a semantic-layer engine. Two workspace packages cooperate:

  • packages/sdk-dashboard — generic, extraction-ready. Owns DTOs, the drizzle-cube anti-corruption adapter, hooks (useCubeQuery, useCubeMeta), Cube.js wire-format helpers (cubejs-wire.ts), and a permission-resolver-aware persistence surface. May not import @/, @cinatra/*, better-auth, bullmq, or any Cinatra module — provider injection only.
  • packages/dashboards — Cinatra glue. Owns Drizzle tables (dashboards, dashboard_revisions), the mutation service (single PERSIST-03 writer), MCP handlers, screens, Server Actions, and the DashboardsClientShell that wraps <CubeProvider> + <DashboardStoreProvider>.
Browser
src/app/agents/page.tsx → AgentsDashboardPage (server component)
packages/dashboards/src/screens/agents-dashboard.tsx
↓ mounts
DashboardsClientShell (provides QueryClient + CubeProvider + DashboardStoreProvider)
↓ wraps
<DashboardGrid> (from drizzle-cube/client, themed via dashboard-theme.css)
↓ calls
GET /api/dashboards/cubejs-api/v1/load?query=<JSON>
↓ routes through
sdk-dashboard's drizzle-cube adapter → drizzle-cube/server
↓ generates
SELECT ... FROM cinatra.agent_runs LEFT JOIN cinatra.agent_templates ...

On save:

<DashboardGrid onSave={saveAgentsDashboardAction}>
↓ Server Action
packages/dashboards/src/actions.ts → upsertDashboardConfig()
INSERT ... ON CONFLICT (id) DO UPDATE (atomic, wrapped in pg_advisory_xact_lock)
↓ same TX
INSERT INTO audit_events (...) (PERSIST-03 invariant)
  • ADR 0044 — the headless foundation. Seven decisions: drizzle-cube/server only, anti-corruption adapter, two-package split, ESLint boundary, exact-version pin, shadcn-admin UI, LLM only via @cinatra-ai/llm-orchestration.
  • ADR 0045 — the carve-out for drizzle-cube/client. After Phase 273 shipped static <Card> widgets and lost every interactive feature drizzle-cube ships, ADR 0045 walked back Decision 6 (“no drizzle-cube/client/* anywhere”) and scoped a directory-bounded exception: drizzle-cube/client/* allowed inside packages/dashboards/src/components/** ONLY.

ESLint no-restricted-imports in eslint.config.mjs runs in 4 layered blocks (last matching block wins):

LayerScopeWhat it forbids
1repo-widedrizzle-cube/mcp, drizzle-cube/client* (lifted by Layer 4), drizzle-cube root + non-client subpaths (lifted by Layer 3)
2packages/sdk-dashboard/src/** excluding the adapter@/*, @cinatra/*, better-auth, bullmq
3packages/sdk-dashboard/src/adapters/drizzle-cube/**Re-allows drizzle-cube/* server imports
4packages/dashboards/src/components/**Re-allows drizzle-cube/client/* (ADR 0045)

Boundary tests live at packages/sdk-dashboard/src/__tests__/eslint-boundary.test.ts and assert both positive and negative controls.

The /api/dashboards/cubejs-api/v1/[...endpoint]/route.ts route serves drizzle-cube/client (Cube.js-compatible) AND Cinatra’s internal useCubeQuery hook from the same surface:

  • GET /meta — returns CubeMeta ({ cubes: CubeMetaCube[] }) with member names fully qualified <cube>.<member>. Cinatra dimension.type === "date" maps to drizzle-cube "time"; granularities emit as ["day", "week", "month"] string literals (NOT {name: "day"} objects).
  • GET /load?query=<encoded JSON> — Cube.js wire format. Returns { data, query, annotation }.
  • POST /load — same response shape; accepts the legacy { cubeId, query } body too.
  • POST /batch — drizzle-cube/client’s multi-query path; serial-N over /load; HTTP 200 with per-query partial-success items ({ success: true|false, data?, error?, query }).

v1 rejects funnel/flow/retention/multi-query top-level keys with 400 unsupported_analysis_type and rejects filters[] / timeDimensions[] with 400 unsupported_query_feature. Phase 276 widens.

packages/dashboards/src/store/dashboard-config.ts ships two Zod schemas:

  • v1.0.0 (Phase 272) — permissive baseline, portlets discriminated by type.
  • v1.1.0 (ADR 0045) — drizzle-cube DashboardConfig shape: w/h/x/y at portlet root + nested analysisConfig. .passthrough() accepts future drizzle-cube fields; .superRefine() enforces invariants (non-empty id/title, finite layout numbers, at least one content spec).

CURRENT_CONFIG_VERSION = "1.1.0" — new writes use v1.1.0; reads dispatch by config_version so v1.0.0 rows still parse.

packages/dashboards/src/mutation-service.ts is the single writer (PERSIST-03 invariant). Every mutation:

  1. Opens a Postgres transaction.
  2. Acquires pg_advisory_xact_lock(hashtext(id)) (in upsertDashboardConfig) or SELECT ... FOR UPDATE (in update/publish/archive).
  3. Validates DashboardConfig via Zod.
  4. Runs resolveDashboardAccess(row, actor) from packages/dashboards/src/permissions.ts — single permission resolver (PERSIST-08 invariant).
  5. Executes the data change.
  6. Writes an audit_events row INSIDE THE SAME TX.

upsertDashboardConfig uses INSERT ... ON CONFLICT (id) DO UPDATE via Drizzle’s .onConflictDoUpdate() for race-free atomic upsert.

An AST regression gate at packages/dashboards/src/__tests__/no-direct-writes.test.ts walks every file and rejects any tx.insert|.update|.delete(dashboards|dashboardRevisions) outside the mutation-service allowlist.

/agents mounts an interactive <DashboardGrid> with two seeded portlets backed by the agent_runs cube:

  • Top 5 recently used agents — bar chart of agent_runs.count grouped by agent_runs.agent_name, order desc, limit 5.
  • 5 latest run agents — table of agent_runs.last_run_at (MAX, post-processed to relative time) grouped by agent_runs.agent_name, order desc, limit 5.

The dashboard id is system-agents:<orgId>:<userId> (per-user-per-org). On first save the row materialises with ownerLevel: "user" + visibility: "private". Defense-in-depth:

  • The MCP schema (packages/dashboards/src/mcp/schemas.ts) rejects any user-supplied dashboardId starting with system- — the reserved prefix.
  • The screen-level read in loadAgentsConfig() filters by id AND organizationId AND ownerId AND ownerLevel='user', so a pre-poisoned row created under a different actor falls through to the seed.

packages/sdk-dashboard/src/adapters/drizzle-cube/cubes/agent-runs.ts:

  • Dimensions: agent_id (UUID, back-compat), agent_name (LEFT JOIN to agent_templates, coalesce(nullif(name, ''), template_id)), status, created_at (time).
  • Measures: count (count of id), last_run_at (MAX of EXTRACT(EPOCH FROM created_at) — numeric so MAX works; humanised by the route’s humanizeAgentRunsRows() post-processor since DC’s table renderer has no per-column date formatter).
  • Access predicate: WHERE org_id = ctx.organizationId OR run_by = ctx.userId (owns OR can-access). v1 approximates “can access” as active-org membership; Phase 276 widens SecurityContext with accessibleOrgIds[] so users in multiple orgs see all org-accessible runs.
  • The factory takes { tableRef, columns, templatesTableRef, templateColumns }. drizzle-cube needs the FULL Drizzle Table in from (Codex round-12 lesson — passing a destructured column-bag produces FROM $1 and a pg JSON-serialisation crash).

drizzle-cube/client ships ~124 --dc-* CSS variables. packages/dashboards/src/components/dashboard-theme.css (loaded by dashboards-client-shell.tsx AFTER drizzle-cube/client/styles.css) maps ~60 color/surface families to shadcn tokens:

  • Surface family → var(--card) / var(--surface) / var(--surface-muted).
  • Text family → var(--foreground) / var(--muted-foreground).
  • Border → var(--border).
  • Primary / accent → var(--primary) / var(--accent).
  • Status (success / warning / error / info / danger) → shadcn semantic tokens, body fill derived with color-mix(in srgb, var(--…) 15%, var(--card)).
  • Field-picker pills (drizzle-cube concept) — --dc-dimension-* / --dc-measure-* / --dc-time-dimension-* / --dc-filter-* — derived with color-mix(...) at 18% bg / 45% border against shadcn semantics.
  • --dc-primary-rgb hardcoded per theme: light 45, 74, 138, dark 226, 232, 240 (drizzle-cube uses it inside rgba(var(--dc-primary-rgb), <alpha>) which cannot accept a CSS color).
  • Sizing/animation/font tokens keep DC defaults.

DashboardsClientShell sets features.enableAI = false (suppresses AnalysisBuilder AI buttons + short-circuits useExplainAI) and enableBatching = false (single-query path uses N /load calls, not 1 batched /batch; multi-query falls through to /batch via useMultiCubeLoadQuery).

Three MCP tools mount drizzle-cube’s native semantic-query surface under Cinatra’s existing /api/mcp Better-Auth / OAuth gate:

  • dashboards_cube_discover — list cubes + dimensions + measures + drizzle-cube’s query-language reference.
  • dashboards_cube_validate — parse a CubeQuery and surface the generated SQL (drizzle-cube 0.5.5+).
  • dashboards_cube_load — execute a CubeQuery; rows are filtered at the SQL predicate layer.

This is a vanilla integration of drizzle-cube/mcp’s composable getCubeTools. No Cinatra-side query rewriting, limit clamping, or row post-processing. drizzle-cube owns the query lifecycle end-to-end; Cinatra owns identity resolution and registration shape only.

LayerFileJob
Bridgepackages/sdk-dashboard/src/adapters/drizzle-cube/mcp-tools.tsWraps getCubeTools so the rest of the repo never imports drizzle-cube/mcp. Returns Cinatra-typed { definitions, handle, handles, toolNames }.
Schemapackages/sdk-dashboard/src/adapters/drizzle-cube/json-schema-to-zod.tsNarrow converter: drizzle-cube emits plain JSON Schema, Cinatra’s MCP SDK expects Zod. Fails fast on unknown shapes so a drizzle-cube minor bump trips a regression test before the LLM sees the broken tool.
Cubespackages/dashboards/src/mcp-cubes/cubes-singleton.tsLazy globalThis-cached semantic layer + cube tools bridge. Same agent_runs cube definition as the HTTP route.
Identitypackages/dashboards/src/mcp-cubes/handlers.tsReads mcpRequestContextStorage (populated by the MCP transport’s Better Auth / OAuth Bearer pass). Strict A2A precedence — if a2aActorContext is present, BOTH userId AND orgId must come from it.
Registrypackages/dashboards/src/mcp-cubes/registry.tsserver.registerTool(name, meta, handler) x3 — same shape as packages/lists/src/mcp/registry.ts.
Modulepackages/dashboards/src/mcp-cubes/index.tscreateDashboardCubesMcpModule() slotted into the modules = [...] array in src/lib/mcp-server.ts.

Identity flows into drizzle-cube’s getSecurityContext, then into the cube’s sql callback. The agent_runs cube enforces org_id = ctx.organizationId OR run_by = ctx.userId (owns-OR-can-access) at the SQL predicate layer — see Cube — agent_runs above. Phase 276+ widens this to accessibleOrgIds[] for multi-org membership.

Same gate as every other Cinatra MCP tool: the transport authenticates the request and populates mcpRequestContextStorage before any registered handler runs. The cube tools introduce no second auth surface; ESLint enforces this — see ESLint boundary (Layer 3 was widened in Phase 297-cube-mcp to permit drizzle-cube/mcp imports ONLY inside the adapter directory).

OFF by default and statically defended. Regression tests at packages/dashboards/src/__tests__/no-ai-on-agents.test.ts walk packages/dashboards/src/{screens,components}/** and forbid:

  • import of AgenticNotebook, ExplainAIPanel, useExplainAI, useAgentChat.
  • JSX render of <AgenticNotebook> / <ExplainAIPanel>.
  • Hook call of useExplainAI() / useAgentChat().
  • String occurrences of /agent/chat, /api/ai/, aiEndpoint, literal enableAI: true.

The grep is scoped to Cinatra source only (skips node_modules) so drizzle-cube’s internal bundle references don’t trigger false positives.

Future AI work routes exclusively through @cinatra-ai/llm-orchestration per ADR 0044 Decision 7 — Phase 278 (async AI dashboard generation via BullMQ) + Phase 279 (AI cube proposals with admin promotion).

feedback_drizzle_cube_pitfalls.md (user memory) catalogues seven traps invisible to typecheck + unit tests:

  1. CSS subpath imports must live inside the workspace package that declares drizzle-cube as a dep (pnpm strict hoisting).
  2. Cube from needs the FULL Drizzle Table, not a destructured column-bag.
  3. <DashboardGrid> needs <DashboardStoreProvider> IN ADDITION to <CubeProvider> — separate Zustand context.
  4. Chart peer deps load eagerly (@nivo/heatmap, @xyflow/react, d3, elkjs, react-is) — install them all even if you only render bar+table.
  5. chartType: "table" has NO per-column formatter. Humanise (relative time, custom labels) via a route-handler post-processor or SQL CASE.
  6. <DashboardGrid config={...}> is read-back on edit-mode exit. The inner Dr re-derives the visible grid from props.config when the user clicks “Finish Editing”, so wrappers must hold a local React state mirror and advance it inside onSave — otherwise the visible layout snaps back to the pre-edit baseline even though the DB already holds the new layout. Fixed in PR #206 (AgentsDashboardGrid).
  7. onSave alone misses edit actions. DC’s compiled save funnel has conditional gating that’s fragile to inspect (a guard difference between ye/be in DashboardEditModal-*.js). Subscribe to BOTH onConfigChange (debounced) and onSave (immediate) and route through a single coordinator that dedupes by JSON. Pattern in packages/dashboards/src/components/auto-save-coordinator.ts (PR #210).
  • docs/ai/cube-schemas.md — TBD; how to author a new cube.
  • docs/ai/llm-orchestration.md — where Phase 278 AI generation hooks in.
  • .planning/REQUIREMENTS.md — DASH, SDK, SEM, PERSIST, VIEW, BUILD, METRICS, CUBE-IR, AI requirement categories.
  • .planning/PENDING-UAT.mdOPTION-A-UAT-01..06 deferred items: modal focus-trap a11y, dark-mode visual smoke, bundle-size impact, multi-org isolation, concurrent first-save race, /batch partial-success.