This version is in beta. Some features may change before release.

Dashboard widgets

KPI cards, line + donut + bar charts, tables, feed widgets, declared in Rust, rendered via ApexCharts, grouped into named sections.

The admin dashboard is more than a list of tables. Cards summarise the day, charts trace trends, tables surface recent activity, donuts show distribution. Each one is a Widget: declared in Rust, registered against AdminPlugin, and rendered through ApexCharts (charts) or minijinja (cards / tables / feed).

The shape is the same across every kind: pick a WidgetKind, declare a default_span (grid cells wide by rows tall), and hand a WidgetDataFn async closure that returns a typed payload.

A widget in 10 lines

Code
rust
use umbral_admin::{
CardPayload, Widget, WidgetDataFn, WidgetKind, WidgetPayload,
};
 
pub fn shop_customers_card() -> Widget {
Widget::new(
"shop_customers",
"Customers",
WidgetKind::Card,
WidgetDataFn::new(|_user| async move {
let total = Customer::objects().count().await.unwrap_or(0);
WidgetPayload::Card(
CardPayload::new(umbral_admin::humanize_number(total as f64))
.unit("total")
.icon("users")
.subtitle("All time"),
)
}),
)
.with_span(3, 2)
}
Warning
Prefer `Widget::new(..)` over a `Widget { .. }` struct literal. `Widget` grows fields as the dashboard grows — `filters` was added this way — and every new field breaks every literal that spells out the old ones. The builder makes the next field free.

Then register it in the dashboard:

Code
rust
.plugin(
AdminPlugin::default()
.dashboard_section(
umbral_admin::WidgetSection::new("Overview")
.widget(shop_customers_card()),
)
)

Widget kinds

KindWhen to reach for itPayload
CardKPI tiles: sales total, order count, AOV. Icon + headline value + optional growth-vs-previous-period + sparkline trail.CardPayload
LineTime series. Single series (one metric over time) or multi (overlay 2-5 metrics on the same x-axis).LinePayload
DonutCategorical breakdown: 3-6 buckets (status mix, top regions). Center label shows the total.DonutPayload
BarCategorical counts that exceed donut's 6-slice readability.BarPayload
RadialProgress toward a goal: one or more 0-100% gauges (quota attainment, capacity used, completion rate). One big ring, or 2-4 to compare ratios.RadialPayload
HeatmapActivity-by-time / cohort patterns: a 2-D grid of cells colored by magnitude (day by hour signups, retention by cohort).HeatmapPayload
Progress"Top N by metric": a ranked list of labeled horizontal bars (revenue by product, traffic by source). Pure HTML, no chart lib.ProgressPayload
Table"Recent orders / posts / comments." Three or four columns, 5-10 rows, optional "View all" link.TablePayload
FeedChronological activity: recent signups, comments, audit log entries. Avatar + title + timestamp per item.FeedPayload
KpiThe original simple stat card. Superseded by Card for most cases; kept for backwards compat.KpiPayload

Span: sizing widgets on the grid

The dashboard is a 12-column grid; rows auto-size to 120px each. Span { cols, rows } says how many cells a widget occupies.

Code
rust
default_span: Span { cols: 3, rows: 2 }, // 1/4 width, 240px tall, standard card
default_span: Span { cols: 8, rows: 3 }, // 2/3 width, 360px tall, line chart
default_span: Span { cols: 12, rows: 4 }, // full width, 480px tall, big table

A user override (via Widget::with_span(cols, rows)) wins over default_span at registration time:

Code
rust
umbral_admin::builtin_total_models_widget().with_span(8, 2)

Sections: grouping widgets

WidgetSection is a named group with a title + optional subtitle + its own widget grid:

Code
rust
.dashboard_section(
WidgetSection::new("Sales overview")
.subtitle("Daily KPIs across the storefront")
.widget(shop_total_sales_widget())
.widget(shop_orders_widget())
.widget(shop_customers_widget())
.widget(shop_avg_order_value_widget()),
)
.dashboard_section(
WidgetSection::new("Trends")
.subtitle("Daily sales + activity + status mix")
.widget(shop_daily_sales_chart())
.widget(shop_activity_chart())
.widget(shop_order_status_donut()),
)

Sections render top-to-bottom in registration order with a heading band between each. Past roughly 3 widgets per section, sections beat a single mega-grid for visual scanability.

Card

The everyday KPI tile: icon top-left, optional growth-vs-prior pill top-right, label, big humanized value, subtitle, optional sparkline trail.

Code
rust
data: WidgetDataFn::new(|_user| async move {
let now = chrono::Utc::now();
let month_ago = now - chrono::Duration::days(30);
let two_months_ago = now - chrono::Duration::days(60);
 
let current = sales_between(month_ago, now).await;
let previous = sales_between(two_months_ago, month_ago).await;
let trail = daily_sales_trail(30).await;
 
WidgetPayload::Card(
CardPayload::new(umbral_admin::humanize_number(current))
.unit("USD")
.icon("dollar-sign") // lucide icon name
.subtitle("Last 30 days")
.growth(current, previous) // computes delta_percent
.delta_label("vs prior 30d".to_string())
.sparkline(trail), // Vec<f64> drawn as area chart
)
}),

humanize_number(12_438.0) returns "12.4K", giving the headline value room when the column gets narrow. Use format_thousands(12438.0) for "12,438" on tiles where the precise figure matters (AOV, invoice totals).

The sparkline goes through ApexCharts (not hand-rolled SVG). Pass any Vec<f64> and the renderer mounts a smoothed area chart in the card's trail area; keep it to 7-30 points. Span { cols: 3, rows: 2 } gives it 240px to fit icon row + body + trail without clipping.

Line

Two flavours: single-series (one metric over time, gradient area fill) and multi-series (2-5 metrics overlaid, solid colors). The same LinePayload shape covers both; the renderer picks the style based on how many series the payload carries.

Code
rust
data: WidgetDataFn::with_params(|_user, params| async move {
let days = params.period_days().unwrap_or(30);
let now = chrono::Utc::now();
let trail = daily_sales_trail(days).await;
let points: Vec<ChartPoint> = trail
.into_iter()
.enumerate()
.map(|(i, y)| {
let back = (days - 1 - i as i64).max(0);
let day = now - chrono::Duration::days(back);
ChartPoint { x: day.format("%b %-d").to_string(), y }
})
.collect();
WidgetPayload::Line(LinePayload {
series: vec![Series { name: "USD".to_string(), points }],
x_type: "date".to_string(),
})
}),

For multi-series, pass two or more Series. The renderer drops the heavy gradient fill so overlaid lines stay readable and surfaces a legend strip in the header:

Code
rust
WidgetPayload::Line(LinePayload {
series: vec![
Series { name: "Orders".into(), points: orders_points },
Series { name: "Items sold".into(), points: items_points },
Series { name: "New customers".into(), points: customers_points },
],
x_type: "date".to_string(),
})

Period chips

A line widget carries a [7d] [30d] [90d] chip strip in its header by default. Clicking re-fetches the widget with ?period=X, and the data closure reads it via params.period_days():

Code
rust
WidgetDataFn::with_params(|_user, params| async move {
let days = params.period_days().unwrap_or(30); // "7d" → 7
// ...
})

Pre-select the active chip with .with_default_period("7d"). For anything beyond a period — or on any other widget kind — declare filters, below.

Donut

Categorical breakdown summing to 100%. Best for 3-6 slices; past that, switch to Bar.

Compute the per-bucket counts in SQL with a grouped annotate, one row per status, rather than pulling every order into memory to tally client-side:

Code
rust
use umbral::orm::Aggregate;
 
data: WidgetDataFn::new(|_user| async move {
// GROUP BY status with COUNT(*) per group: one row
// per status, the count aliased as "count".
let rows = Order::objects()
.annotate(&["status"], &[("count", Aggregate::count())])
.await
.unwrap_or_default();
let mut counts: HashMap<String, f64> = HashMap::new();
for row in &rows {
if let (Some(label), Some(n)) = (
row.get("status").and_then(|v| v.as_str()),
row.get("count").and_then(|v| v.as_f64()),
) {
counts.insert(label.to_string(), n);
}
}
// Canonical lifecycle order: the donut reads as a flow,
// not random alphabetical buckets.
let order = ["pending", "paid", "shipped", "delivered", "cancelled"];
let mut pairs: Vec<(String, f64)> = Vec::new();
for k in order {
if let Some(v) = counts.remove(k) { pairs.push((k.to_string(), v)); }
}
WidgetPayload::Donut(DonutPayload::from_pairs(pairs))
}),
Info

annotate(&["status"], &[("count", Aggregate::count())]) returns [{ "status": "paid", "count": 12 }, ...]. The count happens in the database, so the widget scales to millions of orders without loading them. The canonical-order reshuffle stays client-side (it's presentation, not aggregation). See Aggregates and annotate for Sum/Avg/multi-column grouping.

DonutPayload::from_pairs([(label, value), ...]) is the short constructor. For explicit colors per slice, build DonutSlice { label, value, color: Some("#34d399") } and pass to DonutPayload::new(slices).

The chart center shows the total automatically. Legend renders to the right (or below on narrow viewports).

Bar

Same Series shape as Line, rendered as vertical bars. Use for low-cardinality categorical counts that exceed donut's 6-slice readability.

Code
rust
WidgetPayload::Bar(BarPayload {
series: vec![Series {
name: "Models".into(),
points: counts.into_iter()
.map(|(plugin, n)| ChartPoint { x: plugin, y: n as f64 })
.collect(),
}],
x_type: "category".to_string(),
})

Radial

A gauge of one or more 0-100% tracks, rendered as concentric arcs (ApexCharts radialBar). Reach for it when a metric is a ratio against a target (quota attainment, capacity used, completion rate, SLA) where a card's bare number hides how close you are to "done".

The everyday case is a single ring built straight from the current value and its target; the percent lands in the centre:

Code
rust
use umbral_admin::widgets::{RadialPayload, WidgetPayload};
 
// 73% of the monthly sales goal.
WidgetPayload::Radial(RadialPayload::goal("Monthly goal", sales, target))

goal(label, current, target) computes current / target * 100 and clamps to [0, 100] (a non-positive target reads as 0%). For a percentage you already have, use RadialPayload::single(label, percent).

Pass two to four tracks to compare related ratios on one tile:

Code
rust
// Per-plan conversion, compared at a glance.
WidgetPayload::Radial(RadialPayload::from_pairs([
("Free", 8.0),
("Pro", 21.5),
("Team", 34.0),
]))

Every percent is clamped to [0, 100] (non-finite to 0), so an overshoot fills the ring rather than overrunning it. For an explicit arc color, build RadialTrack { label, value, color: Some("#34d399") } and pass to RadialPayload::new(tracks).

Heatmap

A 2-D grid of cells colored by magnitude (ApexCharts heatmap). Reach for it when the story is when or where something concentrates: signups by weekday and hour, retention by cohort and week, load by region and day.

The dense-grid constructor takes row labels, the shared column labels, and a values[row][col] matrix:

Code
rust
use umbral_admin::widgets::{HeatmapPayload, WidgetPayload};
 
WidgetPayload::Heatmap(HeatmapPayload::from_grid(
["Mon", "Tue", "Wed", "Thu", "Fri"],
["00-06", "06-12", "12-18", "18-24"],
signups_by_day_and_bucket, // Vec<Vec<f64>>, one inner vec per row
))

from_grid is rectangular by construction: a short value row is padded with 0.0 and any extra values past the column labels are dropped, so ragged input never desyncs the grid. For full control build HeatmapRow { name, cells: vec![HeatmapCell { x, y }] } and pass to HeatmapPayload::new(rows).

Progress

A ranked list of labeled horizontal bars, "top N by metric": revenue by product, traffic by source, completion per category. It's the readable middle ground between a Donut (proportions, but circular and capped at roughly 6 slices) and a Table (exact numbers, but no visual weight). Rendered as pure HTML; no chart library mounts.

By default each bar is sized relative to the largest value, so the top item fills the bar:

Code
rust
use umbral_admin::widgets::{ProgressPayload, WidgetPayload};
 
WidgetPayload::Progress(ProgressPayload::from_pairs([
("Pro", 48_200.0),
("Team", 31_000.0),
("Free", 9_400.0),
]))

When each row should be measured against a fixed target instead (a per-row "% of goal"), use from_pairs_of(pairs, target). Values over the target clamp to a full bar, and a non-positive target falls back to sizing against the max:

Code
rust
WidgetPayload::Progress(ProgressPayload::from_pairs_of(
[("Web", 82.0), ("Mobile", 57.0)],
100.0, // 100-task target
))

display (the right-aligned value) is thousands-grouped for you; percent is the clamped [0, 100] bar width. For explicit per-bar colors or pre-formatted values, build ProgressItem { label, display, percent, color } and pass to ProgressPayload::new(items).

Table

Three to four columns, 5-10 rows, optional "View all" link in the header.

Code
rust
data: WidgetDataFn::new(|_user| async move {
let columns = vec![
TableColumn { key: "number".into(), label: "Order".into() },
TableColumn { key: "status".into(), label: "Status".into() },
TableColumn { key: "grand_total".into(), label: "Total".into() },
];
let rows: Vec<serde_json::Value> = Order::objects()
.order_by(order::PLACED_AT.desc())
.limit(5)
.fetch().await.unwrap_or_default()
.into_iter()
.map(|o| serde_json::json!({
"number": o.number,
"status": format!("{:?}", o.status).to_lowercase(),
"grand_total": format!("${}", o.grand_total),
}))
.collect();
WidgetPayload::Table(
TablePayload::new(columns, rows)
.view_all_for::<Order>(), // auto-resolves to {admin_base}/order/
)
}),

The view_all_for::<T>() helper takes any T: Model and resolves to the admin changelist URL for that table ({admin_base}/<table>/). No string-typing, rename-safe: a #[umbral(table = "...")] change to the model propagates without chasing strings in the widget code. For a non-managed target, set view_all_url(...) explicitly.

Feed

Chronological activity stream. Each FeedItem reads as a subject-verb-object sentence with a timestamp: actor did verb to object at some time. object_link makes the object clickable.

Code
rust
WidgetPayload::Feed(FeedPayload {
items: AuthUser::objects()
.order_by(auth_user::DATE_JOINED.desc())
.limit(5)
.fetch().await.unwrap_or_default()
.into_iter()
.map(|u| FeedItem {
actor: u.username.clone(),
verb: "signed".into(),
object: "up".into(),
object_link: None,
at: u.date_joined.to_rfc3339(),
})
.collect(),
view_all_url: Some("/admin/auth_user/".into()),
})

The at value is an RFC 3339 timestamp string; the feed template renders it through the admin's naturaltime filter ("2 hours ago") with the absolute date in a hover tooltip.

WidgetDataFn: closure shapes

Two constructors:

Code
rust
// Per-request params dropped: use for widgets that always render
// the same shape (count tiles, registry-size cards, etc.)
WidgetDataFn::new(|user| async move {
...
})
 
// Per-request params passed in: use for widgets that read query
// state like period chips, status filters, etc.
WidgetDataFn::with_params(|user, params| async move {
let days = params.period_days().unwrap_or(30);
...
})

WidgetParams carries period: Option<String>, start: Option<String>, end: Option<String>, plus a catch-all raw: HashMap<String, String> for widget-specific query params.

Built-ins

Two builtins ship with umbral-admin:

  • umbral_admin::builtin_total_models_widget(): a bar chart of model counts grouped by plugin.
  • umbral_admin::builtin_recent_users_widget(): a feed of the 5 most-recent signups.

Both are regular Widget values you opt into via .widget(...) on a section. Register them where you want, override with_span(cols, rows) for a non-default size, and skip them if your dashboard doesn't need them.

Code
rust
.dashboard_section(
WidgetSection::new("System")
.subtitle("Framework-wide health")
.widget(umbral_admin::builtin_total_models_widget().with_span(8, 2))
.widget(umbral_admin::builtin_recent_users_widget().with_span(4, 2)),
)

Model cards section

Below the widget sections, the dashboard renders one card per registered model, quick access to every CRUD table. Configure via:

Code
rust
AdminPlugin::default()
.dashboard_models_title("Data")
.dashboard_models_subtitle("Jump straight to a managed table")
.dashboard_models_only(&umbral::models![ // type-safe table list
ecommerce::models::Product,
ecommerce::models::Order,
ecommerce::models::Customer,
content::models::Post,
])

umbral::models![T, U, V] expands to [T::TABLE, U::TABLE, V::TABLE], rename-safe (a #[umbral(table = "...")] change propagates) and type-checked at compile time.

Three modes:

  • .dashboard_models_only(&[...]): show exactly these tables
  • .dashboard_models_hidden(): hide the section entirely
  • (default): show every registered model card

How it wires together

The runtime contract:

  1. AdminPlugin::dashboard_section(...) collects WidgetSection values during App build.
  2. The dashboard handler (GET /admin/) renders the section frame for each section + a placeholder skeleton per widget.
  3. Each skeleton kicks off an HTMX hx-get="/admin/api/dashboard/widgets/<key>/data" request.
  4. The widget-data handler calls the widget's data: WidgetDataFn, which returns a WidgetPayload.
  5. The handler renders the per-kind macro (card.html / line.html / donut.html / etc.) and HTMX swaps it into the placeholder.
  6. ApexCharts mounts on [data-umbral-chart="<kind>"] divs that the macros emit.

The skeleton-first shape means the dashboard paints fast: every widget loads in parallel after the initial HTML lands.

  • Plugin trait: AdminPlugin IS a plugin; widgets are how it exposes a customisation surface.
  • Admin plugin: the broader admin features (CRUD, filters, search, permissions).
  • user in templates: widget headers can branch on user.is_staff for staff-only widgets.

Filters: making a widget answer a question

A widget with no controls answers one fixed question. Declare filters and the admin renders controls in its header — on any widget kind, not just charts:

Code
rust
use umbral_admin::{Widget, WidgetDataFn, WidgetFilter, WidgetKind};
 
Widget::new("orders_by_status", "Orders", WidgetKind::Bar, WidgetDataFn::with_params(
|_user, params| async move {
let status = params.choice("status").unwrap_or("open");
let days = params.period_days().unwrap_or(30);
// an explicit range beats a period preset
if let Some((start, end)) = params.date_range() {
// ...
}
// ... build the payload
},
))
.filter(WidgetFilter::period_default()) // [7d] [30d] [90d]
.filter(WidgetFilter::date_range()) // start → end
.filter(WidgetFilter::choice("status", "Status", [ // a <select>
("open", "Open"),
("paid", "Paid"),
]))

Three kinds, because each reaches the closure by a different route:

FilterControlRead it with
WidgetFilter::period([...])chip stripparams.period_days()
WidgetFilter::date_range()two date inputsparams.date_range()Some((start, end))
WidgetFilter::choice(key, label, [...])<select>params.choice(key)

WidgetFilter::period_default() is the conventional 7d / 30d / 90d strip. Give any filter a first-paint value with .with_default("paid").

Info

Filters are sticky. The value a user picks is saved to their admin preferences and restored on the next visit — a dashboard you re-open every morning shouldn't make you re-pick "status = paid" every time. Selecting one control also preserves the others, so choosing a status doesn't silently reset the period.

A half-specified date range is ignored: "from March" with no end date is not a window, and guessing an end would show data nobody asked for. Fill both boxes.

Export: the rows behind the picture

Any widget whose payload has rows — line, bar, donut, radial, heatmap, progress, table — gets a CSV link in its header. The export runs the widget's own data closure with the same filters currently in force, so the file matches the chart you exported it from.

Code
txt
GET /admin/api/dashboard/widgets/{key}/export.csv?status=paid&period=7d

A KPI tile or a feed exports nothing (404): a single number is not a table. The endpoint enforces the same permission gates as the data endpoint, so export can never become the softer way to read numbers you're not allowed to see.

Reordering: the dashboard is the user's

Drag any widget onto another and the grid reorders. The new arrangement is saved per user (PUT /admin/api/dashboard/layout) and honoured on the next render, spans included.

A widget the saved layout doesn't mention — a plugin you installed last week — still appears, after the placed ones, rather than vanishing because it was missing from a layout saved a month ago. A layout is a preference, never a permission: an entry for a widget you may not see is ignored.

Reports

A report page is just a custom view holding filterable widgets — a filter strip per widget, a CSV link per widget, and the whole page behind a permission:

Code
rust
AdminPlugin::default().view(
AdminView::new("reports/sales", "Sales report")
.with_permission("shop.view_report")
.section(
WidgetSection::new("Revenue")
.widget(revenue_by_month()) // .filter(WidgetFilter::date_range())
.widget(orders_by_status()), // .filter(WidgetFilter::choice(..))
),
)

See Custom views for the page itself.