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 theDashboardsClientShellthat wraps<CubeProvider>+<DashboardStoreProvider>.
Architecture at a glance
Section titled “Architecture at a glance”Browser ↓src/app/agents/page.tsx → AgentsDashboardPage (server component) ↓packages/dashboards/src/screens/agents-dashboard.tsx ↓ mountsDashboardsClientShell (provides QueryClient + CubeProvider + DashboardStoreProvider) ↓ wraps<DashboardGrid> (from drizzle-cube/client, themed via dashboard-theme.css) ↓ callsGET /api/dashboards/cubejs-api/v1/load?query=<JSON> ↓ routes throughsdk-dashboard's drizzle-cube adapter → drizzle-cube/server ↓ generatesSELECT ... FROM cinatra.agent_runs LEFT JOIN cinatra.agent_templates ...On save:
<DashboardGrid onSave={saveAgentsDashboardAction}> ↓ Server Actionpackages/dashboards/src/actions.ts → upsertDashboardConfig() ↓INSERT ... ON CONFLICT (id) DO UPDATE (atomic, wrapped in pg_advisory_xact_lock) ↓ same TXINSERT 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 (“nodrizzle-cube/client/*anywhere”) and scoped a directory-bounded exception:drizzle-cube/client/*allowed insidepackages/dashboards/src/components/**ONLY.
Where the boundary is enforced
Section titled “Where the boundary is enforced”ESLint no-restricted-imports in eslint.config.mjs runs in 4 layered blocks (last matching block wins):
| Layer | Scope | What it forbids |
|---|---|---|
| 1 | repo-wide | drizzle-cube/mcp, drizzle-cube/client* (lifted by Layer 4), drizzle-cube root + non-client subpaths (lifted by Layer 3) |
| 2 | packages/sdk-dashboard/src/** excluding the adapter | @/*, @cinatra/*, better-auth, bullmq |
| 3 | packages/sdk-dashboard/src/adapters/drizzle-cube/** | Re-allows drizzle-cube/* server imports |
| 4 | packages/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.
Wire format
Section titled “Wire format”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— returnsCubeMeta({ cubes: CubeMetaCube[] }) with member names fully qualified<cube>.<member>. Cinatradimension.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.
Persistence
Section titled “Persistence”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
DashboardConfigshape:w/h/x/yat portlet root + nestedanalysisConfig..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.
Mutation service
Section titled “Mutation service”packages/dashboards/src/mutation-service.ts is the single writer (PERSIST-03 invariant). Every mutation:
- Opens a Postgres transaction.
- Acquires
pg_advisory_xact_lock(hashtext(id))(inupsertDashboardConfig) orSELECT ... FOR UPDATE(inupdate/publish/archive). - Validates
DashboardConfigvia Zod. - Runs
resolveDashboardAccess(row, actor)frompackages/dashboards/src/permissions.ts— single permission resolver (PERSIST-08 invariant). - Executes the data change.
- Writes an
audit_eventsrow 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 (the user-visible deliverable)
Section titled “/agents (the user-visible deliverable)”/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.countgrouped byagent_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 byagent_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-supplieddashboardIdstarting withsystem-— the reserved prefix. - The screen-level read in
loadAgentsConfig()filters byid AND organizationId AND ownerId AND ownerLevel='user', so a pre-poisoned row created under a different actor falls through to the seed.
Cube — agent_runs
Section titled “Cube — agent_runs”packages/sdk-dashboard/src/adapters/drizzle-cube/cubes/agent-runs.ts:
- Dimensions:
agent_id(UUID, back-compat),agent_name(LEFT JOIN toagent_templates,coalesce(nullif(name, ''), template_id)),status,created_at(time). - Measures:
count(count of id),last_run_at(MAX ofEXTRACT(EPOCH FROM created_at)— numeric so MAX works; humanised by the route’shumanizeAgentRunsRows()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 widensSecurityContextwithaccessibleOrgIds[]so users in multiple orgs see all org-accessible runs. - The factory takes
{ tableRef, columns, templatesTableRef, templateColumns }. drizzle-cube needs the FULL Drizzle Table infrom(Codex round-12 lesson — passing a destructured column-bag producesFROM $1and a pg JSON-serialisation crash).
Theming
Section titled “Theming”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 withcolor-mix(...)at 18% bg / 45% border against shadcn semantics. --dc-primary-rgbhardcoded per theme: light45, 74, 138, dark226, 232, 240(drizzle-cube uses it insidergba(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).
MCP cube tools (Phase 297-cube-mcp)
Section titled “MCP cube tools (Phase 297-cube-mcp)”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.
Architecture
Section titled “Architecture”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.
| Layer | File | Job |
|---|---|---|
| Bridge | packages/sdk-dashboard/src/adapters/drizzle-cube/mcp-tools.ts | Wraps getCubeTools so the rest of the repo never imports drizzle-cube/mcp. Returns Cinatra-typed { definitions, handle, handles, toolNames }. |
| Schema | packages/sdk-dashboard/src/adapters/drizzle-cube/json-schema-to-zod.ts | Narrow 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. |
| Cubes | packages/dashboards/src/mcp-cubes/cubes-singleton.ts | Lazy globalThis-cached semantic layer + cube tools bridge. Same agent_runs cube definition as the HTTP route. |
| Identity | packages/dashboards/src/mcp-cubes/handlers.ts | Reads 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. |
| Registry | packages/dashboards/src/mcp-cubes/registry.ts | server.registerTool(name, meta, handler) x3 — same shape as packages/lists/src/mcp/registry.ts. |
| Module | packages/dashboards/src/mcp-cubes/index.ts | createDashboardCubesMcpModule() slotted into the modules = [...] array in src/lib/mcp-server.ts. |
Tenant isolation
Section titled “Tenant isolation”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.
Auth surface
Section titled “Auth surface”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).
AI surface
Section titled “AI surface”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:
importofAgenticNotebook,ExplainAIPanel,useExplainAI,useAgentChat.- JSX render of
<AgenticNotebook>/<ExplainAIPanel>. - Hook call of
useExplainAI()/useAgentChat(). - String occurrences of
/agent/chat,/api/ai/,aiEndpoint, literalenableAI: 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).
Gotchas — see also
Section titled “Gotchas — see also”feedback_drizzle_cube_pitfalls.md (user memory) catalogues seven traps invisible to typecheck + unit tests:
- CSS subpath imports must live inside the workspace package that declares drizzle-cube as a dep (pnpm strict hoisting).
- Cube
fromneeds the FULL Drizzle Table, not a destructured column-bag. <DashboardGrid>needs<DashboardStoreProvider>IN ADDITION to<CubeProvider>— separate Zustand context.- Chart peer deps load eagerly (
@nivo/heatmap,@xyflow/react,d3,elkjs,react-is) — install them all even if you only render bar+table. chartType: "table"has NO per-column formatter. Humanise (relative time, custom labels) via a route-handler post-processor or SQL CASE.<DashboardGrid config={...}>is read-back on edit-mode exit. The innerDrre-derives the visible grid fromprops.configwhen the user clicks “Finish Editing”, so wrappers must hold a local React state mirror and advance it insideonSave— 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).onSavealone misses edit actions. DC’s compiled save funnel has conditional gating that’s fragile to inspect (a guard difference betweenye/beinDashboardEditModal-*.js). Subscribe to BOTHonConfigChange(debounced) andonSave(immediate) and route through a single coordinator that dedupes by JSON. Pattern inpackages/dashboards/src/components/auto-save-coordinator.ts(PR #210).
Related
Section titled “Related”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.md—OPTION-A-UAT-01..06deferred items: modal focus-trap a11y, dark-mode visual smoke, bundle-size impact, multi-org isolation, concurrent first-save race,/batchpartial-success.