Skip to main content
Version: 1.19.0

Features

Orbit is a single panel made of focused modules. Each one reads live state from the host application's Runtime. There is no separate data store to feed or keep in sync.

Data Studio​

Browse, create, edit, and delete records for every model in the application's registry. It is tenant-aware (when multitenancy is enabled) and supports import/export.

Tenant scope. With multitenant_enabled on, every Data Studio operation is confined to the tenant the host application resolves for the request (in Nucleus, from the subdomain or the configured header), or to multitenant_default when it resolves none. Confined means: the list and the CSV export only show that tenant's rows; a record of another tenant is not found by id (get, update, delete, bulk delete); a create or an update cannot name another tenant under any key the backend resolves to the tenant field — its storage column, its Go field name or, for a Nucleus model, the JSON key its records carry, in any letter case — and a payload naming the field under two of those keys is a 400. The tenant value itself is compared exactly: both backends store it verbatim, so a padded spelling of the request's tenant (" acme ") is another tenant and is refused the same way, and a payload naming the request's tenant has that value replaced by the resolved tenant before it reaches the backend, so the stored column is always the tenant the request resolved. Both backends refuse a payload that names one field twice (Nucleus 422, Quark 400) whichever keys it uses, so a key the panel does not resolve cannot outvote the tenant it stamps; Quark's store applies the keys of an update by column and Go name only, so a JSON-tag alias in an update is dropped, never applied. A tenant column hidden from JSON (json:"-") is confined the same way: the records both backends emit carry no tenant key, so a row is confirmed by id through a list filtered by tenant and primary key instead of read off the record, the guard knows the field by its column and Go name, and a create is stamped there — Quark's store sets a schema column hidden from JSON on the entity itself, since json.Unmarshal never would. Exports, imports and fixtures work inside that tenant whatever tenant_id their request body carries — a row that names another tenant fails, a row whose id belongs to another tenant's record fails as not found, and an export job (/api/exports, its status and its download) is listed and served only to requests scoped to the tenant it was produced for. Models without a tenant column are not scoped. A request the host resolves no tenant for, with no default configured, is refused with a 403 rather than opened to every tenant — unless it comes from a superuser or a subject granted tenant_switch, who is then unscoped (every tenant) without an audit entry: only an explicit ?tenant= switch is recorded. Looking at another tenant, or at all of them, is that explicit switch: ?tenant=<id> or ?tenant=all on the request, accepted only from a superuser or a subject granted the tenant_switch action on admin:* (a policy granting every action, *, on admin:* includes it), and recorded in the audit log as tenant.override. Anyone else gets a 403. Without an auth provider (the open posture, warned at mount) there is no operator to gate: ?tenant= is accepted from any client and a request with no resolved tenant is unscoped. The audit log itself is not filtered by tenant (see below). The confinement is only as strong as the host's resolution: a tenant read from a request header the client can set (Nucleus' header resolver with no proxy overwriting that header) is the client's choice, so resolve it from the host name, or from a header a trusted proxy sets, for the scope to hold.

Record ids. Ids are strings everywhere the API exchanges them — record paths, the bulk endpoint's ids and errors[].id, the export's ?ids=, fixture pk values — so a UUID key works like an integer one. Numbers are still accepted on input. An id the backend cannot narrow to the model's key type is a 400 on a single-record call and a per-id entry in errors[] on a bulk one.

Search. ?search= looks in the fields a model declares searchable: in Nucleus, fields tagged admin:"search", listed in ModelConfig.SearchFields, or switched on in the panel's Field settings; Quark models search every string column. A Nucleus model that declares none is not searchable today — the registry does not yet default search to its string columns — so a search on it answers 400 naming the model and how to enable search, rather than every row, and the grid disables its search box. The two backends match differently (Nucleus lower-cases both sides; Quark escapes % and _ in the text per engine), so do not expect identical results across them.

Filters that carry an operator. A filter used to mean one thing — this column equals this value — and the query string carries the comparison now:

GET /api/models/Invoice?status=unpaid&total__gt=1000&due_date__lte=2026-06-30
GET /api/models/Article?title__contains=hammer&archived_at__isnull=true
GET /api/models/Order?status__in=open,paused&customer__startswith=ACME

The operator goes after the field, separated by two underscores. The twelve are eq, ne, gt, gte, lt, lte, contains, startswith, endswith, in, not_in and isnull; in and not_in take a comma-separated list, and isnull a boolean. A plain ?field=value still means equality, and the two forms are ANDed, so ?status=open&views__gt=100 is one query.

Three things to know before you rely on it:

  • An operator this panel does not know is refused, not read as equality. A filter that is quietly dropped answers every row and looks like a result.
  • A wildcard in the value is data. ?title__contains=50%25 looks for the per-cent sign. On PostgreSQL, MySQL and SQL Server it is escaped; on an engine whose LIKE has no escape character (SQLite, Oracle) the Quark-backed data source refuses the query instead of answering it wrongly.
  • An empty in matches nothing. ?status__in= is a question with an answer, not an absent filter.

A field the model does not offer as a filter is refused through the operator form exactly as it is through the plain one, and an excluded field is answered as if the column did not exist — see below.

The grid's own filter row still sends equality; the operator forms are typed into the URL, and a saved view keeps one — which is what a query an operator returns to every morning usually is.

A total the pager can divide. A filtered list used to answer total: -1 with is_estimated: true, which no pager can turn into a page count. The list endpoint now asks the data source for a real count over the same filters the page uses, so total and total_pages describe the query you sent. It costs one extra count per page request — paid by the screen that draws a pager, and not by the exports, imports and fixtures that walk every page without one.

What the grid refuses. A column the panel does not show is not a sort key and not a filter. ?order_by= and the filter parameters resolve only against the fields the panel would render, so a request naming an excluded field — password_hash, say — is refused instead of reaching the store's ORDER BY, where it would have paginated the table in that hidden column's order and turned the page numbers into a comparison oracle over a value the schema endpoint, the exporters and the audit redactor all take care never to emit. The import validator reads a cell against the width its column declares, in arbitrary precision rather than through the validator's own conversions, so 300 in an int8 column and 70000 in a uint16 one fail the row instead of reaching the writer.

Data Studio with the Articles model selected: a sidebar listing the registered models with their record counts, and a grid showing seven article records with their real column values

What it lists comes entirely from the host application: a model appears here when your app registers it (in Nucleus, by listing the struct in a module's Models). An app that registers no models gets an empty Data Studio — see the quick start for the three lines that populate it.

Data Studio does not speak the framework's types directly. It reads and writes through a neutral data-source contract (github.com/jcsvwinston/orbit/datasource, a module of its own that neither the panel nor its SPA come with), with the Nucleus model registry as the default backend. Applications built on the Quark ORM can point it at their Quark models instead: add the opt-in quarkdatasource module and set orbit.Config.DataSource.

datasource.Query is a frozen shape, so the operator filters and the exact count arrived as new fields beside the old ones rather than as a richer Filters: Where []Filter and ExactTotal bool. A data source written before them keeps compiling and keeps answering what it answered, which is the point of adding rather than changing — but a list it serves will then ignore an operator filter the panel sent, so a third-party implementation should read both. Two rules it has to keep, because each is a way to answer more rows than were asked for while looking like a filter: a pattern operator matches its value LITERALLY (a % in the text is a per-cent sign), and an in with no values matches nothing rather than being dropped. ExactTotal may be ignored by a source that always counts exactly, which is what quarkdatasource does.

Live runtime inspector​

A real-time feed of incoming HTTP requests and executed SQL across the whole application, sourced from the framework's observability event bus.

The Network Inspector&#39;s request log capturing traffic against the showcase application&#39;s public API: GET and POST requests to /api/articles and /api/authors with their status codes and durations

On a single node the feed is filled from three lanes:

  • HTTP requests — from the framework's event bus.
  • SQL statements — from the framework's event bus.
  • Session activity — recorded by the panel's own surface.

The panel's own admin traffic is kept out of the request feed by default: the admin prefix ships as the default exclude pattern. Remove that pattern to see it.

Two keys shape the rest of the feed. live_exclude_patterns keeps noisy paths (health checks, static assets) out, and trace_url_template deep-links each entry into an external trace explorer.

Seeing more than one node​

The feed can aggregate across nodes in either of two ways, and they are independent of each other:

  • The Redis live-feed relay (cluster_* in Configuration) — the nodes of one application share a single feed, with no extra process to deploy.
  • The fleet plane — a standalone observability server that application nodes stream to.

Bridging Quark ORM statements​

The SQL lane is fed by the framework's event bus, which the framework's own CRUD layer publishes to. Applications that run their queries through the Quark ORM can surface those statements in the same live view with the opt-in quarkbridge module.

quarkbridge is a Quark middleware. It maps each executed statement to a Nucleus SQL event, correlates it to the request, and publishes it through the framework's public SQL ingest. It respects Quark's argument redaction and needs no change to Orbit itself. OpenTelemetry remains complementary for durable tracing.

Session viewer​

List active server-side sessions, see whose each one is and from what device, and revoke one — or every session of one account at once.

What you doRoute
List sessionsGET /admin/api/sessions
End one sessionDELETE /admin/api/sessions/{id}
End every session of one userPOST /admin/api/sessions/revoke-all with {"user": "<name>"}

A row carries user (the operator the panel signed in, or the identity key the application stores in its own sessions), device (a short label such as Firefox on Linux derived from the user agent the panel recorded, with the raw user_agent beside it), remote_ip, the node that served it, and current: true on the session the listing request was made with, so the viewer can say "this is you" instead of asking the operator to match a token prefix. The row never carries the session token itself: id is a one-way handle the terminate endpoint resolves server-side.

Revoke-all matches on the name the row shows. The user you pass is the same string the list serves, so what you read is exactly what the call acts on. The session the request is made with is always kept: revoking your own account signs out every other device, and a request that revoked itself would leave the screen with nobody behind it. The answer says how many sessions were ended and whether yours was among the matches and kept ({"user": "ana", "revoked": 2, "kept_current": true}); a user with no open session is an honest revoked: 0, not an error. Both routes need the terminate_sessions action and both are audited — the bulk one as session.revoke_all with the user as the record and the count in the entry, whether or not the call completed.

The device column depends on the panel seeing a request from that session: the panel stamps the user agent on every request that goes through it, under the same key the framework's own session middleware uses, so a session that signed in and has not touched the panel yet shows no device until it does.

Operators​

The people who sign in to the panel are managed from it: create an account, give it a role, reset a password somebody forgot, deactivate the account of somebody who left. Until this existed the panel could edit the policies and not the people they applied to — an account could only be created with nucleus createuser on the server.

What you doRoute
List operators, with the roles each one holdsGET /admin/api/admin-users
Create one (optionally with roles)POST /admin/api/admin-users
Change an email, promote or demote a superuserPUT /admin/api/admin-users/{id}
Set a new passwordPOST /admin/api/admin-users/{id}/password
Deactivate / reactivatePOST /admin/api/admin-users/{id}/disable · /enable
Grant or revoke a rolePOST · DELETE /admin/api/admin-users/{id}/roles
Delete the accountDELETE /admin/api/admin-users/{id}

Deactivating is not deleting. A deactivated operator cannot sign in and their existing session stops working on its next request — the panel re-reads the account on every request — but the account and everything the audit log records about it stay. Deleting removes the row; the trail of what that person did remains.

Two refusals are built in, because locking everyone out is a single click: you cannot deactivate, delete or demote your own account, and nobody can deactivate, delete or demote the last active superuser. Both answer 409 with the reason.

Passwords set here are hashed like any other credential and are never written to the audit log — the entry records that a password changed, not what to.

Operator management needs an admin authentication provider that owns the accounts, which is the default one (backed by nucleus_admin_users). An application that authenticates its operators elsewhere gets 501 on these routes rather than a second, competing account store.

Saved views​

The filter set an operator returns to every morning used to live in the URL and nowhere else. A saved view stores a name, the model and the query string the grid was showing:

GET /api/views?model=Invoice
POST /api/views {"model":"Invoice","name":"Unpaid over 90 days","query":"status=unpaid&order_by=due_date+asc"}
PUT /api/views/{id}
DELETE /api/views/{id}

The query is stored as text and is not parsed against today's schema: a view is a shortcut to a URL, so one that stops making sense fails on the list endpoint with that endpoint's message rather than being silently dropped.

A view belongs to whoever saved it. is_shared makes it visible to everyone; editing and removing stay with its owner (a superuser may tidy up any). There is no permission of its own — creating a view needs the list permission of the model it points at, and a view of a model an operator cannot list is not shown to them, because the row would disclose both the model and what somebody filters it by. Every change is audited (view.create, view.update, view.delete).

Views need a database handle to live in; a panel without one answers 501 rather than failing later.

Forms that hold a relation, a document and a file​

A form needs three things a table of scalars does not.

What a foreign key points at. The schema marks the key (is_fk), and two endpoints resolve it:

GET /api/models/{model}/options?q=&limit= the candidates of a model
GET /api/models/{model}/fields/{field}/options the candidates a field may point at

Each option carries the value a record stores and a label a person reads (the model's first searchable text field, then its first listed one). The permission is the target's: resolving what an Author id means is reading Authors, so an operator who may edit the record and not browse the target gets a 403 and a form that falls back to the raw id — the panel does not widen a grant to render a nicer widget. Search, tenant confinement and row scope apply exactly as they do to a list.

Children, edited with the parent. A model whose foreign key names another appears in the parent's schema as an inline, and the parent's own payload carries the children:

{ "title": "Kind of Blue",
"tracks": [ { "title": "So What" },
{ "id": 12, "title": "Blue in Green" },
{ "id": 13, "_delete": true } ] }

A row with an id is an edit, one without is an insert, and a row that should go says so — absence never deletes, because a form that loaded two of five lines would otherwise remove the three it never showed. The key pointing at the parent is stamped by the panel, so a child cannot be filed under another record. Writing children needs the child model's own permissions, and they are checked before the parent is written.

This is deliberately not transactional: the panel's data contract writes one row at a time, so the parent is saved first and each child reported on its own in the response (inlines). A form that needs all-or-nothing needs a transactional data source underneath it, and saying so is better than implying otherwise.

Documents, rich text and files. The schema's widget vocabulary was scalar; it now also names json, richtext, file and image. A JSON document is inferred from the column type; the other two are claims about intent that no type carries, so the application declares them:

modules:
orbit:
field_widgets:
Album.Notes: richtext
Album.cover: image

A value may also name a field renderer your own script registers, which draws the field your way in the list and on the record view (see Code of your own in the browser).

The declaration is checked when the panel mounts. A widget outside those four that is not a renderer you declared, a key that names no field of your models, or two keys for one field with different widgets stops the application with a message naming the entry — each of them is a form that would otherwise quietly show the column's plain input.

A file field holds a storage key, and POST /api/models/{model}/upload (multipart, file plus an optional field) produces one: the bytes go to the application's own storage and the answer carries the key the form writes into the record. The route refuses a field that does not hold a file, caps an upload at 32 MB, keeps only the base name of what the client called it, and is audited (field.upload).

Access control (RBAC)​

Inspect and manage the Casbin policies and roles that back the application's authorizer. Orbit registers its own prefix with the framework's default-deny RBAC, so the admin surface is gated like any other route.

A policy is (subject, object, action). The subject is an operator's id, role or username; the action is the verb a handler checks (list, retrieve, create, update, delete, export_csv, bulk_delete, bulk_export, and the panel-wide ones such as list_models or audit_view). The object names what the verb applies to, at three levels of resolution:

ObjectMeans
admin:Postthe whole model, every row and every field
admin:Post#ownthe same verb, confined to the rows that belong to this operator
admin:Post.titleone field of the model

A superuser bypasses all three, as it always has.

Per-row permissions​

admin:Post#own is the grant an editorial admin needs: an author lists, opens and edits their own posts and does not see anybody else's. Lists are filtered by the owner column, a row owned by somebody else answers 404 on the record endpoints (the same answer as a row that does not exist, so ids are not disclosed), a create stamps the operator as the owner, and an update cannot hand a row over.

Which column says who owns a row is the application's answer, not a guess:

modules:
orbit:
row_owner_fields:
Post: author # the column of Post that holds the operator
"*": owner # the default for every other model
row_owner_subject: username # or "id"

A #own grant on a model with no entry there is refused with a 403 that says why. It is never widened to every row: an ownership rule that silently degrades to "everything" is the failure this is built to prevent.

Per-field permissions​

A policy whose object names a field narrows one column, in either of the two shapes an admin needs — the exception, or the whole permitted set:

PolicyMeans
(editors, admin:Post.price, deny)editors neither read nor write price
(editors, admin:Post.title, update)an allow-list: editors update title, and nothing else
(editors, admin:Post.title, create)the same, for creates
(editors, admin:Post.title, write)both of the two above
(editors, admin:Post.title, read)a read allow-list: only the named fields are emitted

An allow-list only applies to a subject that holds at least one field policy of that action for the model; a subject with none keeps the model-level grant it always had. A write that names a field the operator may not write is refused with a 403 naming the field — not dropped silently, because a form that believes it saved a value it did not save is worse than one that is told. A field the operator may not read is left out of the record, the list, the CSV export and the schema.

Field policies narrow a grant; they never widen one. An operator who cannot update the model at all is refused before any field is consulted.

What a screen is told​

The payloads a model screen loads carry what this operator may do, so the panel can disable what it may not instead of finding out by being refused:

  • GET /api/models and GET /api/models/{name}/schema carry permissions (action → boolean), can_create, can_update, can_delete, and row_scope — the actions confined to the operator's own rows;
  • each field of the schema carries can_edit (can_read is true for every field that arrives: the ones it is false for are not in the schema).

They are a rendering aid. Every one of them is enforced again on the request that follows, and a client that ignores them is refused exactly as before.

System metrics​

Runtime and resource consumption at a glance — CPU, memory, goroutines, and the database connection pool.

System Pulse showing live runtime metrics of the showcase application: goroutine and heap-allocation counters, a runtime trend chart, database pool health, and outbox delivery state

Audit log​

A trail of admin actions, kept in the database the panel already uses: a table it creates and owns (nucleus_admin_audit). It survives a restart or a deploy, every replica writes to and reads from the same trail, and the answer to "what happened before the incident" does not begin when the process did.

audit_storeWhat you get
database (default when the application has a database)the durable trail described here
memorythe process-lifetime ring — bounded by audit_max_size, cleared by a restart, private to each replica

An application with no database handle gets the ring either way, and so does one whose table cannot be created: the panel logs a warning and keeps working rather than refusing to start.

The listing (GET /api/audit) says which of the two it is serving (persistent), so an empty page is never mistaken for "nothing happened".

Retention​

audit_retention_days drops entries older than that many days — a period, which is what a compliance window is. (audit_max_size is a count of entries and bounds the in-memory ring only.) Zero keeps entries until somebody clears the log. The window is applied when the panel comes up and at most hourly afterwards, on the writing path — there is no background sweeper to start, stop or leak.

An operator can read the policy and change the window in effect from the panel:

GET /api/audit/retention → {"retention_days": 30, "configured_retention_days": 90, "store": "database", …}
PUT /api/audit/retention {"retention_days": 30}

A change made this way applies immediately and is audited (audit.retention.set); it does not rewrite the application's configuration, so a restart comes back to configured_retention_days, which the payload carries for exactly that reason.

Export​

GET /api/audit?format=csv streams the trail as a CSV file, carrying the same filters as the listing — what you export is what you were reading. The export is itself recorded (audit.export, with the filters and the number of entries): who took a copy of the log is the kind of thing the log is for.

The history of one record​

GET /api/models/{model}/{id}/history is the same trail read by record: what this row said before, and who changed it. It is gated by the record's own permission (retrieve), not by audit_view — and the row scope and field permissions of that grant apply, so a history never shows a row the operator cannot open or a field they may not read.

It goes back as far as the trail does, which the payload states (persistent, retention_days) so a short history is read as a retention window rather than as a quiet one.

If you want the trail written in the same transaction as the change itself, that belongs at the data layer: applications on the Quark ORM can enable its transactional quark_audit log (EnableAuditLog). The panel's trail complements it — it also covers panel-only actions (logins, session terminations, tenant switches recorded as tenant.override with the requested tenant as record_id) that never touch a model.

What is recorded​

Every write the panel performs leaves an entry, recorded by the handler that performed it. The action names the operation:

SurfaceActions
Data Studiocreate, update, delete (each with the record's values before and/or after the change), bulk_delete (one summary plus one delete per row), bulk_export, export.csv, schema.update (field metadata edits, with the fields before and after)
Access controlrbac.policy.add, rbac.policy.remove, rbac.role.assign, rbac.role.remove
Feature flags and jobsflag.create, flag.set, flag.delete, jobs.queue.<action>
Operationsmigration.apply, cache.flush, live.exclude.add, live.exclude.remove, audit.clear (the one entry that survives the clear)
The trail itselfaudit.export (a CSV copy was taken, with the filters and the count), audit.retention.set (the window in effect changed, with the old and new values)
Filesfield.upload (a file was stored for a model's field, with the key it was stored under)
Saved viewsview.create, view.update, view.delete (with the name, the query and whether it is shared)
Data managementexport.create, fixtures.dumpdata, import.upload, import.validate, import.execute, fixtures.loaddata — exports are recorded whether they completed or failed
Sessionslogin, login.failed, login.locked, logout, session.terminate, session.revoke_all (the user as the record, with how many sessions were ended and whether the caller's own was kept)
Tenant scopetenant.override (an accepted ?tenant= switch, with the requested tenant as record_id; a refused switch leaves no entry)

old_value and new_value are redacted before they are stored, because the log is readable by any operator with audit_view: fields the model excludes from Data Studio and credential-shaped names (password, secret, token, hash, salt…) appear as [redacted], string values longer than 4 KB are truncated, a Redis URL loses its password, a session token is shortened, imports and exports record counts rather than rows, and login entries carry the attempted username, never the password.

Entries are not filtered by tenant. With multitenant_enabled, an operator granted audit_view reads every entry the ring holds — the redacted old and new values of rows written by other tenants' operators, and every tenant.override — not only the entries of the tenant the request resolved to.

Entries are bounded as well as redacted. The user id, username, model, record id and client IP are cut at 256 bytes and the User-Agent at 512, with a …[truncated] marker on anything cut. The login route is the one place an anonymous client writes to the log, so it is bounded twice more: the panel caps the login POST body at 16 KB (whichever layer parses the form first, the entry a failed attempt leaves is cut to the field bounds above), and per client IP and lockout window (one minute) the log keeps at most 10 login.failed entries and one login.locked — the lockout keeps answering 429 for the rest of the window without adding entries, and a successful login from that IP starts the count again. The client IP is the full remote address: a client that rotates source addresses, as an IPv6 /64 allows, is bounded per address, not per client. A single address can neither inflate the ring's memory nor push the entries recorded before its attempts out of it.

GET /api/audit pages newest first; total and total_pages count the entries that match the user_id, model and action filters, so a filtered listing does not page into nothing. POST /api/audit/clear answers {"cleared": true, "dropped": <n>}.

Overview & Health​

A dashboard summarizing the above, plus a health-at-a-glance view.

Runtime operations​

Three operations endpoints report what is happening, not how they were configured.

The cache, as the application has it​

Nothing in the framework owns an application's cache — pkg/cache is a library you build with, not a service the app wires — so the panel cannot discover one. An application that wants its cache on the panel declares it:

orbit.Module(orbit.Config{
// ...
Cache: myCache, // implements orbit.Cache
})
// orbit.Cache
type Cache interface {
CacheName() string
CacheEntries(ctx context.Context) (count int64, known bool, err error)
FlushCache(ctx context.Context) (removed int64, err error)
}

GET /admin/api/cache then reports that cache by name with its entry count, and POST /admin/api/cache/flush empties it and records an audit entry. A backend that cannot count answers known=false, and the view shows the cache without a count rather than a zero that would read as empty.

The panel never reads or writes entries through this contract. Counting and emptying is the whole of what an operator does to a cache from a screen, and a contract that could also read entries would put cached values behind a panel permission that was never meant to cover them.

Three postures, and the view says which one it is in:

kindwhencan_flush
declaredthe application passed Config.Cacheyes
redisno declared cache, redis_url configuredwhen Redis answers
noneneitherno

An application with no cache is told there is none. It used to be told "redis url is not configured", which reads as a setting somebody forgot to fill in; the flush button is now withheld rather than offered and refused. Declaring a cache takes precedence over redis_url — it is the application's own statement about what its cache is.

Mail delivery, not mail configuration​

GET /admin/api/email still reports the driver, the sender address and the provider, and adds the two things you look at when a message did not arrive:

  • health — the configured sender's own liveness check, when it has one. An SMTP host can be spelled correctly in the config and refuse every connection, so a driver that reports healthy is a different claim from a driver that is configured. A driver that cannot be probed says so (checked: false with a reason) rather than passing for healthy.
  • delivery — the outbox: queued, processing, delivered and failed, the oldest message still pending and the last one delivered. An application with no outbox gets enabled: false and the reason, instead of zeros that would read as "nothing pending".

delivery.scope is part of the payload because the counts are of the WHOLE outbox, every topic, and mail is one topic in it (delivery.topic, today nucleus.mail). An application that also queues webhooks would otherwise read "4 pending" on the email screen as four unsent emails.

Migrations with nothing to list​

An application that ships no migrations directory gets an empty list, available: false and the reason. It used to get a 500 with the migrator's error. A path that exists and is not a directory is still an error — a misconfigured migrations_path read as "nothing to apply" would stay hidden until a deploy needed the migrations that were never listed.

/api/* answers JSON​

An unrouted path under the panel's API prefix returns 404 JSON. It used to reach the single-page fallback, so a client asking for an endpoint that does not exist got 200 text/html and no way to tell the difference between "no such endpoint" and "here is a web page". A path that IS served under another method still returns 405, which is a different statement from 404.

What your application adds to the panel​

The panel's verbs are the ones every table has, and its screens are the ones every application has. Three contracts let an application add the ones that are only its own — its verbs, its screens and its own code in the browser — without forking the panel. All three are Go-only wiring: they carry functions or files, so there is nothing for nucleus.yml to bind.

An action of your own, on your own model​

orbit.Module(orbit.Config{
// ...
Actions: []orbit.ModelAction{{
Name: "publish", // the verb, and the RBAC action
Model: "Post",
Label: "Publish",
Confirm: "Publish the selected posts?",
Destructive: true,
Run: func(ctx context.Context, req orbit.ActionRequest) (orbit.ActionResult, error) {
n, err := publishPosts(ctx, req.IDs) // your code, your database
if err != nil {
return orbit.ActionResult{}, err
}
return orbit.ActionResult{
Message: fmt.Sprintf("%d post(s) published", n),
Affected: n,
}, nil
},
}},
})

The grid draws the button next to Delete and Export once rows are selected, asks the Confirm question when there is one, and shows the Message your function returned. On the wire it is the bulk endpoint the selection already uses: POST /admin/api/models/Post/bulk with {"action":"publish","ids":[…]}.

What the panel supplies around your function:

  • Authorization. The verb IS the permission: p, editors, admin:Post, publish. An operator without it gets a 403 and your function is never called, and the action is not offered in the schema a screen renders from.
  • Confinement. The ids you receive are the ones this operator may touch. A grant of admin:Post#own confines an action exactly as it confines a delete, and a multi-tenant panel confines it to the request's tenant; rows outside come back to the client as per-id failures. A selection that is refused whole never reaches your function — it answers ran: false — so "no ids" always means the same thing as AllowEmptySelection.
  • The audit entry, recorded as action.<name> with the model, the ids and what you reported, whether your function succeeded or returned an error. An action that failed halfway still touched rows, and only the trail can tell that from one that never started.
  • The operator's name, in req.Actor, so your own log line names the same person the trail does.

An error you return reaches the operator as the reason ("publish needs a publication date"), because this is a console and a generic failure is worth less there.

Set AllowEmptySelection: true for an action whose subject is the table rather than a selection ("rebuild the index"); its button is then always there. Destructive: true marks it dangerous in the UI and makes it refuse a read-only model, which is what read-only means.

A declaration the panel cannot honour stops the application at startup: an unknown model, a duplicate verb, one of the panel's own verbs (delete, export, create, update, …) or a missing Run. Each of those would otherwise be a button that silently never appears.

An action that asks before it runs​

Some actions need one more thing from the operator — the reason for a refund, the date to publish on, the channel to notify. Declare it next to the verb, and the grid asks for it in a form instead of the plain confirmation:

orbit.ModelAction{
Name: "schedule",
Model: "Post",
Label: "Schedule",
Fields: []orbit.ActionField{
{Name: "reason", Label: "Reason", Required: true, Help: "Recorded with the audit entry"},
{Name: "priority", Label: "Priority", Type: orbit.ActionFieldNumber},
{Name: "notify", Label: "Notify subscribers", Type: orbit.ActionFieldBoolean},
{Name: "channel", Label: "Channel", Type: orbit.ActionFieldSelect, Required: true,
Options: []orbit.ActionOption{{Value: "web", Label: "Website"}, {Value: "email", Label: "Email"}}},
{Name: "publish_on", Label: "Publish on", Type: orbit.ActionFieldDate, Required: true},
},
Run: func(ctx context.Context, req orbit.ActionRequest) (orbit.ActionResult, error) {
day, _ := req.Input.Date("publish_on") // a time.Time, midnight UTC
channel := req.Input.String("channel") // "web" or "email", nothing else
n, err := schedulePosts(ctx, req.IDs, day, channel, req.Input.String("reason"))
if err != nil {
return orbit.ActionResult{}, err
}
return orbit.ActionResult{Message: fmt.Sprintf("%d post(s) scheduled", n), Affected: n}, nil
},
}
Typethe form drawsRun receives
ActionFieldText (the default)a text inputstring
ActionFieldNumbera number inputfloat64
ActionFieldBooleana checkboxbool, always present, false when unticked
ActionFieldSelecta list of Optionsthe option's Value, a string
ActionFieldDatea date pickertime.Time at midnight UTC

The server decides what is valid, not the form. What is posted ({"action":"schedule","ids":[…],"input":{"reason":"…","channel":"email",…}}) is checked against the declaration before a row is read and before Run is called, and a refusal never reaches your function. It is a 422 that names every field at fault in one answer:

{"error": {"code": "VALIDATION_FAILED",
"message": "Schedule: channel must be one of web, email; reason is required",
"details": {"channel": "must be one of web, email", "reason": "is required"}}}

The form shows each message on the input it names. A key you did not declare is refused the same way ("is not a field of this action"). An optional field left empty is absent from req.Input rather than a zero you cannot tell from a real one, and Required on a boolean means the box must be ticked ("I understand this cannot be undone"). What passed is recorded with the audit entry under input, dates as the day picked.

A field the panel cannot draw stops the application at startup, naming the action and the field: an unknown Type, a select with no options, an option with no Value or declared twice, options on a field that is not a select, or a name that is not a letter followed by letters, digits, _ or -.

An action without Fields is unchanged: it keeps the Confirm question, and an input posted to it is ignored.

An action on one record​

"Refund this order" and "open the reconciliation of this batch" are about one record, and the record view is where an operator is when they decide to do them. Placement says where the panel offers an action:

Placementofferedruns on
ActionOnSelection (the default)the grid's toolbar, once rows are selectedthe selection
ActionOnRecordthe record view, and the menu on the record's rowthat one record
ActionOnSelectionAndRecordbothwhichever it was started from
orbit.ModelAction{
Name: "refund",
Model: "Order",
Label: "Refund",
Placement: orbit.ActionOnRecord,
Fields: []orbit.ActionField{{Name: "reason", Label: "Reason", Required: true}},
Run: func(ctx context.Context, req orbit.ActionRequest) (orbit.ActionResult, error) {
// req.IDs is exactly one id: the record the operator was looking at.
return refund(ctx, req.IDs[0], req.Input.String("reason"))
},
}

An action declared without a Placement is offered on the selection, where every action was before placements existed, and behaves exactly as it did.

On the wire a record action has its own endpoint, POST /admin/api/models/Order/actions/refund/{id}, with {"input":{…}} when it declares fields. It runs through the same code as the bulk endpoint — the verb is the permission, the record is confined to the operator's tenant and rows, the input is checked before Run — over that one id. A record that is not there, or that the operator may not reach, is the 404 the record view would give them, and your function is not called.

The placement is enforced, not only drawn: a record action posted to the bulk endpoint is refused, so it is never handed fifty ids it was not written for, and a selection action posted to the record endpoint is refused too. The 400 says where the action is offered.

The audit entry of a record action names the record, so its history (GET /admin/api/models/{model}/{id}/history) shows what was done to it; the entry's on says which place the action ran from, record or selection.

The row's menu is the way to a record action for an operator who may run it and may not edit the record — the record view is the edit form, which they are not offered. When they open a record through a link, they are shown it read-only.

A Placement the panel does not know stops the application at startup, and so does AllowEmptySelection on an action offered only on a record, which always has one.

An action that answers with a page or a file​

An action answers in one of three ways:

the action returnsthe operator getsthe audit entry records
Message (and nothing below)the message, as beforeresult: "message"
Redirect: "/data-studio?model=Order&record=42"that page of the panel, after the messageresult: "redirect" and the path
Download: &orbit.ActionDownload{…}a file to saveresult: "download", the file's name, type and size
// Copy the order and open the copy.
return orbit.ActionResult{
Message: "order copied",
Redirect: fmt.Sprintf("/data-studio?model=Order&record=%d", copyID),
}, nil

// Hand back the invoice as a PDF.
return orbit.ActionResult{Download: &orbit.ActionDownload{
Filename: fmt.Sprintf("invoice-%s.pdf", req.IDs[0]),
ContentType: "application/pdf",
Body: bytes.NewReader(pdf),
}}, nil

A redirect is a page of the panel, and nothing else. It is a path relative to the panel's root — /data-studio?model=Order&record=42, not /admin/data-studio… — and the panel refuses, when the action answers, anything that would take the browser off it: an absolute URL, a scheme, a host, a path that starts with //, a backslash, a control character, or a . or .. segment, encoded or not. A redirect an operator's input can steer is how a panel becomes an open redirect, so the check is made on every answer and not on the declaration. The single-page app follows a redirect to one of its own screens without reloading the page; /data-studio?model=…&record=… opens that record's view. A redirect to a screen of your own (/x/<id>/…) loads it.

A download is the bytes your action produced. ActionDownload carries a name, a media type and a body — never a path: the panel does not open a file on an action's behalf, so there is no file it can be steered into reading. It is sent as an attachment with:

  • Content-Disposition: attachment, carrying only the last element of Filename (reports/q3.pdf is saved as q3.pdf), as an ASCII fallback and as the exact UTF-8 name;
  • the ContentType you declared, which is required — the panel does not guess what a file is — with X-Content-Type-Options: nosniff;
  • Cache-Control: no-store and a Content-Security-Policy of default-src 'none'; sandbox, so a file opened from the browser's downloads is not a page of the panel with the operator's session.

The panel reads the body before it sends a byte, up to 32 MiB, and closes it when it is an io.Closer. A body over that is refused whole rather than sent truncated: a file cut short looks like a file. Message is not shown with a download — the file is the answer.

An answer the panel will not send — a redirect out of the panel, a download with no name or no type or over the ceiling, or a redirect and a download at once — is refused after your function ran. The operator gets a 500 (ACTION_ANSWER_REFUSED) whose message says the action ran and its answer was refused, so nobody runs it twice thinking it did nothing, and the audit entry records the attempt with the error.

Each answer works from either placement: a selection can be exported as one file, and a record can be opened after it was changed.

A screen of your own​

orbit.Module(orbit.Config{
// ...
Pages: []orbit.Page{{
ID: "reconciliation", // served at /admin/x/reconciliation/
Title: "Reconciliation",
Handler: myReportHandler, // an ordinary http.Handler
}},
})

The page is listed in the panel's navigation (GET /admin/api/ui/extensions) and served under the panel's prefix, behind the panel's session. Your handler sees its own root — / is the page, /monthly is a sub-path of it — so it builds links without knowing where the panel is mounted, and it can read who is looking at it:

operator, ok := orbit.OperatorFromContext(r.Context())

Authorization is view on admin:page:<id>, in its own namespace so a grant about a model can never open a screen. A page an operator may not open is not in their navigation at all: a link that refuses is a worse answer than no link.

A page is a link, not a frame. The panel sends X-Frame-Options: DENY and frame-ancestors 'none' on every response, so a screen embedded in the single-page app would be blocked by the browser — and relaxing that header for the whole panel to embed one screen would trade a clickjacking defence for a layout. The panel's Content-Security-Policy applies to your page too, so its scripts come from files rather than inline <script>.

Code of your own in the browser​

//go:embed panel
var panelFiles embed.FS

func adminPanel() nucleus.ModuleSpec {
files, err := fs.Sub(panelFiles, "panel") // or os.DirFS("panel")
if err != nil {
panic(err)
}
return orbit.Module(orbit.Config{
// ...
Client: orbit.ClientCode{
Files: files,
Scripts: []string{"money.js"},
Stylesheets: []string{"money.css"},
FieldRenderers: []string{"money"}, // what money.js registers
},
FieldWidgets: map[string]string{
"Invoice.Total": "money", // drawn by your renderer
},
})
}
// panel/money.js
(function () {
var orbit = window.orbit
if (!orbit || orbit.version !== 1) return
var euros = new Intl.NumberFormat('en', { style: 'currency', currency: 'EUR' })
orbit.registerFieldRenderer('money', function (value, context) {
var amount = document.createElement('span')
amount.className = 'money'
amount.textContent = euros.format(Number(value) / 100)
return amount
})
})()

The panel serves your files under its own prefix and its own policy. It reads each declared file once, when it mounts, and serves those bytes at /admin/client/<path>, behind the panel's session. The panel's document names them at the end of its <head>, after its own bundle — a stylesheet as a <link>, a script as a deferred <script>, so it runs once the panel's code has — each with an integrity attribute carrying the digest of the bytes the panel read, so the browser runs or applies exactly those. The Content-Security-Policy does not change: script-src stays 'self', and your files are 'self'. Inline script stays refused, so your code comes from files. A file in Files you did not declare is not served, and the login screen loads none of them. With nothing declared, nothing changes.

Your scripts run in the operator's session, like the panel's own code: they can call the panel's API with exactly the rights the operator holds, so declare only code you would ship in the panel itself.

What is checked when the panel mounts. A path is inside Files: no leading /, no . or .. segment, no backslash, and only letters, digits, ., - and _ in each segment; a script ends in .js and a stylesheet in .css. A file that cannot be read, a path declared twice, files with no Files to read them from, a renderer name that is not lowercase letters, digits and dashes, a renderer named like one of the panel's own widgets (json, richtext, file, image) and a renderer with no script declared to register it each stop the application, with a message naming the entry. A field_widgets value that names neither one of the panel's widgets nor a renderer you declared stops it too.

window.orbit, version 1. The panel's code installs it, read-only, before your scripts run:

memberwhat it is
version1. A later version changes what a renderer receives or returns; a script that needs one checks it first.
registerFieldRenderer(name, render)Registers the renderer field_widgets names. A second registration under one name replaces the first; a name that is not lowercase letters, digits and dashes, or a render that is not a function, throws.

render(value, context) draws one value and returns a DOM node, or a string, which is drawn as text and never parsed as markup. context carries the model, the field, its column, a read-only copy of the record and where it is drawn: "list" or "record".

Where a renderer draws. In the Data Studio list, and on the record view: above the input that edits the field, and in place of the value when the record is shown read-only. A renderer draws a value; it does not edit one. The form keeps the panel's own input for the field's type, so a field drawn by a renderer is edited as its column says (a JSON document keeps its JSON editor). One field takes one field_widgets value, so a file or rich-text field cannot also have a renderer.

When a renderer fails, the value pays, not the screen. A renderer that throws, or returns anything but a node or a string, leaves that one value drawn the panel's way, with a line in the same place that says the renderer failed and why; the browser console has the rest. A renderer the schema names that no script registered draws the panel's way, with a warning in the console. A renderer registered after the panel drew is applied when it registers.

The panel in your product's clothes​

Three more things an application declares, so the panel it opens every day looks and reads like the product it belongs to. Branding and the locale are plain configuration and bind from nucleus.yml; the widgets carry functions, so they are wired in Go.

Branding​

orbit.Module(orbit.Config{
// ...
Branding: orbit.Branding{
LogoURL: "/static/acme-logo.svg", // or https://cdn.example/logo.svg
FaviconURL: "/static/acme.ico",
PrimaryColor: "#0b5fff",
},
})
modules:
orbit:
branding:
logo_url: /static/acme-logo.svg
primary_color: "#0b5fff"

The logo replaces the wordmark in the sidebar and appears on the login screen — the page an operator sees before they are anybody, and the one that says whose product this is. The colour lands in the custom property the stylesheet already reads, so it colours the buttons, the active navigation entry and the focus ring together.

Three things the panel decides for you:

  • The text drawn on your colour. A brand colour is chosen to look like a brand, not to contrast with white, so the panel draws whichever of white or dark text reads better on it. White on a yellow button is a contrast failure the panel would otherwise have introduced on your behalf.
  • What a URL may be. An absolute http(s) URL or a path your application already serves. A javascript: or data: URL would be script execution on every page of the panel, granted by a line of YAML, so it is refused at startup rather than escaped — and so is a "colour" that is not a hex colour.
  • That the browser loads it. The panel sends a strict Content-Security-Policy, and an absolute logo or favicon URL adds its origin (scheme, host and port) to the policy's img-src, and nothing else. A path your application serves needs nothing, and must be reachable before sign-in: the login screen draws it. A host the policy cannot name — an IPv6 address, a wildcard — is refused at startup, since the browser would never load it.

The theme it opens in, and a palette for each​

modules:
orbit:
branding:
theme: dark # dark, light or system
primary_color: "#1d4ed8"
dark:
primary_color: "#60a5fa"
surface_color: "#111827"
text_color: "#e5e7eb"
Branding: orbit.Branding{
Theme: "dark",
PrimaryColor: "#1d4ed8",
Dark: orbit.Palette{
PrimaryColor: "#60a5fa",
SurfaceColor: "#111827",
TextColor: "#e5e7eb",
},
},

The theme decides the first frame. A control room whose screens open dark is a configuration, not a preference each operator rediscovers. The document carries the theme and loads, ahead of the panel's bundle, a small script from the panel's own origin that applies it before the browser has anything to paint — so the page never appears in one theme and switches to the other, and the Content-Security-Policy's script-src stays 'self'. system follows the operator's system preference each time the panel opens.

The operator's choice wins. The theme toggle records what the operator chose, and that choice wins over the configured theme on every reload. A theme kept by a panel from before this setting existed does not count as a choice: the panel used to store the browser's preference on every first visit, so it cannot tell one from the other, and honouring it would make the setting invisible to everyone who has opened the panel before. Leave theme unset and nothing changes: the panel opens in the operator's last theme, or else the browser's preference, as it always has.

Each theme has its own palette, checked against its own ground. A dark navy accent reads on white and is close to invisible on the dark surface; one colour for both themes is how that happens. light and dark each take an accent, a surface and a text colour, and every one you set is checked when the panel mounts, against the colours that theme will actually draw with — yours where you set them, the panel's where you did not:

pairneeds
your text on your surface4.5:1
the panel's secondary text on your surface4.5:1
the accent against the surface3:1
the text the panel draws on the accent4.5:1

A pair that falls short stops the application, and the message names the theme, the keys and the ratio. The one exception is primary_color: it was accepted on any hex colour before the palette was checked per theme, so a value that falls short in one theme still starts, and the panel logs a warning that names the theme and the per-theme key that fixes it.

The palette is written into the document as the custom properties the panel's stylesheet already reads, one rule per theme, so the first frame is in your colours too. The values are numbers the panel computed from colours it parsed; nothing is copied into the stylesheet as typed.

The overview's cards​

orbit.Module(orbit.Config{
// ...
Widgets: []orbit.Widget{{
ID: "pending-orders",
Title: "Orders awaiting review",
Description: "Placed but not yet approved",
Link: "/data-studio",
Permission: "view", // on admin:dashboard
Load: func(ctx context.Context) (orbit.WidgetValue, error) {
n, err := countPendingOrders(ctx)
if err != nil {
return orbit.WidgetValue{}, err
}
return orbit.WidgetValue{
Value: strconv.Itoa(n),
Detail: "oldest: 3 days",
}, nil
},
}},
})

The cards appear on the panel's overview, above its own numbers. Value is a string because your application knows how to format its own numbers — a panel that formatted them would have to be told the currency, the locale and the precision to get them wrong in three ways. Fill Items instead for a short list rather than a single figure.

What the panel does around your function:

  • Authorization. Permission is the RBAC action on admin:dashboard, so "this role sees the finance numbers" is a policy and not a fork. A card an operator may not see is not in their payload at all.
  • A bound. Each card gets three seconds and is loaded concurrently with the others: the overview is a glance, and one slow report must not be what makes it feel broken.
  • Degradation. A card that fails — or panics — is drawn saying it could not be read. Dropping it would report a broken query as "nothing to see".

Cards of every kind​

A card's Kind says what it draws, and each kind reads from a function of its own type — so what your function returns is what the card can draw, and you write no frontend code for any of them. A card with no Kind is the value card above, read from Load.

Widgets: []orbit.Widget{
{
ID: "revenue", Title: "Revenue this month",
Kind: orbit.WidgetStat,
Stat: func(ctx context.Context) (orbit.StatValue, error) {
return orbit.StatValue{
Value: "$1.2M",
Delta: "+12% on last month",
Trend: "up", // up, down or flat: the arrow
Sentiment: "good", // good, bad or empty: the colour
}, nil
},
},
{
ID: "signups", Title: "Signups per day",
Kind: orbit.WidgetLine, // or orbit.WidgetBar
Series: func(ctx context.Context) (orbit.SeriesValue, error) {
days, web, mobile, err := signupsLastWeek(ctx)
if err != nil {
return orbit.SeriesValue{}, err
}
return orbit.SeriesValue{
Labels: days, // "Sep 29", "Sep 30", …
Series: []orbit.Series{
{Name: "Web", Values: web},
{Name: "Mobile", Values: mobile},
},
}, nil
},
},
{
ID: "queues", Title: "Deepest queues",
Kind: orbit.WidgetTable,
Table: func(ctx context.Context) (orbit.TableValue, error) {
return orbit.TableValue{
Columns: []string{"Queue", "Waiting"},
Rows: [][]string{{"mail", "214"}, {"billing", "3"}},
}, nil
},
},
{
ID: "new-orders", Title: "Newest orders",
Kind: orbit.WidgetRecords,
Records: orbit.RecordList{
Model: "Order",
Fields: []string{"number", "customer", "total"},
OrderBy: "created_at desc", // the default when the model has created_at
Limit: 5,
},
},
},
kindreadsdraws
(empty)Loada value, a detail line, or a short list
statStata figure, its change as you formatted it, an arrow for the trend and a colour for whether it is good news — the direction does not decide that, since failed payments going up is bad news
line, barSeriesone or more series over the same labels, with a legend when there is more than one
tableTablecolumns and rows of text you formatted
records—the newest rows of one of your models

A chart, a table and a list of records take two columns of the grid by default; set Span to change it.

A records card has no function, on purpose. The panel lists the rows itself, as the operator who is looking, through the same list the grid uses: the tenant they are confined to, the rows a #own grant leaves them, and only the columns their field permissions let them read. A function of yours would return rows none of those policies ever saw. For the same reason, an operator who may not list the model is not shown the card at all.

What the panel checks, and when:

  • At startup. An unknown Kind, a kind without its function (a line with no Series), or a function the kind never reads (a Series on a stat) stops the application, naming the widget — each would otherwise be a card that silently draws nothing, or something other than what you wrote. So does a records card on a model, a field or an order your application does not have, or a Span wider than its screen.
  • On every load. What your function returns has to be something its card can draw: a series with one value per label, at most 8 series and 500 points, every value a finite number; a table whose rows have one cell per column, at most 12 columns and 100 rows; a trend of up, down or flat. Anything else is that card's error, drawn as a card that could not be read, and never the screen's — a NaN cannot even be written as JSON, so unchecked it would have taken the whole screen with it.

The chart code is loaded only for a screen that has a chart on it: the panel's first load does not carry it, and an overview with no charts never fetches it.

More than one dashboard​

The overview is the screen every operator opens. Readings that belong to some operators and not others — the finance numbers, the support queue — get a screen of their own:

orbit.Module(orbit.Config{
// ...
Dashboards: []orbit.Dashboard{{
ID: "finance", // served at /admin/dashboards/finance
Title: "Finance",
Description: "Revenue, refunds and failed payments",
Columns: 2, // 1 to 4; the overview has 4
Widgets: []orbit.Widget{
{ID: "revenue", Title: "Revenue", Kind: orbit.WidgetLine, Span: 2, Series: revenueByDay},
{ID: "refunds", Title: "Refunds today", Kind: orbit.WidgetStat, Stat: refundsToday},
{ID: "failed", Title: "Failed payments", Kind: orbit.WidgetRecords,
Records: orbit.RecordList{Model: "Payment", Fields: []string{"id", "amount", "reason"}}},
},
}},
})

A dashboard is listed in the navigation of every operator who may open it and is drawn from the same cards as the overview. The cards are drawn in the order you declare them, and that order, Columns and each card's Span are its layout.

  • Authorization. view on admin:dashboard:<id> opens it (p, finance-team, admin:dashboard:finance, view); set Permission to ask for another action. It is a resource of its own: a grant on the overview (admin:dashboard) opens no dashboard, and a grant on one dashboard opens no other. A card's own Permission is an action on its dashboard's resource.
  • What an operator without it sees. Nothing in the navigation, and a 403 from the dashboard's API (GET /admin/api/ui/dashboards/<id>) — so the screen, opened by its address, says they do not have permission rather than showing an empty page. An unknown id is a 404.
  • The overview does not change. Widgets are still its cards, still authorized on admin:dashboard, still ordered by ID; a dashboard's cards never appear on it.
  • A dashboard with no cards stops the application, naming it, like an ID with a slash in it, two dashboards of one ID, or more than 4 columns.

An operator whose role opens a dashboard and nothing else can sign in and use it: the panel does not require access to the model list to treat a session as signed in.

The language​

orbit.Module(orbit.Config{
// ...
Locale: "es",
Messages: map[string]map[string]string{
"es": {"nav.data_studio": "Catálogo"},
},
})

The panel ships its own chrome in English and Spanish. Locale picks the one it opens in — it also sets the document's lang, which is what a screen reader pronounces the page with — and Messages adds to or overrides any phrase, including for a language the panel does not ship.

The catalogues merge rather than replace: your override wins over the panel's translation, the panel's translation over its English, and a phrase nobody translated reads in English rather than as nav.audit. So a five-word correction is five words, and a new language is as complete as you make it.

Translation stops where your application begins. The chrome is the panel's words — navigation, buttons, empty states. A model called Invoice is called Invoice in every language, a field label comes from your struct tags, and an error your code returns is your sentence. A panel that translated those would be translating your data.