Skip to main content
Version: 1.15.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 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. Two contracts let an application add the ones that are only its own, without forking the panel. Both are Go-only wiring: they carry functions, 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.

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

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.

Two 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 computes the foreground from its lightness. White on a pale 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.

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

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.