Skip to content

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.

PermissionScope
Dashboards.Catalog.ReadRead the in-memory catalogue of registered DashboardDefinitions.
Dashboards.Instances.ReadRead the tenant’s persisted Dashboard aggregate — list, read-by-id.
Dashboards.Instances.ManageWrite access — import, edit metadata, state transitions, widget add / update / remove.

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.

MethodRoutePermissionDescription
GET/dashboards/catalogCatalog.ReadList every registered DashboardDefinition with its name, version, category, layout summary, widget count and view count.

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).

MethodRoutePermissionStatus codes
POST/dashboards/from-definition/{name}Instances.Manage201 + 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.

MethodRoutePermissionDescription
GET/dashboardsInstances.ReadList the tenant’s persisted dashboards, paginated.
GET/dashboards/{id:guid}Instances.ReadRead one dashboard with its full widget tree (ordered by Position).

GET /dashboards accepts three query parameters:

ParameterTypeDefaultNotes
statusDashboardStatus?noneFilter by Draft / Published / Archived. Omit to return everything in scope.
pageint0Zero-based page index (< 0 returns 400).
pageSizeint50Capped 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).

MethodRoutePermissionStatus codes
PUT/dashboards/{id:guid}Instances.Manage200 + 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.

Three idempotent transitions on the aggregate’s state machine — each returns the post-transition DashboardSummaryResponse so callers refresh without a follow-up GET.

MethodRoutePermission
POST/dashboards/{id:guid}/publishInstances.Manage
POST/dashboards/{id:guid}/archiveInstances.Manage
POST/dashboards/{id:guid}/restoreInstances.Manage

Transitions and status codes:

  • publishDraftPublished (idempotent on Published); 200 on success, 404 on miss, 409 when the dashboard is Archived.
  • archiveanyArchived (always idempotent); 200 on success, 404 on miss.
  • restoreArchivedDraft; 200 on success, 404 on miss, 409 when the dashboard is not Archived.

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.

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.

MethodRoutePermission
POST/dashboards/{id:guid}/widgetsInstances.Manage
PUT/dashboards/{id:guid}/widgets/{widgetId:guid}Instances.Manage
DELETE/dashboards/{id:guid}/widgets/{widgetId:guid}Instances.Manage

Status codes:

  • POST201 + WidgetInstanceResponse; 400 validation; 404 parent not in scope; 422 domain-guard rejection.
  • PUT200 + WidgetInstanceResponse; 400 validation; 404 dashboard or widget not in scope; 422 domain-guard rejection.
  • DELETE204; 404 dashboard or widget not in scope.
{
"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 uses metricName, table uses queryName, free-form widgets use neither.
  • requiredPermission — optional, ≤ 200 chars. Narrows visibility on top of the dashboard-level permission.
{
"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.

The DELETE and PUT widget endpoints distinguish the two 404 reasons:

  • Dashboard not in scopeDashboard '{id}' not found.
  • Widget not on that dashboardWidget '{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.

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.

MethodRoutePermission
POST/dashboards/{id:guid}/renderInstances.Read

Status codes:

  • POST200 + DashboardRenderResponse; 400 period-bound validation; 404 dashboard not in scope.
{
"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). from strictly before to. 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 to en.
  • 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.
{
"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".

Eight shipped kinds. Each carries its own typed snapshot payload — the frontend dispatches on widgetType to pick the matching renderer.

{
"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").

{
"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.

{
"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.

{
"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.

{
"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.

Numeric aggregations across every kind share the same contract (locked by tests #1374):

  • Count over zero rows → 0 (noData: false).
  • Sum over an empty set → 0 (sum-of-empty identity, noData: false).
  • Avg / Min / Max over a cell with no usable values → null (noData: true on KPI; value: null on 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 (StatusStatus.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.

  • Permission gate — widgets carrying a RequiredPermission the user lacks short-circuit to Unavailable BEFORE the typed renderer runs. The dashboard returns 200 with the unreadable widget masked; no 403 on the whole request, and the underlying metric / query is never touched.
  • Unknown kind = Error, not 500 — a WidgetType with no registered IWidgetInstanceRenderer (module unloaded, version mismatch) becomes an Error envelope. Other widgets continue.
  • Per-widget exception isolation — a renderer that throws is logged server-side and surfaced as Error. OperationCanceledException propagates unchanged (caller cancellation honoured).
  • Deterministic ordering — widgets stream out in WidgetInstance.Position order 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.
  • Endpoint metadata — every route declares the five mandatory OpenAPI metadata elements per CLAUDE.md (WithName, WithSummary, WithDescription, Produces<T>, ProducesProblem for each error path).
  • Validation — every *Request DTO has a matching FluentValidation validator under Granit.Dashboards.Endpoints/Validators/. Built-in operators (NotEmpty, MaximumLength, GreaterThan, …) auto-localize via GranitErrorCodeLanguageManager — no hardcoded messages.
  • No EF Core leak — endpoint handlers never reference EF Core types. Reader / Editor / StateTransitionService / WidgetService all live in Granit.Dashboards.EntityFrameworkCore.Internal and are reachable from *.Endpoints via InternalsVisibleTo.
  • 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.
  • ConventionsDashboardDefinition declarative shape, widget catalogue, JSON wire format.
  • Analytics inline metrics — How the metrics surfaced by KpiWidget are 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.