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", "...": "..."}