Admin plugin
Auto-generated CRUD UI for your models, served at /admin/.
umbral-admin mounts a ready-made HTML CRUD interface at /admin/. Every model
that any registered plugin contributed to the migration registry gets list, detail,
create, edit, and delete views - no extra code required.
What you get from AdminPlugin::default()
| Route | What it does |
|---|---|
GET /admin/login | Login form |
POST /admin/login | Authenticate, create session, redirect |
GET /admin/logout | Destroy session, redirect to login |
GET /admin/ | Index - list every registered model |
GET /admin/<table>/ | Row list (paginated, default 25 rows/page, ordered by PK) |
GET /admin/<table>/<id> | Detail view for one row |
GET /admin/<table>/new | Create form |
POST /admin/<table>/new | Insert row |
GET /admin/<table>/<id>/edit | Edit form |
POST /admin/<table>/<id>/edit | Update row |
POST /admin/<table>/<id>/delete | Delete row |
App::builder() .plugin(AuthPlugin::default()) // required: login verifies against auth_user .plugin(SessionsPlugin::default()) // required: login creates a session .plugin(AdminPlugin::default()) .build()?;Mounting under a different base path
The default base is /admin. Override it with .at(...) - every route, redirect, and template link follows the new base. "/backoffice", "backoffice", and "/backoffice/" all normalise to /backoffice:
AdminPlugin::default().at("/backoffice")// → routes mount at /backoffice/login, /backoffice/{table}/, ...AdminPlugin::base_path() returns the normalised value, so other plugins can reference it.
Opt-in features (and what makes them visible)
Several admin features ship with full backend wiring but only appear in the UI when you opt in by configuring the model. If you don't see them, it's because the registration is missing - not because the feature isn't there.
| Feature | Visible when … |
|---|---|
| Inline cell edit (double-click) | AdminModel::new("post").inline_edit_fields(&["title", "status"]) lists the columns. |
| "Change password" button on the edit sheet | AdminModel::new("auth_user").password_field("password_hash") names the hash column. The route + audit-log handler are always wired; this flag is what makes the footer button render. |
| Bulk actions (toolbar appears when ≥1 row is selected) | Default delete-selected ships; custom ones via .actions(vec![Action::new("publish", handler)]). |
| Async FK combobox | Automatic for every ForeignKey<T> field - no opt-in. |
<select> for closed-set enums | Automatic for every #[umbral(choices)] / MultiChoice<E> field - no opt-in. |
| Search box on list view | AdminModel::new("post").search_fields(&["title", "body"]). Without this, the search box is hidden. |
| Filter sidebar | .list_filter(&["status", "published_at"]). Each column gets its own facet. |
| Customisable column set on the list view | .list_display(&["title", "status", "published_at"]). Without this, every column shows. |
The admin shell (phase 1)
The admin ships a dark-mode shell matching a production back-office aesthetic:
- Fixed 260px sidebar with a live model search box and model groups, one per plugin. Active models are highlighted with the primary accent. The sidebar is independently scrollable.
- Sticky 56px topbar with breadcrumbs on the left, a
⌘Kcommand-palette placeholder in the center, and a theme toggle + user menu on the right. - Light/dark theme driven by CSS variables.
:rootholds the light palette;.darkoverrides with dark values. Every Tailwind utility maps to avar(--token)reference, so togglinghtml.darkactually flips every color in place - nodark:prefix sprinkled through every template. The toggle persists inlocalStorage. Default is dark. Phase 4 will move this to per-user server-side prefs. - HTMX loaded via CDN so later phases can target and swap fragments without restructuring the shell.
The shell is implemented in templates/base.html and all existing admin templates extend it.
Login flow (HTML form, not Basic Auth)
Phase 1 replaces the old HTTP Basic Auth challenge with a proper HTML form flow:
- Unauthenticated requests to any admin route redirect to
GET /admin/login?next=<path>. GET /admin/loginrenders a login form. If no session cookie is present, a fresh anonymous session is created so the CSRF token has somewhere to live.- The form submits
username,password,csrf_token, andnexttoPOST /admin/login. - The handler verifies the CSRF token against the session, then calls
umbral_auth::authenticate. On success it callsumbral_auth::login_with_request(which also handles session-fixation defense) and redirects tonext. - On failure, the form re-renders with a generic error message - the wording never reveals whether the username or the password was wrong.
GET /admin/logoutdestroys the session and redirects to/admin/login.
The next parameter is validated server-side: only paths starting with /admin are accepted; //evil.com/ and absolute URLs are silently replaced with /admin/.
Model discovery - nothing to register
The admin walks umbral::migrate::registered_plugins() at request time and
discovers every model every plugin declared via Plugin::models(). You do not
register models explicitly with the admin. Add a plugin, its models appear in the
admin automatically.
Auth gating
Every admin route requires a valid session cookie carrying an is_staff = true user. Missing or expired sessions redirect to /admin/login?next=<path>. Non-staff authenticated sessions receive 403 Forbidden.
// Create a staff user (e.g. in a management command or seed script).// create_superuser sets is_staff = is_superuser = true; create_user_with_flags// lets you pick a staff-but-not-superuser shape. Both go through the ORM.let admin = umbral_auth::create_superuser("admin", "admin@example.com", "secret").await?; // Staff-only (no superuser) editor account:let editor = umbral_auth::create_user_with_flags( "editor", "editor@example.com", "secret", /* is_staff */ true, /* is_superuser */ false,).await?;The simplest path is the createsuperuser CLI command, which calls create_superuser for you.
Form widgets
The admin dispatches HTML input types by SqlType. Nullable fields omit the
required attribute; the PK column is hidden from create/edit forms.
Model display name and sidebar icon
Two attributes on the model struct control how it appears in the admin sidebar - no separate registration required.
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, Model)]#[umbral(plugin = "auth", display = "Users", icon = "users")]pub struct AuthUser { pub id: i64, pub username: String, // ...}| Attribute | Default | Effect |
|---|---|---|
display = "Users" | Struct name ("AuthUser") | Sidebar label for this model |
icon = "users" | "database" | Lucide icon slug shown beside the label |
The values flow through Model::DISPLAY and Model::ICON into ModelMeta and from there into the auto-discovery path of AdminRegistry::apps(). An explicit AdminModel::label(...) or AdminModel::icon(...) call on a registered AdminModel overrides the struct-level defaults.
Any valid Lucide icon name works. Unknown names are silently ignored by Lucide's runtime.
The AdminModel registration shape
AdminModel (renamed from AdminConfig in phase 1 - the old name is kept as a type alias) is the admin equivalent of REST's ResourceConfig - one config per model, registered on the plugin before it is passed to App::builder().
use umbral_admin::{Action, ActionInvocation, ActionResult, AdminModel, AdminPlugin, ToastLevel}; let admin = AdminPlugin::default() .register( AdminModel::new("post") .list_display(&["title", "author", "published_at"]) .list_filter(&["published", "author"]) .search_fields(&["title", "body"]) .ordering(&["-published_at", "title"]) .readonly_fields(&["created_at"]) .actions(vec![ Action::delete_selected(), Action::new("publish", "Publish", "send", |inv: ActionInvocation| async move { Ok(ActionResult::Toast { message: format!("Published {} post(s).", inv.ids.len()), level: ToastLevel::Success, }) }), ]), ); App::builder() .plugin(AuthPlugin::default()) .plugin(admin) .build()?;Registering many models at once
As with REST's .resources(...), keep each app's admin configs next to its models and register them in one call. .register_many(iter) is the batch form of .register(...); .register_for_many(plugin_name, iter) is the batch form of .register_for(...) (groups the models under that plugin's sidebar section).
// plugins/blog/src/lib.rs - the blog app's admin surfacepub fn admin_models() -> Vec<umbral_admin::AdminModel> { vec![post_admin(), comment_admin(), tag_admin()]} // main.rsAdminPlugin::default() .register_many(blog::admin_models()) .register_many(shop::admin_models());Each accepts any IntoIterator<Item = AdminModel> and is equivalent to calling .register(...) once per item.
list_display
.list_display(&["title", "author", "published_at"])Picks which columns appear on the list view, in this exact order. Default (no call): all columns in declaration order.
list_filter
.list_filter(&["published", "author"])Each named field becomes a filter facet in the right sidebar. Clicking a value adds ?filter_<field>=<value> to the URL and narrows the list. Values are the distinct non-null values currently in that column (booleans show 0/1 from SQLite storage; text columns show their distinct strings).
search_fields
.search_fields(&["title", "body"])Enables a search box at the top of the list view. The ?q= query parameter is matched via LIKE %term% across all listed columns, OR-ed together and AND-ed with any active filter.
ordering
.ordering(&["-published_at", "title"])Default sort order for the list view. Each entry is a column name optionally prefixed with - for descending. Without this call the list orders by primary key ascending.
actions
.actions(vec![ Action::delete_selected(), Action::new("publish", "Publish", "send", |inv: ActionInvocation| async move { // inv.ids: Vec<i64> - the selected primary keys // inv.username, inv.table, inv.pool - context Ok(ActionResult::Toast { message: format!("Published {} post(s).", inv.ids.len()), level: ToastLevel::Success, }) }),])Registers bulk actions. Action::new(key, label, icon, handler) takes a Lucide icon name and an async handler receiving an ActionInvocation (selected ids, plus username / table / pool context) and returning a Result<ActionResult, String>. Action::delete_selected() is the built-in bulk-delete action registered by default. The full descriptor surface (.scope(...), .danger(), .confirm(...), and the ActionResult variants) is documented under Phase 3 below.
Trash UI for soft-delete models
Register a model tagged #[umbral(soft_delete)] and the admin grows a full trash workflow automatically - no extra config on AdminModel:
- The changelist header gains a Trash / Active toggle (with a trashed-row count badge).
?trash=1lists only soft-deleted rows; the default view shows the live set, which already excludes trashed rows. - The default
Action::delete_selected()soft-deletes on a soft-delete model - it moves rows to the trash (setsdeleted_at) rather than purging them, because it routes throughDynQuerySet::delete(). - In the trash view, the row and bulk affordances become Restore selected (clears
deleted_atviaDynQuerySet::restore()) and Delete permanently (a realhard_delete()behind a confirm dialog - the row leaves the table entirely and cannot be restored).
Restore is gated by the change_<model> permission; Delete-permanently by the stronger delete_<model>. A model without #[umbral(soft_delete)] is unchanged: no toggle, no restore, and delete_selected hard-deletes as before.
#[derive(umbral::orm::Model)]#[umbral(soft_delete)]struct Note { id: i64, #[umbral(string)] title: String, deleted_at: Option<chrono::DateTime<chrono::Utc>>,} // Registration needs nothing special - the trash UI is automatic.AdminModel::new("note") .list_display(&["title"]) .actions(vec![Action::delete_selected()]) // soft-deletes for this modelreadonly_fields
.readonly_fields(&["created_at", "id"])Fields that appear on the create and edit form but render as <input readonly> so they cannot be changed. The field is visible to the operator but excluded from the INSERT and UPDATE SQL.
Deferred surfaces
Phase 2+ only. The following fields are stored on AdminModel but have no rendering path in phase 1:
inlines- inline related-model editors (InlineModel { model, fk_field, list_display }). Phase 2 renders them as collapsible sections on the edit form.list_per_page- stored (default 25) but pagination is not yet rendered. Phase 2 adds the full paginator.fieldsets- grouping fields into named sections on the edit form. Lands in phase 2 as.fieldsets(vec![...]).prepopulated_fields- slug autofill from another field. Phase 3.raw_id_fields- FK input as a raw integer. Phase 3.
Phase 2: changelist + sheet
Phase 2 ships the three reusable primitives that drive every model list view: the DataTable, the right-side Sheet, and the field-editor catalog.
DataTable
The data_table macro (templates/_macros/data_table.html) drives every model changelist. What it does:
- Header bar. Model name, live record count, and an "Add
<Model>" button that opens the create sheet. - Toolbar. Search input (HTMX-debounced, 300ms), filter panel toggle, column-visibility menu, density toggle (comfortable/compact).
- Sortable columns. Clicking any header sends a new
?sort=col&order=asc/descrequest that HTMX-swaps only the<tbody id="table-body">. Active sort shows a chevron; neutral shows a dimmed double-chevron. - Filter chips. One chip per active filter, each with an
×to clear. Active filters are encoded as?filter=field=value. - Row actions. Three Lucide icons on each row (sticky right column):
eyeopens the preview sheet,pencilopens the edit sheet,trash-2opens the delete confirm dialog. The whole row is clickable (outside buttons/checkboxes) and also opens the preview sheet. - States. Empty (no rows yet - shows a "Create the first one" button), no-results (filters/search returned nothing - shows a "Clear filters" link), and pagination-aware.
- Full pagination. Range text ("1-25 of 1,204"), page-size select (10/25/50/100, default from
list_per_page), prev/next, first/last, windowed page number list. - Selection. Header checkbox selects all on page; per-row checkboxes. Selection state is preserved via
hx-preserveon a hidden#bulk-action-form. The floating bulk-action toolbar appears when any rows are selected (phase 3 adds action descriptors to it). - Responsive. ≥1024px: full table. 768-1023px: low-priority columns hidden via
hidden lg:table-cell. < 768px: stacked card behavior (low-priority columns hidden; action column visible).
AdminModel fields that drive it:
| Field | Effect |
|---|---|
list_display | Which columns appear and in what order |
list_filter | Columns that get filter facets |
search_fields | Columns the search box matches against |
ordering | Initial sort order |
list_per_page | Default page size |
HTMX endpoints added in phase 2
| Route | Returns |
|---|---|
GET /admin/<table>/ | Full changelist page (extends base.html) |
GET /admin/<table>/rows?search=&filter=&sort=&order=&page=&page_size= | Fragment: <tbody> rows + pagination footer row. HX-request → fragment; direct → also fragment |
GET /admin/<table>/<id>/sheet | Fragment: preview sheet. HX-request → fragment; direct → redirect to changelist with ?row=<id> |
GET /admin/<table>/<id>/edit-sheet | Fragment: edit sheet |
GET /admin/<table>/new-sheet | Fragment: create sheet |
GET /admin/<table>/<id>/_confirm-delete | Fragment: delete confirm dialog |
POST /admin/<table>/create | On success: HX-Redirect to refresh changelist. On failure: create sheet with errors |
DELETE /admin/<table>/<id> | HX-Redirect to refresh changelist after delete |
All previous phase-1 routes (/new, /<id>/edit, /<id>/delete, /action) remain working for backward compatibility and full-page fallback.
The Sheet
The sheet macro (templates/_macros/sheet.html) is a floating right-side panel, anchored at top-4 right-4 bottom-4, 640px wide by default.
- Header. Title + record identity + Preview/Edit segmented toggle + close ✕.
- Preview mode. Fields as labelled values. Booleans render as Yes/No pills. Empty values show "-". Numbers use tabular numerals.
- Edit mode. Full form with per-type field editors.
- Footer. In preview: "Edit" button. In edit: Cancel / Save & continue / Save. In edit (non-create): Delete button that opens the confirm dialog.
- Drag-resize. A left-edge drag handle lets users resize the sheet. Width is persisted in
localStorageunderumbral-admin-sheet-width. - Esc to close. Vanilla JS listener inside the rendered fragment.
Field editor catalog
The field_editor macro (templates/_macros/field_editor.html) dispatches per SqlType:
kind | Renders |
|---|---|
text, uuid, network types | <input type="text"> |
number (SmallInt, Integer, BigInt, Real, Double, ForeignKey) | <input type="number" step="any"> |
bool | Styled toggle switch (Tailwind utility-built, no checkbox) |
date | <input type="date"> |
time | <input type="time"> |
datetime-local | <input type="datetime-local"> (value pre-coerced via format_for_input) |
textarea (Json, FullText, Array) | <textarea> + Format JSON button |
Each editor: label row (with * for required, "(nullable)", "(read-only)" badge), the input, inline validation error area.
Markdown image upload
A field rendered with the markdown widget mounts an EasyMDE editor. When a storage backend is mounted (the StoragePlugin media side, or any umbral::storage::set_storage(...) backend), the editor's toolbar gains an image button and you can paste, drop, or pick an image: it uploads to POST {base}/upload-image and the returned URL is inserted into the markdown as .
The endpoint is staff-gated exactly like every other admin route (unauthenticated → login redirect, logged-in-non-staff → 403), validates the part is an image (png, jpeg, gif, webp, svg) under a 10 MiB cap, and stores the bytes through the ambient storage seam - the admin never names a media crate directly. The fetch carries the shared CSRF token from the umbral_csrf_token cookie.
With no storage backend installed the route returns a 409 with a clear JSON error ("image upload requires a storage backend - add StoragePlugin"), which EasyMDE surfaces inline. The editor itself still works - only the image-upload affordance needs a backend. See arch.md and gaps2 #36.
Confirm dialog
The confirm_dialog macro (templates/_macros/confirm_dialog.html) is a centered alert dialog (14px radius, danger styling). Used by trash-2 in the DataTable and the Delete button in the Sheet. Injected into <div id="umbral-dialog-slot">. Escape key and scrim click cancel. Cancel is the default-focused button (danger action is never auto-focused).
Extending the DataTable
To add a custom column type, add an {% elif col.name == "..." %} clause inside the cell-rendering block in rows_fragment.html and data_table.html. To add a custom row action, add it to the AdminModel::actions list and to the action column in the macros (phase 3 makes this declarative).
Phase 3: actions, async FK pickers, sheet stacking, inline cell edit
Action descriptor system
Phase 3 replaces the simple bulk-action shim with a typed Action descriptor. Every action now carries an icon (Lucide), variant, scope, optional confirm message, and returns a structured ActionResult:
use umbral_admin::{Action, ActionInvocation, ActionResult, ActionScope, ToastLevel}; let publish = Action::new( "publish", // URL-safe key "Publish", // Label shown in tooltips / overflow menus "send", // Lucide icon name |inv: ActionInvocation| async move { // inv.ids - selected primary keys // inv.pool - backend-aware `DbPool` (match `Sqlite` / `Postgres` // for escape-hatch raw SQL; prefer the ORM) // inv.username, inv.table - context Ok(ActionResult::Toast { message: format!("Published {} post(s).", inv.ids.len()), level: ToastLevel::Success, }) },).scope(ActionScope::Both) // Row | Bulk | Both.danger() // red styling.confirm("Publish selected posts?") // confirm dialog before firing.permission("blog.publish_post"); // requires this codename to run AdminModel::new("post") .actions(vec![Action::delete_selected(), publish]).permission(codename) restricts the action to users who hold the named codename (or are superusers). The check runs after the model-level change_<model> gate and before the handler. When umbral-permissions is not installed the check is a no-op and any staff user can run the action. Use the same composite "<plugin>.<verb>_<model>" format that PermissionsPlugin auto-creates, or any custom codename you seed.
ActionResult variants and their HTMX encoding:
| Variant | Effect |
|---|---|
Toast { message, level } | HX-Trigger: {"showToast": {...}} header |
RefreshTable | HX-Trigger: {"refreshTable": {}} - JS refreshes #table-body |
OpenSheet { table, id } | HX-Trigger: {"openSheet": {...}} header |
Download { filename, content_type, bytes } | Content-Disposition: attachment response |
Redirect { url } | HX-Redirect header |
Row action column
The DataTable renders the built-in eye/pencil/trash-2 buttons, then up to 2 custom row-scope actions as inline icon buttons. If more than 2 custom actions have scope = Row | Both, a more-horizontal overflow menu lists the rest. Danger-variant actions render in text-error.
Floating bulk-action toolbar
When rows are selected, a pill-shaped toolbar rises from the bottom-center. It shows the selection count, a "Clear" link, and up to 4 bulk-scope actions. Additional bulk actions appear in an overflow menu. Clicking a button fires POST /admin/{table}/actions/{key} with {"ids": [...]} via fetch().
Phase 3 action endpoints
| Route | Purpose |
|---|---|
POST /admin/{table}/actions/{key} | Run an action over { "ids": [...] } (JSON body) |
Async FK picker
ForeignKey fields in create/edit sheets now use a searchable combobox instead of a plain number input. The combobox calls:
GET /admin/api/{table}/{field}/options?search=&page=&page_size=20→ { "items": [{ "value": 1, "label": "My Post" }], "page": 1, "has_more": false } GET /admin/api/{table}/{field}/options/resolve?ids=1,2→ { "items": [{ "value": 1, "label": "My Post" }, ...] }Both endpoints require is_staff. The label comes from the related model's first text column. search matches against the related AdminModel's search_fields (or the first text column as fallback). The resolve endpoint loads pre-selected labels on edit-form open without a round-trip.
M2M chip picker
The field_editor macro also renders a chip multi-select for m2m kind fields. No model uses this yet - the ORM's M2M support is deferred to a later milestone. The UI is wired and ready.
Sheet stacking
Opening "+ Add new" inside an FK picker pushes the current sheet onto a JS stack and loads the create sheet for the related model, offset 40px to the left. The stacked sheet header shows a back chevron to pop. Esc closes the top sheet only. The stack state machine (~80 LOC vanilla JS) lives in wrapper.html.
Inline cell edit
Opt specific columns into double-click inline editing:
AdminModel::new("post") .inline_edit_fields(&["title", "slug"])Double-clicking an enabled cell HTMX-swaps the cell content with a compact inline editor. Saving on blur or Enter POSTs the new value; the cell reverts to read-only on success. Validation errors appear inline as text-error spans.
| Route | Purpose |
|---|---|
GET /admin/{table}/{id}/cell/{field}/edit | Returns the editor fragment for one cell |
POST /admin/{table}/{id}/cell/{field} | Saves new value, returns read-only cell span |
Toast notifications
All action results that produce a Toast trigger a bottom-right notification via the showToast HTMX event. Levels: info / success / warning / error. Auto-dismisses after 4 seconds. The renderer (~50 LOC vanilla JS) lives in wrapper.html.
Phase 4: dashboard widgets, audit log, user prefs, file previews, command palette
Admin-owned tables
The admin plugin contributes two models via Plugin::models(), so their tables are produced by the migration engine like any other plugin's models (no bootstrap DDL):
admin_user_pref- one row per admin user. Stores theme (light/dark/system), density (comfortable/compact), sidebar-collapsed state, and the serialized dashboard layout (JSON).admin_audit_log- one row per write operation. Records actor, action (create/update/delete/action:<key>), model table, object PK, and a short human diff summary.
Run makemigrations then migrate to create them, the same as for your own models.
Server-side user preferences
GET /admin/api/prefs → { theme, density, sidebar_collapsed, dashboard_layout, updated_at }PUT /admin/api/prefs ← { theme?, density?, sidebar_collapsed? }The GET endpoint creates the row with defaults on first hit (theme=dark, density=comfortable, sidebar_collapsed=false). The PUT endpoint accepts a partial JSON object and updates only the fields supplied. Invalid values for theme or density are silently ignored.
The theme toggle in the topbar still writes localStorage as the fast path (no flash on page load), and additionally PUTs to /admin/api/prefs so the preference survives across browsers and sessions.
Audit log recording
admin_audit_log captures mutations the admin itself drives. Every successful admin write writes one row:
| Operation | action | Triggered by |
|---|---|---|
| Full-page form create | create | POST /admin/{table}/new |
| Sheet create | create | POST /admin/{table}/create |
| Full-page form update | update | POST /admin/{table}/{id}/edit |
| Inline cell edit | update | POST /admin/{table}/{id}/cell/{field} |
| Password change | update | POST /admin/{table}/{id}/change-password |
| Delete (form) | delete | POST /admin/{table}/{id}/delete |
| Delete (HTMX) | delete | DELETE /admin/{table}/{id} |
| Per-key action dispatch (incl. bulk delete, custom actions) | action:<key> | POST /admin/{table}/actions/{key} |
| Legacy form action | action:<key> | POST /admin/{table}/action |
The write is fire-and-forget - a failure to insert the row is logged at tracing::error level but does not bubble up to the originating request.
What is NOT in admin_audit_log
The table is intentionally scoped to the admin's CRUD surface. These events are not recorded:
- Logins, logouts, session creation. The 11K rows in
sessioncome fromumbral-sessionsdoing its job; that's its own data store. If you want admin-side login auditing, write a signal handler onumbral_sessions::login_succeededand forward intoadmin_audit_logfrom there. - Direct ORM writes from your own code. Calling
Article::objects().save(...)from a non-admin handler bypasses the admin entirely. If you want those writes audited, hook apost_savesignal or write to the table directly viaumbral_admin::models::log(...). - API endpoints the admin exposes for itself. The palette search, FK picker, and dashboard widget data endpoints are reads; they don't mutate user models and don't log.
For "everything that happens" auditing, build a separate event log fed by signal handlers across umbral-sessions, umbral-auth, and per-model post-save hooks. The admin's audit log is the right datum for "who deleted that row from the admin?" - not for general security telemetry.
Per-object history is available at:
GET /admin/{table}/{id}/historyThis renders an audit timeline as a stacked sheet fragment (HTMX) or a standalone page (direct). Each entry shows actor, action, diff summary, and timestamp. A "History" link in the sheet preview header opens this.
Writing your own audit entries
umbral_admin::models::log(actor_id, action, table, object_id, summary) is pub so handlers outside the admin can append to the same timeline. Useful when an external job mutates a model and you want the change visible to admin users:
use umbral_admin::models; models::log( current_user.id, "import", "article", Some(article.id), &format!("imported {} from CSV row {}", article.title, row_num),).await;action is a free-form string; the admin renders it verbatim in the timeline. Use the create / update / delete shapes when the operation is one of those; use a custom verb ("import", "approve", "merge") for anything else.
Dashboard widget system
GET /admin/ now renders a 12-column widget grid instead of the static model index. Each widget is an HTMX-hydrating placeholder that fetches its data on load.
Registering a widget:
use umbral_admin::{AdminPlugin, Widget, WidgetKind, WidgetDataFn, WidgetPayload, KpiPayload, Span}; AdminPlugin::default() .register_widget(Widget { key: "total_posts", title: "Total Posts".to_string(), kind: WidgetKind::Kpi, default_span: Span { cols: 3, rows: 1 }, permission: None, data: WidgetDataFn::new(|_user| async move { WidgetPayload::Kpi(KpiPayload { value: "42".to_string(), unit: Some("posts".to_string()), delta: Some(12.5), sparkline: None, }) }), })Widget kinds and their payload shapes:
| Kind | Payload |
|---|---|
Kpi | { value, unit?, delta?, sparkline?: [f64] } |
Line | { series: [{ name, points: [{x, y}] }], x_type } |
Bar | same shape as Line |
Table | { columns: [{key, label}], rows: [{}] } |
Feed | { items: [{ actor, verb, object, object_link?, at }] } |
Dashboard API endpoints:
| Endpoint | Purpose |
|---|---|
GET /admin/api/dashboard/catalog | Widgets the user may add (key, title, kind, default_span) |
GET /admin/api/dashboard/layout | User's saved layout JSON (or default) |
PUT /admin/api/dashboard/layout | Save user's layout |
GET /admin/api/dashboard/widgets/{key}/data | Compute and return one widget's payload |
The data endpoint returns JSON for API consumers and an HTML fragment for HTMX requests (swap target is the widget's container div).
Two built-in widgets ship as public functions you register where you want them (they used to be auto-prepended; now you place and size them yourself):
builtin_total_models_widget()→ keyumbral_total_models(KPI) - count of all registered models.builtin_recent_users_widget()→ keyumbral_recent_users(Feed) - last 5auth_usersignups.
use umbral_admin::{AdminPlugin, builtin_total_models_widget, builtin_recent_users_widget}; AdminPlugin::default() .register_widget(builtin_total_models_widget()) .register_widget(builtin_recent_users_widget());File preview infrastructure
A preview_kind is resolved server-side from the MIME type and file extension so the front-end never has to sniff bytes:
use umbral_admin::{file_descriptor, resolve_preview_kind}; // Build a file descriptor JSON value for a given file.let desc = file_descriptor( "report.pdf", 184320, "application/pdf", "/admin/api/files/tok123", None, // thumbnail_url);// desc["preview_kind"] == "pdf"preview_kind | Resolved by |
|---|---|
image | image/* MIME |
pdf | application/pdf MIME |
video | video/* MIME |
audio | audio/* MIME |
code | File extension: .rs, .py, .js, .ts, .json, .toml, .yaml, .html, .css, .sql, .sh, .md, .mdx, and more |
text | Extension .txt, .log or text/plain MIME |
download | Everything else (zip, tar, binaries, unknown) |
Preview macros live in templates/_macros/previews/: image.html (thumbnail + lightbox), pdf.html (<embed>), video_audio.html (HTML5 players), code_text.html (syntax highlighted <pre>), download.html (download card with type glyph).
Command palette (⌘K)
Pressing ⌘K (macOS) or Ctrl-K (all platforms) opens a centered modal palette. The palette is also triggered by clicking the search bar in the topbar.
Features:
- Jump targets: every registered model in the sidebar (with its Lucide icon).
- Fixed commands: Toggle theme, Logout.
- Client-side search filtering (no server round-trip per keystroke).
- Keyboard navigation: arrow keys move between items; Enter activates; Escape closes.
The palette fragment is HTMX-loaded from:
GET /admin/api/palette→ HTML fragment injected into #umbral-palette-slotThe palette slot (<div id="umbral-palette-slot">) lives in wrapper.html so it is available on every authenticated admin page.
Deferred admin features
| Feature | Status |
|---|---|
| Per-user drag-resize dashboard layout editor | Deferred - data layer + endpoints are wired; a future JS drag-drop UI just calls PUT /admin/api/dashboard/layout |
Actual file serving (GET /admin/api/files/{token}) | Deferred - HMAC-signed token + streaming + range headers land when the ORM File field type lands |
| Thumbnail generation | Deferred (same milestone as file serving) |
| Rich form widgets for Postgres-only fields | Basic - the types store/query fine on Postgres; the admin renders a text input (dedicated widgets deferred) |
| Per-row permissions | Binary is-staff gating only |
For the design spec see docs/admin-backend/backend-prd.md and docs/admin-backend/design-prd.md.
What is NOT in v1
- Postgres-only field types in a SQLite-backed admin. The ORM ships these
Postgres-only column types -
Array,Inet,MacAddr,FullText(tsvector), andDecimal- and they store and query in a Postgres-backed admin. What's deferred: they fail the boot-time system check on a SQLite backend (so a SQLite admin can't include them), and the admin renders them with a basic text input pending dedicated widgets. (Cidrexists as a column type but isn't reachable from#[derive(Model)]yet, and PostGIS isn't implemented - see gaps2 #70.) - Per-row permissions. Auth gating is binary: staff or not. Row-level restrictions and per-model action gating are deferred.
The spec lives in docs/specs/02-plugin-contract.md. Source: plugins/umbral-admin/src/lib.rs and plugins/umbral-admin/src/config.rs.