REST Endpoints — Catalogue, Import, CRUD, State Transitions, Widgets
Granit.Dashboards.Endpoints maps every route under the /dashboards prefix
(configurable via DashboardsEndpointsOptions.RoutePrefix). Every endpoint
requires authentication and a permission from DashboardsPermissions. The
DashboardsDbContext applies multi-tenant filtering through
ApplyGranitConventions — endpoints never have to add WHERE tenant_id = …
themselves, and cross-tenant rows are never reachable.
| Permission | Scope |
|---|---|
Dashboards.Catalog.Read | Read the in-memory catalogue of registered DashboardDefinitions. |
Dashboards.Instances.Read | Read the tenant’s persisted Dashboard aggregate — list, read-by-id. |
Dashboards.Instances.Manage | Write access — import, edit metadata, state transitions, widget add / update / remove. |
Catalogue
Section titled “Catalogue”Read-only view over the IDashboardDefinitionRegistry — the pool of
DashboardDefinitions registered by every loaded module. No tenant scope: the
catalogue is the same for every tenant on the host.
| Method | Route | Permission | Description |
|---|---|---|---|
GET | /dashboards/catalog | Catalog.Read | List every registered DashboardDefinition with its name, version, category, layout summary, widget count and view count. |
Import
Section titled “Import”Deep-copies a registered DashboardDefinition into a fresh tenant-scoped
Dashboard aggregate. The import is explicit — there is no auto-import on
first read (ADR-038 §2). Multi-view definitions yield a Dashboard whose
widget pool is the entry view’s widgets (DefaultView, falling back to the
first view when DefaultView is null).
| Method | Route | Permission | Status codes |
|---|---|---|---|
POST | /dashboards/from-definition/{name} | Instances.Manage | 201 + DashboardImportResponse · 404 when the definition name is not registered |
The new dashboard is created in Draft status with the source-definition
version captured at import time — drift detection between the
running-definition and the persisted snapshot lands in a follow-up story.
Persisted dashboards (read)
Section titled “Persisted dashboards (read)”| Method | Route | Permission | Description |
|---|---|---|---|
GET | /dashboards | Instances.Read | List the tenant’s persisted dashboards, paginated. |
GET | /dashboards/{id:guid} | Instances.Read | Read one dashboard with its full widget tree (ordered by Position). |
GET /dashboards accepts three query parameters:
| Parameter | Type | Default | Notes |
|---|---|---|---|
status | DashboardStatus? | none | Filter by Draft / Published / Archived. Omit to return everything in scope. |
page | int | 0 | Zero-based page index (< 0 returns 400). |
pageSize | int | 50 | Capped at 200 (out-of-range returns 400). |
Results are ordered by Name ascending. The response is a
PagedResponse<DashboardSummaryResponse> envelope — summary entries strip the
widget tree but expose WidgetCount so list views can render a badge without a
follow-up GET.
GET /dashboards/{id} returns 404 when the id is not in scope (multi-tenant
isolation is enforced at the DbContext level — the existence of a cross-tenant
dashboard is never leaked).
Persisted dashboards (write)
Section titled “Persisted dashboards (write)”Edit metadata
Section titled “Edit metadata”| Method | Route | Permission | Status codes |
|---|---|---|---|
PUT | /dashboards/{id:guid} | Instances.Manage | 200 + DashboardSummaryResponse · 400 validation · 404 not in scope · 422 domain-guard rejection |
Full replacement of the editable metadata: Name, LayoutColumns,
LayoutRowHeight. Status, source-definition pinning and the widget pool stay
out of scope here — they are owned by the state-transition / import / widget
endpoints respectively.
FluentValidation runs first (NotEmpty, MaximumLength(200),
GreaterThan(0)). The domain guards in Dashboard.Rename and
Dashboard.UpdateLayout stay as defense-in-depth and surface as 422 if hit.
State transitions
Section titled “State transitions”Three idempotent transitions on the aggregate’s state machine — each returns
the post-transition DashboardSummaryResponse so callers refresh without a
follow-up GET.
| Method | Route | Permission |
|---|---|---|
POST | /dashboards/{id:guid}/publish | Instances.Manage |
POST | /dashboards/{id:guid}/archive | Instances.Manage |
POST | /dashboards/{id:guid}/restore | Instances.Manage |
Transitions and status codes:
publish—Draft→Published(idempotent onPublished);200on success,404on miss,409when the dashboard isArchived.archive— any →Archived(always idempotent);200on success,404on miss.restore—Archived→Draft;200on success,404on miss,409when the dashboard is notArchived.
409 Conflict carries the domain’s invariant message verbatim (e.g.
“An archived dashboard must be restored before it can be published.”) — the
state machine is one-way except for Archived → Draft.
Widgets
Section titled “Widgets”Widget operations target the dashboard’s widget pool. The widget id is
allocated server-side on create — callers never choose it. Switching widget
kind (WidgetType) or rebinding to a different metric / query is delete +
add, not edit.
| Method | Route | Permission |
|---|---|---|
POST | /dashboards/{id:guid}/widgets | Instances.Manage |
PUT | /dashboards/{id:guid}/widgets/{widgetId:guid} | Instances.Manage |
DELETE | /dashboards/{id:guid}/widgets/{widgetId:guid} | Instances.Manage |
Status codes:
POST—201+WidgetInstanceResponse;400validation;404parent not in scope;422domain-guard rejection.PUT—200+WidgetInstanceResponse;400validation;404dashboard or widget not in scope;422domain-guard rejection.DELETE—204;404dashboard or widget not in scope.
Add — AddWidgetRequest
Section titled “Add — AddWidgetRequest”{ "widgetType": "Kpi", "position": 0, "width": 3, "height": 1, "titleLocalizationKey": "Widget:Sample.UnpaidCount", "configJson": "{\"kind\":\"metric\"}", "metricName": "Sample.UnpaidInvoiceCount", "queryName": null, "requiredPermission": "Custom.Composite.Read"}widgetType— non-empty, ≤ 100 chars.position—>= 0.width,height—> 0.titleLocalizationKey— non-empty, ≤ 200 chars.configJson— non-empty, ≤ 16 000 chars.metricName,queryName— optional, ≤ 200 chars each. Exactly one of them carries kind-specific context: KPI usesmetricName, table usesqueryName, free-form widgets use neither.requiredPermission— optional, ≤ 200 chars. Narrows visibility on top of the dashboard-level permission.
Update — UpdateWidgetRequest
Section titled “Update — UpdateWidgetRequest”{ "position": 4, "width": 6, "height": 2, "titleLocalizationKey": "Widget:Sample.UnpaidCount.Highlighted", "configJson": "{\"kind\":\"metric\",\"emphasis\":\"high\"}"}The shape is the create-request minus the immutable fields. Constraints match
their AddWidgetRequest counterparts — same error codes, so callers handle
validation uniformly.
404 — dashboard vs widget
Section titled “404 — dashboard vs widget”The DELETE and PUT widget endpoints distinguish the two 404 reasons:
- Dashboard not in scope —
Dashboard '{id}' not found. - Widget not on that dashboard —
Widget '{widgetId}' not found on dashboard '{id}'.
The reason matters for caller UX — the first is a routing / tenant problem, the second is a stale widget id (typically: another tab removed the widget). Idempotent retries on a removed widget therefore receive the widget 404, which UIs treat as benign.
Render
Section titled “Render”Bundles every widget on the dashboard into one HTTP response. The renderer applies a uniform permission gate, registry-driven dispatch, and per-widget exception isolation per ADR-039 §3 — one bad widget never 500s the whole render.
| Method | Route | Permission |
|---|---|---|
POST | /dashboards/{id:guid}/render | Instances.Read |
Status codes:
POST—200+DashboardRenderResponse;400period-bound validation;404dashboard not in scope.
Request — DashboardRenderRequest
Section titled “Request — DashboardRenderRequest”{ "periodFrom": "2026-04-01T00:00:00Z", "periodTo": "2026-05-01T00:00:00Z", "periodToken": "mtd", "locale": "en", "filters": { "Status": "Open" }}periodFrom/periodTo— both required together (or both omitted).fromstrictly beforeto. Absolute UTC; named-token resolution stays client-side.periodToken— optional named token (mtd,qtd,ytd, …) echoed back in the response for client convenience; the renderer never re-resolves it server-side.locale— BCP-47 tag. Defaults toen.filters— dashboard-level filter spec applied uniformly across widgets that share the same underlying entity (e.g.{ "Status": "Open" }drives an unpaid-invoices KPI and an unpaid-invoices table on the same dashboard). The renderer pipeline never interprets the values; each typed renderer applies them in its own way.
Response — DashboardRenderResponse
Section titled “Response — DashboardRenderResponse”{ "dashboardId": "8c6b...", "renderedAt": "2026-04-29T12:34:56.789Z", "period": { "from": "2026-04-01T00:00:00Z", "to": "2026-05-01T00:00:00Z", "token": "mtd" }, "widgets": [ { "id": "...", "widgetType": "Kpi", "status": "Snapshot", "sequence": 1, "emittedAt": "2026-04-29T12:34:56.789Z", "refreshHint": "Dynamic", "snapshot": { "value": 12, "valueKind": "Count", "noData": false, /* ... */ }, "reasonLocalizationKey": null }, { "id": "...", "widgetType": "Markdown", "status": "Snapshot", "sequence": 1, "emittedAt": "2026-04-29T12:34:56.789Z", "refreshHint": "Static", "snapshot": { "contentLocalizationKey": "Widget:Banner" } }, { "id": "...", "widgetType": "Kpi", "status": "Unavailable", "sequence": 1, "emittedAt": "2026-04-29T12:34:56.789Z", "refreshHint": "Static", "snapshot": null, "reasonLocalizationKey": "Widget:Unavailable" } ]}Per-widget fields disambiguate two concerns the early draft conflated: widgetType is the declarative kind discriminator (mirror of WidgetDefinition’s [JsonDerivedType] "type" tag), status is the runtime outcome (Snapshot / Unavailable / Error).
Enum values travel as PascalCase strings on the wire (ADR-039 §6.1) — RefreshHint, WidgetSnapshotStatus, MetricValueKind, etc. all serialise that way. Frontend TypeScript types match exactly: type RefreshHint = "Static" | "Dynamic" | "Realtime".
Per-widget snapshot shapes
Section titled “Per-widget snapshot shapes”Eight shipped kinds. Each carries its own typed snapshot payload — the
frontend dispatches on widgetType to pick the matching renderer.
Kpi — single-value tile
Section titled “Kpi — single-value tile”{ "value": 1234.56, // null when noData=true "valueKind": "Currency", // Count / Number / Currency / Percentage / Duration / Date "currency": "EUR", // ISO 4217 — non-null when valueKind=Currency "isHigherBetter": true, // drives delta colouring on the frontend "noData": false, // true for empty Avg/Min/Max "previous": null // optional comparison-window payload}KPIs dispatch internally on Datasource.Kind (Metric / QueryAggregate / Telemetry). The wire shape stays uniform regardless. Currency promotion happens automatically when the underlying column declares .Currency("EUR").
Chart — group-by series
Section titled “Chart — group-by series”{ "chartType": "Bar", // Bar / HorizontalBar / Line / Area / Pie / Donut "groupBy": "Status", "aggregation": "Sum", // Count / Sum / Avg / Min / Max "field": "Amount", // null for Count "buckets": [ { "label": "Open", "value": 60.0 }, { "label": "Paid", "value": 150.0 } ], "currency": "EUR" // shared across buckets — null for Count or non-monetary}Count aggregations use SQL GROUP BY directly. Sum/Avg/Min/Max push the aggregation to SQL via dynamically-built expression trees — chart tiles scale with the number of groups, not the row count.
Table — paginated row list
Section titled “Table — paginated row list”{ "columns": [ { "name": "customer", "labelLocalizationKey": "Column:Customer", "currencyCode": null }, { "name": "amount", "labelLocalizationKey": "Column:Amount", "currencyCode": "EUR" } ], "rows": [ { "customer": "Alice", "amount": 1000.0 }, { "customer": "Bob", "amount": 2000.0 } ], "totalRowCount": 142}Per-column currency: a single table can mix AmountEur and AmountUsd on different columns. totalRowCount reflects the underlying query after filters, so the frontend can render “showing 2 of 142” without a second round-trip.
Pivot — rows × columns × value
Section titled “Pivot — rows × columns × value”{ "rowFields": ["Region"], "columnFields": ["Year"], "valueField": "Amount", // null for Count "aggregation": "Sum", "cells": [ { "rowKeys": ["EU"], "columnKeys": ["2025"], "value": 15000.0 }, { "rowKeys": ["EU"], "columnKeys": ["2026"], "value": 22000.0 }, { "rowKeys": ["US"], "columnKeys": ["2025"], "value": 18000.0 } ], "currency": "EUR" // shared across cells}cells is a flat list — the frontend pivots into a row-major matrix. Multi-dimension friendly: rowFields and columnFields accept ordered lists, each cell carries the full row/column-key tuples. Null property values surface as "(null)" keys.
Map — geocoded markers
Section titled “Map — geocoded markers”{ "points": [ { "id": "8c6b...", "latitude": 48.85, "longitude": 2.35, "popup": { "name": "Paris", "country": "FR" } }, { "id": "...", "latitude": 51.50, "longitude": -0.12, "popup": { "name": "London", "country": "GB" } } ], "defaultZoom": 7, "defaultCenter": { "latitude": 50.0, "longitude": 1.0 }, "clusterThreshold": 150, "detailRoute": "/cities/{id}", "tileUrlTemplate": null, // null = host's default (typically OpenStreetMap) "defaultLayerKind": "Satellite" // optional preferred layer; null = no preference}LatLng path (double / decimal lat-lng columns) works on any database. PostGIS path (geography(Point)) is opt-in via the Granit.Analytics.PostGIS package; without it the renderer surfaces Widget:Unavailable.MapGeographyNotImplemented.
defaultLayerKind (B7-3) is a per-widget preference — Plan, Satellite, Hybrid, Topo, or Custom, PascalCase on the wire. The frontend resolves it against the active MapTileProvider: matching layer wins, otherwise the provider’s first layer is used. Useful when a single host registers multiple providers (OSM + SPW Wallonia + ArcGIS) and wants delivery-tracking widgets to default to satellite while parcel maps default to plan.
Markdown / Text / Image — static content
Section titled “Markdown / Text / Image — static content”// Markdown{ "contentLocalizationKey": "Widget:Granit.Invoicing.FinanceOverview.Banner" }
// Text{ "contentLocalizationKey": "Widget:Title", "style": "Heading" }
// Image{ "source": "blob:logo-banner", "altLocalizationKey": "Widget:Logo.Alt", "fit": "Contain" }refreshHint is always "Static" — these widgets don’t refetch.
Empty-set semantics (locked)
Section titled “Empty-set semantics (locked)”Numeric aggregations across every kind share the same contract (locked by tests #1374):
Countover zero rows →0(noData: false).Sumover an empty set →0(sum-of-empty identity,noData: false).Avg/Min/Maxover a cell with no usable values →null(noData: trueon KPI;value: nullon Chart bucket / Pivot cell). The frontend renders—.
Dashboard filters narrow every data-bound widget
Section titled “Dashboard filters narrow every data-bound widget”The request’s filters dictionary applies uniformly to every data-bound kind (Kpi / Chart / Table / Pivot / Map). A { "Status": "Open" } filter narrows the unpaid-invoices KPI, the unpaid-invoices table, AND the unpaid-invoices region map identically — they all run through the same IQueryEngine.BuildFilteredQuery pipeline as the admin grid, so a dashboard never disagrees with a list endpoint on which rows count as “open”.
Operator syntax: bare keys default to equality (Status → Status.eq); pre-encoded keys pass through (Amount.gte → >= 100). The QueryEngine’s FilterableField rules apply downstream — unknown fields and non-whitelisted operators are silently dropped.
Guarantees
Section titled “Guarantees”- Permission gate — widgets carrying a
RequiredPermissionthe user lacks short-circuit toUnavailableBEFORE the typed renderer runs. The dashboard returns200with the unreadable widget masked; no403on the whole request, and the underlying metric / query is never touched. - Unknown kind =
Error, not500— aWidgetTypewith no registeredIWidgetInstanceRenderer(module unloaded, version mismatch) becomes anErrorenvelope. Other widgets continue. - Per-widget exception isolation — a renderer that throws is logged server-side and surfaced as
Error.OperationCanceledExceptionpropagates unchanged (caller cancellation honoured). - Deterministic ordering — widgets stream out in
WidgetInstance.Positionorder regardless of which renderer is faster. The frontend reconciles future incremental push messages by widget id, not position.
Frontend cache identity — per-widget, not per-bundle
Section titled “Frontend cache identity — per-widget, not per-bundle”The bundle response is a transport optimisation (one HTTP round-trip over N widgets), not the cache identity. The useDashboard hook splits the payload into one TanStack Query entry per widget (['dashboard', dashboardId, 'widget', widgetId]) before storing it. This single decision unlocks two future invariants:
- Push-message reconciliation. When a future SSE / WebSocket transport emits
{ widgetId, sequence, delta }, the hook updates only the matching cache entry. No global refetch, no cross-widget invalidation cascade. - Independent staleness per widget. Static-hint widgets stay fresh for 5 min; dynamic widgets refresh at 60–120 s. Per-widget cache entries let TanStack apply the right TTL per kind without recomputing the whole dashboard on the first refresh tick.
Architecture & validation
Section titled “Architecture & validation”- Endpoint metadata — every route declares the five mandatory OpenAPI
metadata elements per CLAUDE.md (
WithName,WithSummary,WithDescription,Produces<T>,ProducesProblemfor each error path). - Validation — every
*RequestDTO has a matching FluentValidation validator underGranit.Dashboards.Endpoints/Validators/. Built-in operators (NotEmpty,MaximumLength,GreaterThan, …) auto-localize viaGranitErrorCodeLanguageManager— no hardcoded messages. - No EF Core leak — endpoint handlers never reference EF Core types.
Reader / Editor / StateTransitionService / WidgetService all live in
Granit.Dashboards.EntityFrameworkCore.Internaland are reachable from*.EndpointsviaInternalsVisibleTo. - Multi-tenant isolation — applied by the DbContext through
ApplyGranitConventions(currentTenant, dataFilter). The endpoints inherit that filter; cross-tenant rows are never reachable from any handler.
Read next
Section titled “Read next”- Conventions —
DashboardDefinitiondeclarative shape, widget catalogue, JSON wire format. - Analytics inline metrics — How the metrics surfaced by
KpiWidgetare declared and rendered. - OData feed (Power BI / Excel / Tableau) — Layer 3: external BI tools consume the same
QueryDefinitions exposed here as dashboard widgets. - Analytics conventions — Naming formula, period tokens, and the empty-set semantics shared with widget renderers.
- ADR-038 — boundary between the catalogue entry and the persisted aggregate.
- ADR-039 — widget renderer architecture:
IWidgetInstanceRenderer,IDashboardRenderer, JSON boundary, error isolation, frontend cache identity. - Permissions — full source of the permission constants.