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
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)}Then register it in the dashboard:
.plugin( AdminPlugin::default() .dashboard_section( umbral_admin::WidgetSection::new("Overview") .widget(shop_customers_card()), ))Widget kinds
| Kind | When to reach for it | Payload |
|---|---|---|
Card | KPI tiles: sales total, order count, AOV. Icon + headline value + optional growth-vs-previous-period + sparkline trail. | CardPayload |
Line | Time series. Single series (one metric over time) or multi (overlay 2-5 metrics on the same x-axis). | LinePayload |
Donut | Categorical breakdown: 3-6 buckets (status mix, top regions). Center label shows the total. | DonutPayload |
Bar | Categorical counts that exceed donut's 6-slice readability. | BarPayload |
Radial | Progress 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 |
Heatmap | Activity-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 |
Feed | Chronological activity: recent signups, comments, audit log entries. Avatar + title + timestamp per item. | FeedPayload |
Kpi | The 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.
default_span: Span { cols: 3, rows: 2 }, // 1/4 width, 240px tall, standard carddefault_span: Span { cols: 8, rows: 3 }, // 2/3 width, 360px tall, line chartdefault_span: Span { cols: 12, rows: 4 }, // full width, 480px tall, big tableA user override (via Widget::with_span(cols, rows)) wins over default_span at registration time:
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:
.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.
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.
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:
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():
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:
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))}),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.
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:
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:
// 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:
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:
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:
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.
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.
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:
// 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.
.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:
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:
AdminPlugin::dashboard_section(...)collectsWidgetSectionvalues during App build.- The dashboard handler (
GET /admin/) renders the section frame for each section + a placeholder skeleton per widget. - Each skeleton kicks off an HTMX
hx-get="/admin/api/dashboard/widgets/<key>/data"request. - The widget-data handler calls the widget's
data: WidgetDataFn, which returns aWidgetPayload. - The handler renders the per-kind macro (
card.html/line.html/donut.html/ etc.) and HTMX swaps it into the placeholder. - 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.
Related
- Plugin trait:
AdminPluginIS 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_stafffor 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:
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:
| Filter | Control | Read it with |
|---|---|---|
WidgetFilter::period([...]) | chip strip | params.period_days() |
WidgetFilter::date_range() | two date inputs | params.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").
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.
GET /admin/api/dashboard/widgets/{key}/export.csv?status=paid&period=7dA 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:
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.