App dashboards

An app declares an admin dashboard for its owners in one file, `dashboard.json`. The owners are the members of the account that holds the app, and for a featured app (a jet), each customer account that enabled it. The Substrio console draws the dashboard with its own components when an owner opens the app: its pages, numbers, charts, tables with filters and paging, records, forms, and buttons that act on a row. No console release and no hand-written console page are needed, for Substrio's own jets and for every app an agent builds. What the app's own users see is the app's own site; the dashboard is the owners' side.

Docs menu

The file names what to show and where the data comes from. Each panel reads, or for a form calls, one route the app's own version declares, and maps the route's answer with JSON pointers. The console calls those routes with route.call as the account viewing it, so the route's own gate decides every answer: the platform scopes the call, and the app never decides access in the dashboard. The platform checks the file once, when the version is checked or pushed, and keeps its canonical bytes; the console reads them with dashboard.get. Nothing is added to an app's request path.

Declaring it

Name the file in substrio.toml, beside plans_file:

dashboard_file = "dashboard.json"

substrio push carries it in the version it makes (the version member dashboard); a version made over the API carries it the same way (version.create's dashboard, an object). In the version's tree it is config/dashboard.json, so an agent that edits and commits the tree's files changes it the same way.

The dashboard is part of the version. It goes live with the version, in a sandbox on push and in the app on promote, and a rollback brings the older one back. A version without a dashboard leaves the app with none.

The file

{"nav": [
  {"id": "overview", "title": "Overview", "icon": "overview",
   "panels": [
     {"kind": "metric", "title": "Sent today", "source": {"route": "GET /stats"},
      "value": "/today", "cap": "/limit", "format": "number"},
     {"kind": "usage"}]}
]}

nav is the app's pages, in the order the console lists them. Each nav item is:

Field Required What it is
id yes 1 to 64 letters, digits, _ or -; unique in the file. The console uses it in the page's address, and a drill-down names it.
title yes 1 to 128 bytes, the page's name in the nav.
icon yes One of overview, chart, table, list, mail, users, key, globe, settings, file, log, card.
panels yes 1 to 16 panels, drawn in order.
role no Who sees the page: owner, admin or member (the default).
badge no A count beside the page in the menu: {"route": "GET /orders/count", "value": "/open"}, read once when the dashboard opens. Its route takes no :params.
params no Derived, never needed: the :params the page's source routes take (below). Written in the canonical bytes; given in the file it must be exactly those.

Pages that take params

A page whose panels read a route with :params (GET /orders/:id) is a detail page. The check derives its params (here ["id"]) and writes them on the nav item. The console does not list such a page in the menu; a table's drill-down opens it with the params bound from the clicked row. Every drill-down to it must bind exactly those params.

Panels

Every panel has a kind. title (1 to 128 bytes) is required for the five kinds that read a route and optional for the others. A field that is not one of its kind's is refused.

Kind Source Fields What the console draws
metric yes value (pointer), cap (pointer, optional), format One number, and when cap is given, a meter of value against it.
series yes points (pointer to an array), x, y (pointers into a point), format A chart of the points, x along, y up. format is y's.
table yes rows (pointer to an array), columns (1 to 12), cursor, open, actions A table, one row an element of rows.
record yes fields (1 to 32), actions One object's fields, label beside value.
form yes fields (1 to 32 inputs), submit A form; submitting it calls the route with the inputs as the JSON body.
keys no none The app's keys, as the console's own Keys panel.
logs no none The app's function logs.
usage no none The app's use against its account's plan.
module no module A console module the featured app ships. Only a featured app may have one.

Fields every kind takes, and the ones the reading kinds take:

Field Kinds What it is
width all full (the default), half or third of the page's row: three third metrics make a row over a full table.
refresh_seconds metric, series, table, record, logs, usage 0 (the default: never) or 5 to 3600; the console calls the route again that often while the page is open. Nothing runs on the server.
query metric, series, table, record 1 to 8 filters, each {"name", "label", "type", "options"}, passed as query keys on the source route (below).

source is {"route": "METHOD /path"}: a method of GET, POST, PUT, PATCH or DELETE, one space, and the path exactly as a function of the same version declares it (its parameters by the same names, GET /orders/:id). The route's gate and ip words are left out: "GET /stats" names the route declared as "GET /stats auth". A route declared with no method, or as ANY, answers every method on its path.

A table's columns and a record's fields are each {"field", "label", "format"}: field a pointer into the row (or the record), label 1 to 128 bytes, format optional (text). A form's fields are each {"name", "label", "type", "options"}: name the body member it sets (1 to 64 letters, digits, _, -, unique in the form), label, type optional (text), and options (1 to 64 strings) for a select and only a select.

A module panel names the module by its id (1 to 64 letters, digits, _, -): {"kind": "module", "module": "domains"}. Modules are the featured apps' own console code; any other app's module panel is refused.

Paging

A table with cursor (a pointer to the next page's cursor in the answer, "/next") pages: the console calls the route again with ?cursor=<that value>, until the answer holds none. No filter of a paging table may be called cursor.

Filters

Each filter of query is {"name", "label", "type", "options"}: name the query key (1 to 64 letters, digits, _, -, unique in the panel), label 1 to 128 bytes, type one of:

Type The console asks for It passes
text (the default) a search box ?<name>=<text>
select one of options (1 to 64 strings, a select's only) ?<name>=<option>
date_range a from and a to date ?<name>_from=<seconds>&<name>_to=<seconds>

A filter left empty is not passed. The route reads its query and answers what it should; the dashboard never filters an answer itself.

Actions

A table (on each row) and a record may have actions, 1 to 8 buttons:

{"id": "refund", "label": "Refund", "route": "POST /orders/:id/refund", "params": {"id": "/id"},
 "confirm": "Refund this order in full?", "danger": true, "role": "admin"}
Field Required What it is
id yes 1 to 64 letters, digits, _, -; unique in the panel.
label yes The button, 1 to 128 bytes.
route yes A route of the version, checked like a source. The console calls it with route.call, no body.
params no {"<param>": "<pointer>"}, at most 8: each :param of the route bound from the row (or the record) by a JSON pointer. A name the route does not take is refused.
confirm no A question the console asks first, 1 to 256 bytes.
danger no true draws the button as destructive. Default false.
role no Who sees the button: owner, admin (the default) or member.

Every :param of an action's route is bound by its params or by its page's params (a record on a detail page binds :id from the page). One that neither binds is refused.

A form's submit is an action too: its button. It is the label alone ("submit": "Send") or {"label", "confirm", "danger", "role"}, and it calls the form's own source route.

Drill-down

A table may have open: {"item": "<nav id>", "params": {"id": "/id"}}. Clicking a row opens that nav item with the params bound from the row into its panels' routes (GET /orders/:id). item must be a nav item of the file, and params must bind exactly the params that page takes, no more, no fewer.

Who sees what

role on a nav item (default member) and on an action (default admin) says the least role of the viewer's account that sees it: owner above admin above member. The console hides what the viewer's role cannot use. That is presentation only: the call itself is route.call as the viewer, and the route's gate decides it. Give a destructive route a gate that refuses who should not use it; never rely on role alone.

Pointers

Every mapping is a JSON pointer (RFC 6901) into the route's answer: "" is the whole answer, "/today" its today member, "/days/0/count" the first day's count. In a pointer, ~1 is a / inside a name and ~0 a ~; any other ~ is refused. At most 256 bytes.

Formats

Format The console writes
text the value as it is (the default)
number a number with grouping, 12,480
bytes a size, 1.2 GB
money an amount in minor units (cents) as money, $12.40
date a date and time, from seconds or milliseconds since the epoch or an ISO 8601 string
duration a length of time, from seconds, 3m 20s
status a status chip, coloured by the word (ok, active, failed, ...)

Form inputs: text, number, email, select, toggle.

Limits

What At most
The file 64 KB, and 64 KB written compact
Nav items 32
Panels a nav item 16
Columns a table 12
Fields a record or form 32
Actions a table or record 8
Filters a panel 8
Params an action or drill-down binds 8
Options a select 64
refresh_seconds 0, or 5 to 3600
An id, a name, a module id 64 bytes
A title, label, option or submit label 128 bytes
A confirm question 256 bytes
A pointer 256 bytes
A route 512 bytes

A file past any limit is refused whole, naming the path.

When it is checked

version.check and version.create check the dashboard before anything is stored: every field, every limit, every source, action and badge route against the routes of that same version, every :param bound, every drill-down's nav item and params, and a module panel against the app being featured. A refusal is DASHBOARD_INVALID with field naming the path, so an agent sees it before it ships. A version whose dashboard is refused is not stored; what is live stays as it was.

The checked file is written back canonical: fixed key order, compact, every default written ("format": "text", "width": "full", "refresh_seconds": 0 on the kinds that refresh, "role" on every nav item and action, "danger": false, a submit as its object), a page's derived params, bindings sorted by name. Absent optional parts (cap, cursor, open, badge, confirm, query, actions) stay absent. Those bytes are what the version carries and what dashboard.get answers, whatever the file's own key order and spacing. Nothing is parsed again after the check.

To see a version's dashboard as the console will draw it before it goes live, read dashboard.get with that version (?version=<number or id>, below): every nav item and panel, checked against that version's own routes by the same checker, canonical. version.check answers only whether the file passes.

Reading it: dashboard.get

GET /v1/_accounts/{id}/apps/{slug}/dashboard (MCP substrio_dashboard_get {id, slug}, substrio dashboard get id=7 slug=shop, the SDK from the same operation) answers the app's stored dashboard, the canonical bytes, as they are. An app with none answers {"nav":[]}. The live version's dashboard is the stored one: promoting a version stores its dashboard, and a version without one removes it.

{id} is the account the caller reads through, and the app is read when:

  • the account holds the app (the caller is a member signed in, or one of the account's keys);
  • the app is a featured app the account has enabled (featured.enable).

A caller who is not in the account is answered 404 ACCOUNT_NOT_FOUND. Any other app is answered 404 TENANT_NOT_FOUND, the same as an app that does not exist, so a dashboard never tells an outsider an app is there.

curl -H "Authorization: Bearer $TOKEN" https://substrio.com/v1/_accounts/7/apps/shop/dashboard
# {"nav":[{"id":"home","title":"Home","icon":"overview","panels":[{"kind":"usage"}]}]}

Cost: the caller's standing in the account (1 lookup), 1 app snapshot, 1 database lease, 1 record read and 1 copy into the reply; 0 parses. For a featured app read by another account, +3 flag tests and +1 read of the account's plan record there.

One version's dashboard: version

GET /v1/_accounts/{id}/apps/{slug}/dashboard?version=12 (MCP substrio_dashboard_get {id, slug, version}) answers the dashboard of version 12 instead of the live one: checked against the routes that version's own functions declare, by the same checker a push runs, and written canonical. A version without a dashboard answers {"nav":[]}. version is a number or an id (64 hex digits); a branch, live or a sandbox's name works too, as in version.check. Use it after a push and before a promote to see exactly what the console will draw.

curl -H "Authorization: Bearer $TOKEN" "https://substrio.com/v1/_accounts/7/apps/shop/dashboard?version=12"

A version's history is its own account's: version on a featured app read by another account is refused with INVALID_ARGUMENT, field version. Cost, paid only when version is named: 0 reads for a number (any other name 1 ref read and 1 version read), 1 manifest read, 1 parse; no database lease.

Refusals

Code Status Field When What to do
DASHBOARD_INVALID (0xC0E0) 400 the path in the file: nav[2].panels[0].source.route, nav[0].icon, none for the whole file version.check or version.create with a dashboard the console cannot draw (the details below) Correct the file at that path; the detail says what is wrong
TENANT_NOT_FOUND 404 dashboard.get of an app that does not exist, or that the account neither holds nor has enabled Read an app of your own account, or featured.enable the featured app first
ACCOUNT_NOT_FOUND 404 dashboard.get through an account the caller is not in Name an account you are a member of (or the account of your key)
INVALID_ARGUMENT 400 version dashboard.get with version on a featured app another account owns Read its live dashboard (no version), or name a version of an app of your own account
KEY_NOT_FOUND 404 version dashboard.get with a version the app does not have: a number past its last, or a branch, live or sandbox name that names none (as version.check answers) Name a version version.list lists
DASHBOARD_INVALID (0xC0E0) 400 the path in the file dashboard.get with a version whose dashboard no longer passes (a commit's file is stored unchecked) Correct config/dashboard.json and commit again

The details, as the server writes them:

Fault Detail
A field unknown to its place nav[0].panels[1].rows is not a field of a metric panel
A required field missing nav[0].panels[0].value is required
A field given twice nav[0].id is given twice
A word not in its set nav[0].icon must be one of overview, chart, table, ...; nav[1].role must be one of owner, admin or member
A source, action or badge route the version does not declare nav[2].panels[0].source.route names no route this version declares: GET /gone
A malformed route nav[0].panels[0].source.route must be "METHOD /path" with a method of GET, POST, PUT, PATCH or DELETE
An action's :param nothing binds nav[0].panels[3].actions[0].route takes :id, bound by neither its params nor its page
An action binding a param its route does not take nav[0].panels[3].actions[0].params.x is not a :param of POST /orders/:id/refund
A drill-down to a nav id the file lacks nav[0].panels[3].open.item names no nav item: invoice
A drill-down leaving a page param unbound, or binding one too many nav[0].panels[3].open.params must bind :id, which nav item order takes; nav[0].panels[3].open.params.x binds :x, which nav item order does not take
A badge route with :params nav[0].badge.route takes a :param; a badge is read with none bound
A refresh out of range nav[0].panels[0].refresh_seconds must be 0 (never) or a whole number of seconds from 5 to 3600
A paging table's filter called cursor nav[0].panels[3].query[0].name is the table's paging key
A repeated id or name nav[1].id repeats orders; nav[0].panels[3].actions[1].id repeats refund
A limit passed nav has more than 32 items; nav[1].panels must be an array of 1 to 16 panels
A bad pointer nav[0].panels[0].value must be a JSON pointer ("" or "/a/0/b", '~' only as ~0 or ~1) of at most 256 bytes
A module on an app that is not featured nav[0].panels[3].module is a featured app's only: shop is not featured
The whole file the dashboard file must be a JSON object; the dashboard file is larger than 65536 bytes

A worked example: an orders app

The app's functions declare GET /orders/count auth ({"open", "revenue", "refunded"}), GET /orders auth (one page of {"items", "next"}, reading q, status, placed_from, placed_to and cursor), GET /orders/:id auth, POST /orders/:id/refund auth:ADMIN and POST /orders/:id/notes auth. Its dashboard.json:

{"nav": [
  {"id": "orders", "title": "Orders", "icon": "card", "badge": {"route": "GET /orders/count", "value": "/open"},
   "panels": [
     {"kind": "metric", "title": "Open", "width": "third", "refresh_seconds": 30,
      "source": {"route": "GET /orders/count"}, "value": "/open", "format": "number"},
     {"kind": "metric", "title": "Revenue", "width": "third",
      "source": {"route": "GET /orders/count"}, "value": "/revenue", "format": "money"},
     {"kind": "metric", "title": "Refunded", "width": "third",
      "source": {"route": "GET /orders/count"}, "value": "/refunded", "format": "money"},
     {"kind": "table", "title": "Orders", "source": {"route": "GET /orders"}, "rows": "/items", "cursor": "/next",
      "query": [{"name": "q", "label": "Search"},
                {"name": "status", "label": "Status", "type": "select", "options": ["open", "paid", "refunded"]},
                {"name": "placed", "label": "Placed", "type": "date_range"}],
      "columns": [{"field": "/id", "label": "Order"},
                  {"field": "/total", "label": "Total", "format": "money"},
                  {"field": "/status", "label": "Status", "format": "status"}],
      "open": {"item": "order", "params": {"id": "/id"}},
      "actions": [{"id": "refund", "label": "Refund", "route": "POST /orders/:id/refund", "params": {"id": "/id"},
                   "confirm": "Refund this order in full?", "danger": true}]}]},
  {"id": "order", "title": "Order", "icon": "file",
   "panels": [
     {"kind": "record", "title": "Order", "source": {"route": "GET /orders/:id"},
      "fields": [{"field": "/id", "label": "Order"},
                 {"field": "/total", "label": "Total", "format": "money"},
                 {"field": "/placed_at", "label": "Placed", "format": "date"}],
      "actions": [{"id": "refund", "label": "Refund", "route": "POST /orders/:id/refund", "danger": true,
                   "confirm": "Refund this order in full?", "role": "owner"}]},
     {"kind": "form", "title": "Add a note", "source": {"route": "POST /orders/:id/notes"},
      "fields": [{"name": "text", "label": "Note"}], "submit": "Add"}]}
]}

What the console shows an owner: an Orders entry in the app's menu with the open count beside it. The page opens on a row of three meters, Open (re-read every 30 seconds), Revenue and Refunded, over a full-width table of orders with a search box, a status picker and a date range. The table pages with a "More" control while the answer has a next. Each row has a red Refund button that asks "Refund this order in full?" and then calls POST /orders/o_42/refund as the owner. Clicking a row opens the Order page, which is not in the menu since it takes :id, bound from the row's /id. That page has the order's fields, an owner-only Refund (its :id bound from the page), and an "Add a note" form that posts {"text": "..."} to POST /orders/o_42/notes.

Pushing it with "route": "POST /orders/:id/cancel" on the table's action, a route no function declares, is refused before anything is stored:

{"code": "DASHBOARD_INVALID", "next_action": "correct_request", "field": "nav[0].panels[3].actions[0].route",
 "detail": "nav[0].panels[3].actions[0].route names no route this version declares: POST /orders/:id/cancel", "...": "..."}