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), Model Context Protocol (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)Design Decisions
Section titled “Design Decisions”- The headless foundation uses
drizzle-cube/serveronly, an anti-corruption adapter, a two-package split, an ESLint boundary, exact-version pinning, shadcn-admin UI, and large language model (LLM) access only via@cinatra-ai/llm. drizzle-cube/clientis allowed only through a directory-bounded exception:drizzle-cube/client/*imports are 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.
Persistence
Section titled “Persistence”packages/dashboards/src/store/dashboard-config.ts ships two Zod schemas:
- Initial baseline — permissive shape, portlets discriminated by
type. - Current shape (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). Multi-org access 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
Section titled “MCP cube tools”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 (the auth server library Cinatra uses) / OAuth Bearer pass). Strict agent-to-agent (A2A) protocol 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. Multi-org membership widens this to accessibleOrgIds[].
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 permits 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, including async AI dashboard generation via BullMQ (a Redis-backed job queue) and 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. Pattern inAgentsDashboardGrid.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.
Related
Section titled “Related”guides/developer/cube-schemas.md— TBD; how to author a new cube.references/platform/llm-orchestration.md— where AI generation hooks in.- Requirement categories: DASH, SDK, SEM, PERSIST, VIEW, BUILD, METRICS, CUBE-IR, AI.
- Deferred verification areas: modal focus-trap a11y, dark-mode visual smoke, bundle-size impact, multi-org isolation, concurrent first-save race,
/batchpartial-success.