First-party Paca plugin providing a fully customizable, drag-and-drop dashboard builder. Users add "panels" (charts, data tables, or text blocks) backed by their own SQL queries, arranged freely on a grid, across three scopes:
- Project dashboard — one editable dashboard per project.
- Admin dashboard — one instance-wide, cross-project dashboard.
- Integration dashboard views — exactly one dashboard per host interaction view, surfaced inside Integration pages (backlog/sprint/ timeline) via the host's "Add view" popover. Want a second dashboard? Add a second "Dashboard"-type view from the host — the plugin no longer manages its own list/switcher.
dashboard/
├── backend/ — Go WASM plugin (runs inside the API host)
├── frontend/ — React micro-frontend (Module Federation remote)
├── mcp/ — MCP tool server (for AI agent access)
└── skills/ — Agent skill(s) documenting how to use the MCP tools
The plugin owns two tables (see
backend/migrations/0001_create_dashboard_tables.sql):
dashboard_views— one row per dashboard.scopeis one ofproject|admin|integration.projectscope: exactly one row perproject_id, created lazily (get-or-create) the first time a project's dashboard page loads. Cannot be deleted or renamed away from its default.adminscope: exactly one row total,project_idisNULL, created lazily the first time the admin dashboard page loads.integrationscope: exactly one row perhost_view_id(the host's own interaction-view record id — a "Dashboard"-type view created via the host's "Add view" popover in Backlog/Sprint/Timeline), created lazily the first time that specific host view is opened. Same get-or-create-singleton shape as project/admin, just keyed byhost_view_idinstead ofproject_id/nothing.- A partial unique index enforces "at most one row" per key for all
three scopes (
uq_dashboard_views_one_project_scope/uq_dashboard_views_one_admin_scope/uq_dashboard_views_one_per_host_view) without a separate flag column.
dashboard_panels— one row per panel, belonging to exactly onedashboard_viewsrow.typeischart|table|text.chart/tablepanels carry aquery(raw SQL, see Query Safety below) and, for charts, achart_type(bar|line|donut).textpanels carrycontentinstead and have no query.pos_x,pos_y,width,heightare grid units (react-grid-layout convention: a 12-column grid,width/heightin grid cells) and are updated in bulk via the layout endpoint after a drag/resize.viz_configis a free-form JSON blob reserved for future chart-specific display options (axis labels, colors, etc.) — currently unused by the frontend beyond being persisted round-trip.
-
Written in Go, compiled to
wasip1/wasmfor production. -
Registered as
com.paca.dashboardin the plugin registry. -
Route map (see
backend/plugin.gofor the authoritative list):Method Path Scope GET /dashboard/viewproject singleton (get-or-create) GET /dashboard/admin-viewadmin singleton (get-or-create) GET /dashboard/view/:hostViewIdintegration singleton (get-or-create, one per host view) GET /dashboard/views/:viewIdany scope, by resolved dashboard id POST /dashboard/views/:viewId/panelscreate panel (project/integration) PATCH/DELETE /dashboard/views/:viewId/panels/:panelIdupdate/delete panel PATCH /dashboard/views/:viewId/panels/layoutbulk layout commit POST /dashboard/views/:viewId/panels/:panelId/datarun a saved panel's query POST/PATCH/DELETE /dashboard/admin-view/panels[/...]admin-scope mirrors of the above POST /dashboard/query/preview(or/dashboard/admin-query/preview)validate + run a not-yet-saved query All routes shown here are relative to
/api/v1/plugins/com.paca.dashboardand get their:projectIdprefix (where applicable) added by the host perplugin.json's route table.
- Vite + React + TanStack Query, Module Federation remote
(
com_paca_dashboard) exposing three entry points:./ProjectDashboardPage—project.pageextension point../AdminDashboardPage—admin.pageextension point../DashboardIntegrationView—viewextension point (see "Integration view architecture" below).
- Shared UI, in dependency order:
types.ts— response/request shapes mirroring the backend JSON.api.ts— all TanStack Query hooks (queries + mutations).PanelGrid.tsx— the drag/resize canvas, wrapsreact-grid-layoutv2 (GridLayout+useContainerWidth). Layout changes are batched into a single bulk commit on drag/resize stop, not on every intermediate tick.PanelCard.tsx— one panel's chrome (drag handle, refresh, edit, delete) plus body dispatch bypanel.type.SimpleCharts.tsx— dependency-free inline-SVG bar/line/donut renderers. No charting library exists anywhere in the monorepo and a panel's query result is expected to be small (one label column + one numeric column), so this avoids adding a real charting dependency.PanelEditor.tsx— the add/edit panel modal: type picker, chart-type picker, SQL textarea with a live "Preview" button (calls the query-preview endpoint) and inline guardrail error display, or a plain textarea for text panels.DashboardBody.tsx— the scope-agnostic body: fetch-on-mount panel data, add-panel affordance, and wiring for the grid + editor. Shared verbatim by all three entry points.
react-grid-layout(^2.2.3) is the one new frontend dependency this redesign introduces — nothing in the monorepo previously had drag/resize grid functionality.
The host's view extension point (ViewExtensionProps) forwards a stable
viewId — the host's own interaction-view record id, unique per view
instance and stable across renames/reloads — alongside projectId. This
plugin uses viewId directly as the key for a get-or-create-singleton
dashboard (GET /dashboard/view/:hostViewId, host_view_id column), the
same pattern as the project/admin scopes. DashboardIntegrationView.tsx
therefore has no list/create/rename/delete UI of its own: each "Dashboard"
view the host creates simply renders its own dashboard, and creating a
second dashboard means creating a second "Dashboard"-type view from the
host's own "Add view" popover — not a switcher inside this plugin.
The host's Postgres is a single shared, multi-tenant database. An
unrestricted SQL textbox would let one project's dashboard author read
another project's task titles, documents, or worse. Rather than ban raw
SQL outright (capping flexibility to whatever a structured query builder
could express) or build a full query builder (large scope for a v1), this
plugin ships a guarded raw-SQL model — see backend/query_guard.go for
the authoritative implementation:
- Exactly one statement, and it must start with
SELECTorWITH. NoINSERT/UPDATE/DELETE/DROP/ALTER/CREATE/GRANT/TRUNCATE/COPY/CALL/EXPLAIN/VACUUM, and nothing after a trailing;. - No
information_schema/pg_catalog/pg_*introspection,dblink,lo_*, orCOPY— closes standard sandbox-escape tricks. - Project/integration-scoped queries must contain the literal
placeholder
{{project_id}}somewhere in aWHERE/ON/HAVINGclause. The guard substitutes it with$1and binds the caller's realproject_id— this is what actually prevents cross-project data leakage, by forcing every project-scoped query to filter itself. Admin-scope queries are intentionally cross-project and skip this requirement (and reject the placeholder if present). - An explicit
LIMIT 500is appended when the query doesn't already declare one.
There used to be a fifth rule here: a per-table read whitelist restricting
which tables could appear after FROM/JOIN, which is how sensitive
tables (users.password_hash, api_keys, etc.) and other plugins' tables
were kept out of panel queries. That's gone — table-level access is no
longer this plugin's concern. The API host itself now redacts any column a
plugin declares sensitive (plugin.json's backend.sensitiveFields, plus a
host-side registry for platform secrets like users.password_hash) to
"***" for every caller except the declared owner or an explicit requester
(backend.requestedSensitiveFields) — see
services/api/internal/platform/plugin/runtime.go. A panel query against
users or another plugin's schema now reaches Postgres; it just comes back
with sensitive values masked, same as any other plugin would see.
This is a pattern-based guard, not a full SQL parser — deliberately conservative, rejecting anything it can't confidently classify as safe rather than risk a false negative.
Note on this default: the query-safety approach was proposed to the user (raw SQL vs. guarded raw SQL vs. a fully structured query builder) and implemented as guarded raw SQL (option 2) as a reasonable default when no response was received. If a different safety posture is wanted later, the natural places to change are
query_guard.go'svalidateQuery(backend enforcement) andPanelEditor.tsx's SQL textarea (frontend UX) — the two are independent of the rest of the panel/view CRUD.
mcp/src/index.ts exposes the panel/view API to AI clients (Claude, GitHub
Copilot, Cursor, etc.) via @paca-ai/plugin-sdk-mcp, mirroring
backend/plugin.go's route map one-for-one:
| Tool | Backend route |
|---|---|
dashboard_get_view |
GET /dashboard/view, /dashboard/view/:hostViewId, or /dashboard/views/:viewId (branches on which optional arg is passed) |
dashboard_preview_query |
POST /dashboard/query/preview |
dashboard_create_panel |
POST /dashboard/views/:viewId/panels |
dashboard_update_panel |
PATCH /dashboard/views/:viewId/panels/:panelId |
dashboard_delete_panel |
DELETE /dashboard/views/:viewId/panels/:panelId |
dashboard_update_panel_layout |
PATCH /dashboard/views/:viewId/panels/layout |
dashboard_get_panel_data |
POST /dashboard/views/:viewId/panels/:panelId/data |
dashboard_get_admin_view |
GET /dashboard/admin-view |
dashboard_preview_admin_query |
POST /dashboard/admin-query/preview |
dashboard_create_admin_panel |
POST /dashboard/admin-view/panels |
dashboard_update_admin_panel |
PATCH /dashboard/admin-view/panels/:panelId |
dashboard_delete_admin_panel |
DELETE /dashboard/admin-view/panels/:panelId |
dashboard_update_admin_panel_layout |
PATCH /dashboard/admin-view/panels/layout |
dashboard_get_admin_panel_data |
POST /dashboard/admin-view/panels/:panelId/data |
Panel-mutating tool arguments are camelCase (chartType, posX, …) and get
mapped onto the backend's snake_case body shape (chart_type, pos_x, …)
inside handleToolCall. Update the table above alongside any future route
changes in backend/plugin.go.
Note: an earlier version of this file exposed
dashboard_project_overview/dashboard_instance_overview, calling/dashboard/overviewand/dashboard/overview-all— routes that predate the panel-based redesign and were never implemented inbackend/plugin.go(nor called by the frontend). Those dead route entries have been removed fromplugin.json, and the MCP tools above replace them with the current panel/view API surface.
skills/paca-dashboard-builder/SKILL.md teaches an AI agent how to build a
dashboard end-to-end using the tools above: resolving the right view across
the three scopes, the query safety model (the {{project_id}} placeholder,
forbidden keywords, the implicit LIMIT 500), a schema reference for the
host's task/sprint tables, and a library of copy-paste preset queries (status
breakdowns, burndown/burnup, workload, overdue tasks, sprint velocity) so the
agent doesn't have to hand-write SQL against an unfamiliar schema. Registered
in plugin.json's skills.names.
cd backend
# Run tests
go test -race -v ./...
# Lint
golangci-lint run --timeout=5m
# Build WASM binary
GOOS=wasip1 GOARCH=wasm go build -o dashboard.wasm .
# (or, for the c-shared variant some tooling expects:)
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o dashboard.wasm .main.go carries a //go:build wasip1 tag, so a plain go build ./...
without GOOS=wasip1 GOARCH=wasm will always fail with "function main is
undeclared in the main package" — this is expected, not a real error.
go vet/go test work fine without the cross-compile target.
Test coverage note: plugintest's InMemoryDB only supports simple
SELECT/INSERT/UPDATE/DELETE statements with $N-bound parameters —
it cannot evaluate SQL-side functions like NOW(), so every updated_at/
created_at write in application code binds an explicit Go-side timestamp
(nowStr() in plugin.go) rather than relying on NOW() — this matters
for both correctness against the test harness and consistency, since the
real migration's column DEFAULT NOW() only fires on INSERT, not
UPDATE. plugin_test.go covers CRUD + validation + query-guard rejection
paths for all three scopes; full guarded-query execution against real
data is exercised via the in-memory backend's basic SELECT support, not
a real Postgres instance.
cd frontend
# Install dependencies
bun install
# Typecheck
bunx tsc --noEmit
# Development build (watch)
bun run dev
# Production build (outputs remoteEntry.js + all federation exposes)
bun run buildThe frontend uses @paca-ai/plugin-sdk-react. Shared singletons (react,
react-dom, @tanstack/react-query) are provided by the host shell and
must not be bundled. react-grid-layout is bundled normally (not shared)
since no other plugin currently depends on it.
cd mcp
bun install
bun run typecheck
bun run buildRouted at /projects/:projectId/plugins/com.paca.dashboard/dashboard via
the project nav item declared in plugin.json. Fetches (get-or-creates)
the project's singleton dashboard view and renders the shared panel-grid
UI with edit permissions gated by the route's own projects.write
middleware (panel mutation routes require projects.write; read routes
require only projects.read).
Routed at /admin/plugins/com.paca.dashboard/dashboard via the admin nav
item declared in plugin.json; gated by the built-in users.write global
permission (same gate as the host's other admin pages). Fetches
(get-or-creates) the instance-wide singleton dashboard view; its panel
queries are cross-project and do not require the {{project_id}}
placeholder.
Registered as an option in the Integration pages' (backlog/sprint/
timeline) "Add view" popover. Renders a self-managed tab strip over the
project's integration-scope dashboard views (see "Integration view
architecture" above for why this scope manages its own view list rather
than relying on host-persisted viewConfig).