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, Span, Widget, WidgetDataFn, WidgetKind, WidgetPayload,
};
 
pub fn shop_customers_card() -> Widget {
Widget {
key: "shop_customers",
title: "Customers".to_string(),
kind: WidgetKind::Card,
default_span: Span { cols: 3, rows: 2 },
permission: None,
default_period: None,
data: 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"),
)
}),
}
}

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

Line widgets can carry a [7d] [30d] [90d] chip strip in the header. Clicking re-fetches the widget data with ?period=X. The data closure reads via params.period_days():

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

Pre-select the active chip on first load via .with_default_period("7d"):

Code
rust
.widget(shop_daily_sales_chart().with_default_period("7d"))

When the URL has no ?period=, the handler stamps params.period = Some("7d") before calling the data fn, so the template renders the matching chip highlighted and the data closure sees the same period. One source of truth: the URL.

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.